From 6271f4b54a33c8fdfbe271037d2356e9620a63d5 Mon Sep 17 00:00:00 2001 From: atagen Date: Mon, 9 Mar 2026 13:58:20 +1100 Subject: [PATCH] pwa module, jay, many thing --- common/nix/substituters.nix | 2 + common/security.nix | 3 - common/terminal.nix | 17 - flake.lock | 154 +- flake.nix | 11 +- graphical/audio-control.nix | 2 +- graphical/browser.nix | 1 + graphical/desktop/jay.nix | 74 + graphical/desktop/niri.kdl | 4 +- ...nfigurable-gap-between-tiled-windows.patch | 196 +++ ...nd-size-animations-for-tiled-windows.patch | 576 ++++++++ ...-border-frames-when-gaps-are-enabled.patch | 201 +++ ...ial-animation-mode-for-tiled-windows.patch | 545 +++++++ ...005-add-cursor-follows-focus-setting.patch | 276 ++++ ...us-between-floating-and-tiled-layers.patch | 199 +++ ...n-close-animations-for-tiled-windows.patch | 975 +++++++++++++ ...focus-navigation-for-floating-window.patch | 90 ++ ...configurable-animation-easing-curves.patch | 566 ++++++++ ...dd-rounded-corners-for-window-frames.patch | 1264 +++++++++++++++++ graphical/desktop/patches/FEATURES.md | 217 +++ graphical/desktop/quickshell/shell.qml | 282 ++-- graphical/desktop/shell.nix | 49 +- graphical/desktop/wm.nix | 65 +- graphical/dev.nix | 11 +- graphical/integrations.nix | 16 - graphical/kernel.nix | 2 +- graphical/quick-services.nix | 50 +- graphical/rice.nix | 1 + graphical/startup.nix | 53 +- graphical/terminal.nix | 7 + hosts/quiver/hw.nix | 2 +- hosts/quiver/input.nix | 2 +- 32 files changed, 5580 insertions(+), 333 deletions(-) create mode 100644 graphical/desktop/jay.nix create mode 100644 graphical/desktop/patches/0001-add-configurable-gap-between-tiled-windows.patch create mode 100644 graphical/desktop/patches/0002-add-position-and-size-animations-for-tiled-windows.patch create mode 100644 graphical/desktop/patches/0003-add-window-border-frames-when-gaps-are-enabled.patch create mode 100644 graphical/desktop/patches/0004-add-sequential-animation-mode-for-tiled-windows.patch create mode 100644 graphical/desktop/patches/0005-add-cursor-follows-focus-setting.patch create mode 100644 graphical/desktop/patches/0006-add-toggle-focus-between-floating-and-tiled-layers.patch create mode 100644 graphical/desktop/patches/0007-add-open-close-animations-for-tiled-windows.patch create mode 100644 graphical/desktop/patches/0008-add-directional-focus-navigation-for-floating-window.patch create mode 100644 graphical/desktop/patches/0009-add-configurable-animation-easing-curves.patch create mode 100644 graphical/desktop/patches/0010-add-rounded-corners-for-window-frames.patch create mode 100644 graphical/desktop/patches/FEATURES.md diff --git a/common/nix/substituters.nix b/common/nix/substituters.nix index 9ccde0e..f0d5d58 100644 --- a/common/nix/substituters.nix +++ b/common/nix/substituters.nix @@ -11,6 +11,7 @@ scope "nix.settings" { "https://cache.flox.dev" "https://cache.amaanq.com" "https://cache.nixos-cuda.org" + "https://niri.cachix.org" ]; trusted-public-keys = [ "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" @@ -21,5 +22,6 @@ scope "nix.settings" { "flox-cache-public-1:7F4OyH7ZCnFhcze3fJdfyXYLQw/aV7GEed86nQ7IsOs=" "cache.amaanq.com:H0iXsEEFsvUNtWb5v9V8Kss+L4F/tnXwDHXcY+xbmKk=" "cache.nixos-cuda.org:74DUi4Ye579gUqzH4ziL9IyiJBlDpMRn9MBN8oNan9M=" + "niri.cachix.org-1:Wv0OmO7PsuocRKzfDoJ3mulSl7Z6oezYhGhR+3W2964=" ]; } diff --git a/common/security.nix b/common/security.nix index ea76c0d..0b19f74 100644 --- a/common/security.nix +++ b/common/security.nix @@ -45,7 +45,4 @@ StartLimitBurst = 5; }; }; - # niri-flake is death - systemd.user.services.niri-flake-polkit = lib.mkForce { }; - services.gnome.gnome-keyring.enable = lib.mkForce false; } diff --git a/common/terminal.nix b/common/terminal.nix index ce1c828..dbb0099 100644 --- a/common/terminal.nix +++ b/common/terminal.nix @@ -8,23 +8,6 @@ }: let inherit (pkgs) nushell; - # init = - # let - # comma = lib.getExe' (getFlakePkg' inputs.nix-index-database "comma-with-db") "comma"; - # in - # '' - # function fish_greeting - # ${./rice/header.sh} - # echo "" - # end - # function fish_title - # set -q argv[1]; or set argv fish - # echo (fish_prompt_pwd_dir_length=100 prompt_pwd): $argv; - # end - # function fish_command_not_found - # ${comma} $argv - # end - # ''; prompt = '' do --env { def prompt-header [ diff --git a/flake.lock b/flake.lock index 31101de..0aaeaa0 100644 --- a/flake.lock +++ b/flake.lock @@ -25,11 +25,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1771605131, - "narHash": "sha256-9K3F2PSorw7cvqotXRLzz9wE29XfioMCtliFclkL/hM=", + "lastModified": 1772210137, + "narHash": "sha256-w5w8OAexQ8Ba62Yd/GG9xjvEp2U6G6ua3+ZCvSGcO7g=", "owner": "linyinfeng", "repo": "angrr", - "rev": "11fee1e3089bdbc0fb144366bc62ee8a95d4628f", + "rev": "3ab5cecfb8a478e3012606396ab36e1b793609f6", "type": "github" }, "original": { @@ -55,11 +55,11 @@ }, "bunker": { "locked": { - "lastModified": 1772004723, - "narHash": "sha256-sTAQT6QejSY5PSJuoCRtPBAGo/wgWzglgaFqtHvv9KQ=", + "lastModified": 1772253494, + "narHash": "sha256-qIVFT2H6YQbnFur0/aKkb2tbX6bppugdYVYZ3eMwzso=", "owner": "amaanq", "repo": "bunker-patches", - "rev": "4f77ba4e6b2579290514dcdcbbb5577b64b027cb", + "rev": "b5e79f6a3c6ccbbbe4065503b16e91a3c876ddef", "type": "github" }, "original": { @@ -239,11 +239,11 @@ "nixpkgs-lib": "nixpkgs-lib_3" }, "locked": { - "lastModified": 1769996383, - "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", + "lastModified": 1772408722, + "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", + "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", "type": "github" }, "original": { @@ -320,11 +320,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1771320802, - "narHash": "sha256-fVwjjcxivStYGSLOYJxtEISKXv/8/TxZI4EB+wqPBpc=", + "lastModified": 1772020767, + "narHash": "sha256-OxeMEMxRJ6dF3UGXVJoNRwxU/F1nOVbdcyX9n8S3Mxk=", "owner": "amaanq", "repo": "helium-flake", - "rev": "e8c651bc6b16925b1f5ede3ca2e206f22d0eb96c", + "rev": "9d3ef138f70b3540397320d25ead6aa96101371d", "type": "github" }, "original": { @@ -419,6 +419,22 @@ "url": "https://git.lobotomise.me/atagen/hudcore-plymouth" } }, + "jay-src": { + "flake": false, + "locked": { + "lastModified": 1772808967, + "narHash": "sha256-F4ZYChLfWymZhdUZIRuz1HNt2htm3vawAh9BY3yuWBU=", + "owner": "mahkoh", + "repo": "jay", + "rev": "893be823b6e215eea14bfc11f3025aa6dd1d6c70", + "type": "github" + }, + "original": { + "owner": "mahkoh", + "repo": "jay", + "type": "github" + } + }, "kitty-themes-src": { "flake": false, "locked": { @@ -545,11 +561,11 @@ "xwayland-satellite-unstable": "xwayland-satellite-unstable" }, "locked": { - "lastModified": 1771940378, - "narHash": "sha256-qe5t8E8uK5eSgPTxtfcde3VO8fnIr/Tu+hn72FDry/E=", + "lastModified": 1772631301, + "narHash": "sha256-wAHeBX+aSiA+cAlWtwe5NisjMfrJq6WxrtT+CE7/tFM=", "owner": "sodiboo", "repo": "niri-flake", - "rev": "f8899e60a1425d21a03a05ac2c069a85398039b5", + "rev": "110e61e49828860499ead8bc539470f1261896ae", "type": "github" }, "original": { @@ -616,11 +632,11 @@ "niri-unstable": { "flake": false, "locked": { - "lastModified": 1771849386, - "narHash": "sha256-CFvjBjS2LxbBMR3Lu6wZhME6ck3CXyKUufRoJA5tlmw=", + "lastModified": 1772207631, + "narHash": "sha256-Jkkg+KqshFO3CbTszVVpkKN2AOObYz+wMsM3ONo1z5g=", "owner": "YaLTeR", "repo": "niri", - "rev": "2dc6f4482c4eeed75ea8b133d89cad8658d38429", + "rev": "e708f546153f74acf33eb183b3b2992587a701e5", "type": "github" }, "original": { @@ -653,11 +669,11 @@ ] }, "locked": { - "lastModified": 1771520882, - "narHash": "sha256-9SeTZ4Pwr730YfT7V8Azb8GFbwk1ZwiQDAwft3qAD+o=", + "lastModified": 1771992996, + "narHash": "sha256-Y/ijH/unOPxzUicbla6yT/14RJgubUWnY2I2A6Ast2Q=", "owner": "nix-darwin", "repo": "nix-darwin", - "rev": "6a7fdcd5839ec8b135821179eea3b58092171bcf", + "rev": "3bfa436c1975674ca465ce34586467be301ff509", "type": "github" }, "original": { @@ -713,11 +729,11 @@ "nixpkgs": "nixpkgs_9" }, "locked": { - "lastModified": 1771734689, - "narHash": "sha256-/phvMgr1yutyAMjKnZlxkVplzxHiz60i4rc+gKzpwhg=", + "lastModified": 1772341813, + "narHash": "sha256-/PQ0ubBCMj/MVCWEI/XMStn55a8dIKsvztj4ZVLvUrQ=", "owner": "Mic92", "repo": "nix-index-database", - "rev": "8f590b832326ab9699444f3a48240595954a4b10", + "rev": "a2051ff239ce2e8a0148fa7a152903d9a78e854f", "type": "github" }, "original": { @@ -821,11 +837,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", + "lastModified": 1771848320, + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", "owner": "nixos", "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", "type": "github" }, "original": { @@ -867,11 +883,11 @@ }, "nixpkgs-lib_3": { "locked": { - "lastModified": 1769909678, - "narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=", + "lastModified": 1772328832, + "narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "72716169fe93074c333e8d0173151350670b824c", + "rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742", "type": "github" }, "original": { @@ -882,11 +898,11 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1771903837, - "narHash": "sha256-sdaqdnsQCv3iifzxwB22tUwN/fSHoN7j2myFW5EIkGk=", + "lastModified": 1772598333, + "narHash": "sha256-YaHht/C35INEX3DeJQNWjNaTcPjYmBwwjFJ2jdtr+5U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e764fc9a405871f1f6ca3d1394fb422e0a0c3951", + "rev": "fabb8c9deee281e50b1065002c9828f2cf7b2239", "type": "github" }, "original": { @@ -898,11 +914,11 @@ }, "nixpkgs-stable_2": { "locked": { - "lastModified": 1771903837, - "narHash": "sha256-sdaqdnsQCv3iifzxwB22tUwN/fSHoN7j2myFW5EIkGk=", + "lastModified": 1772598333, + "narHash": "sha256-YaHht/C35INEX3DeJQNWjNaTcPjYmBwwjFJ2jdtr+5U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e764fc9a405871f1f6ca3d1394fb422e0a0c3951", + "rev": "fabb8c9deee281e50b1065002c9828f2cf7b2239", "type": "github" }, "original": { @@ -929,11 +945,11 @@ }, "nixpkgs_11": { "locked": { - "lastModified": 1772016756, - "narHash": "sha256-noRPhcPF6zI2Wc3khn2Uo01AMmLO7CLFRcgSN1CQXSg=", + "lastModified": 1772671111, + "narHash": "sha256-ftGqh2Ps1bjPUxGPoCOua3nKHY96IyFPvPjWthafre0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "154d55d78649878684bc797cec119e66cceed8b5", + "rev": "9f8880d06df2c55d66df71495620ae1a51b68097", "type": "github" }, "original": { @@ -974,6 +990,22 @@ "type": "github" } }, + "nixpkgs_14": { + "locked": { + "lastModified": 1766651565, + "narHash": "sha256-QEhk0eXgyIqTpJ/ehZKg9IKS7EtlWxF3N7DXy42zPfU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3e2499d5539c16d0d173ba53552a4ff8547f4539", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_2": { "locked": { "lastModified": 1770562336, @@ -1056,11 +1088,11 @@ }, "nixpkgs_7": { "locked": { - "lastModified": 1771848320, - "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "lastModified": 1772542754, + "narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "rev": "8c809a146a140c5c8806f13399592dbcb1bb5dc4", "type": "github" }, "original": { @@ -1088,11 +1120,11 @@ }, "nixpkgs_9": { "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", + "lastModified": 1772198003, + "narHash": "sha256-I45esRSssFtJ8p/gLHUZ1OUaaTaVLluNkABkk6arQwE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", + "rev": "dd9b079222d43e1943b6ebd802f04fd959dc8e61", "type": "github" }, "original": { @@ -1133,6 +1165,7 @@ "hjem": "hjem", "hjem-rum": "hjem-rum", "hudcore": "hudcore", + "jay-src": "jay-src", "meat": "meat", "nil": "nil", "niri": "niri", @@ -1148,6 +1181,7 @@ "run0-shim": "run0-shim", "stash": "stash", "stasis": "stasis", + "tuigreet": "tuigreet", "yoke": "yoke" } }, @@ -1330,11 +1364,11 @@ "nixpkgs": "nixpkgs_13" }, "locked": { - "lastModified": 1772174212, - "narHash": "sha256-Rq3JnZAYzysIPdcVVM/ctKBARPGxKzzae2owVwqNPt8=", + "lastModified": 1772661948, + "narHash": "sha256-YNwi8tvrLkfmPd+4Sofk00Ev44qJReDTLV+VHpODy0U=", "owner": "saltnpepper97", "repo": "stasis", - "rev": "6ce1a1391e1157457a588701b8ca21e3d72fd7f1", + "rev": "043880f89ec3f7def5c92d7a6f2e32967dd7a9f2", "type": "github" }, "original": { @@ -1511,6 +1545,24 @@ "type": "github" } }, + "tuigreet": { + "inputs": { + "nixpkgs": "nixpkgs_14" + }, + "locked": { + "lastModified": 1771455425, + "narHash": "sha256-vaiq9NaaXM4oNIXxcff3EXp3T2Mu3OLxFyFK+el4BZs=", + "owner": "notashelf", + "repo": "tuigreet", + "rev": "b05e1c335cc79881b3c4822f8c8192c38efb2d80", + "type": "github" + }, + "original": { + "owner": "notashelf", + "repo": "tuigreet", + "type": "github" + } + }, "unf": { "inputs": { "ndg": "ndg_2", @@ -1550,11 +1602,11 @@ "xwayland-satellite-unstable": { "flake": false, "locked": { - "lastModified": 1771787042, - "narHash": "sha256-7bM6Y4KldhKnfopSALF8XALxcX7ehkomXH9sPl4MXp0=", + "lastModified": 1772429643, + "narHash": "sha256-M+bAeCCcjBnVk6w/4dIVvXvpJwOKnXjwi/lDbaN6Yws=", "owner": "Supreeeme", "repo": "xwayland-satellite", - "rev": "33c344fee50504089a447a8fef5878cf4f6215fc", + "rev": "10f985b84cdbcc3bbf35b3e7e43d1b2a84fa9ce2", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e306cc3..d90c890 100644 --- a/flake.nix +++ b/flake.nix @@ -21,8 +21,6 @@ inputs.nixpkgs.follows = "nixpkgs"; }; - niri.url = "github:sodiboo/niri-flake"; - hjem = { url = "github:feel-co/hjem"; inputs.nixpkgs.follows = "nixpkgs"; @@ -34,6 +32,8 @@ }; hudcore.url = "atagen:hudcore-plymouth"; + niri.url = "github:sodiboo/niri-flake"; + niri-tag = { url = "atagen:niri-tag"; inputs.nixpkgs.follows = "nixpkgs"; @@ -88,6 +88,13 @@ stash.url = "github:notashelf/stash"; + jay-src = { + url = "github:mahkoh/jay"; + flake = false; + }; + + tuigreet.url = "github:notashelf/tuigreet"; + }; } diff --git a/graphical/audio-control.nix b/graphical/audio-control.nix index 870503c..93f429b 100644 --- a/graphical/audio-control.nix +++ b/graphical/audio-control.nix @@ -8,5 +8,5 @@ volumeOSD = pkgs.avizo; inherit (pkgs) playerctl; }; - quick.services."avizo-service" = "${lib.getExe' pkgs.avizo "avizo-service"}"; + quick.services."avizo-service".cmd = "${lib.getExe' pkgs.avizo "avizo-service"}"; } diff --git a/graphical/browser.nix b/graphical/browser.nix index c8d0e7f..680ef68 100644 --- a/graphical/browser.nix +++ b/graphical/browser.nix @@ -28,6 +28,7 @@ let sponsorblock.id = "mnjggcdmjocbbbhaepdhchncahnbgone"; vimium-c.id = "hfjbmagddngcpeloejdejnfgbamkjaeg"; web-archives.id = "hkligngkgcpcolhcnkgccglchdafcnao"; + url-rewriter.id = "khncccgpokiedblbaahpfchghohlahje"; # webrtc-leak-shield.id = "bppamachkoflopbagkdoflbgfjflfnfl"; }; extensionStrings = map ( diff --git a/graphical/desktop/jay.nix b/graphical/desktop/jay.nix new file mode 100644 index 0000000..3428a67 --- /dev/null +++ b/graphical/desktop/jay.nix @@ -0,0 +1,74 @@ +{ + inputs, + pkgs, + lib, + getFlakePkg, + ... +}: +let + jay = pkgs.jay.overrideAttrs (prev: { + version = "unstable-slay-${toString inputs.jay-src.lastModified}"; + src = inputs.jay-src; + cargoDeps = pkgs.rustPlatform.importCargoLock { + lockFile = "${inputs.jay-src}/Cargo.lock"; + }; + patches = [ + ./patches/0001-add-configurable-gap-between-tiled-windows.patch + ./patches/0002-add-position-and-size-animations-for-tiled-windows.patch + ./patches/0003-add-window-border-frames-when-gaps-are-enabled.patch + ./patches/0004-add-sequential-animation-mode-for-tiled-windows.patch + ./patches/0005-add-cursor-follows-focus-setting.patch + ./patches/0006-add-toggle-focus-between-floating-and-tiled-layers.patch + ./patches/0007-add-open-close-animations-for-tiled-windows.patch + ./patches/0008-add-directional-focus-navigation-for-floating-window.patch + ]; + }); + + jay-session = pkgs.writeShellScript "jay-session" '' + systemctl --user import-environment + dbus-update-activation-environment --all + systemctl --user start jay-session-bridge.service & + exec ${lib.getExe jay} run + ''; +in +{ + environment.systemPackages = [ jay ]; + + services.greetd = { + enable = true; + settings.default_session.command = "${lib.getExe (getFlakePkg inputs.tuigreet)} --sessions /etc/greetd/wayland-sessions --remember-session"; + }; + + environment.etc."greetd/wayland-sessions/jay.desktop".text = '' + [Desktop Entry] + Name=Jay + Comment=A Wayland compositor written in Rust + Exec=${jay-session} + Type=Application + DesktopNames=jay + ''; + + # bridge service to activate graphical-session.target for direct-launched jay. + # waits for jay IPC readiness before pulling in the target. + systemd.user.services.jay-session-bridge = { + unitConfig = { + Description = "Activate graphical-session.target for direct-launched jay"; + BindsTo = [ "graphical-session.target" ]; + Before = [ "graphical-session.target" ]; + Wants = [ "graphical-session-pre.target" ]; + After = [ "graphical-session-pre.target" ]; + }; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Jay's IPC goes through the Wayland socket (unlike niri's separate IPC socket), + # so we can't use a jay subcommand without WAYLAND_DISPLAY already set. + # Wait for Jay to create its wayland socket in XDG_RUNTIME_DIR instead. + ExecStart = pkgs.writeShellScript "jay-ready" '' + until find "$XDG_RUNTIME_DIR" -maxdepth 1 -name "wayland-*" ! -name "*.lock" -type s | grep -q .; do + sleep 0.1 + done + ''; + }; + }; +} diff --git a/graphical/desktop/niri.kdl b/graphical/desktop/niri.kdl index ae7d653..277e09a 100644 --- a/graphical/desktop/niri.kdl +++ b/graphical/desktop/niri.kdl @@ -57,10 +57,10 @@ spawn-at-startup "systemctl" "--user" "start" "startup-sound.service" window-rule { match app-id="Bitwarden" open-floating true - default-column-width { proportion 0.2; } + default-column-width { proportion 0.333333; } } window-rule { match app-id="chrome-listen.lobotomise.me__-Default" open-floating true - default-column-width { proportion 0.2; } + default-column-width { proportion 0.333333; } } diff --git a/graphical/desktop/patches/0001-add-configurable-gap-between-tiled-windows.patch b/graphical/desktop/patches/0001-add-configurable-gap-between-tiled-windows.patch new file mode 100644 index 0000000..1a94795 --- /dev/null +++ b/graphical/desktop/patches/0001-add-configurable-gap-between-tiled-windows.patch @@ -0,0 +1,196 @@ +From c46dbd6689b1a69e23068334e784b53d1bccebb0 Mon Sep 17 00:00:00 2001 +From: atagen +Date: Sat, 7 Mar 2026 13:23:01 +1100 +Subject: [PATCH 01/10] add configurable gap between tiled windows + +Adds a `gap` theme size that controls spacing between tiled windows. +When gap > 0, windows use the gap as inter-window spacing instead of +border_width. The workspace rect is also inset by the gap amount to +provide outer padding around all windows. +--- + jay-config/src/theme.rs | 7 +++++++ + src/config/handler.rs | 1 + + src/theme.rs | 1 + + src/tree/container.rs | 10 ++++++---- + src/tree/output.rs | 15 +++++++++++++++ + toml-config/src/config.rs | 1 + + toml-config/src/config/parsers/theme.rs | 4 +++- + toml-config/src/lib.rs | 1 + + 8 files changed, 35 insertions(+), 5 deletions(-) + +diff --git a/jay-config/src/theme.rs b/jay-config/src/theme.rs +index 64883b09..134498d2 100644 +--- a/jay-config/src/theme.rs ++++ b/jay-config/src/theme.rs +@@ -363,5 +363,12 @@ pub mod sized { + /// + /// Default: 1 + const 04 => BAR_SEPARATOR_WIDTH, ++ /// The gap between tiled windows in pixels. ++ /// ++ /// When set to a value greater than 0, windows are separated by this ++ /// gap rather than by the border width. ++ /// ++ /// Default: 0 ++ const 05 => GAP, + } + } +diff --git a/src/config/handler.rs b/src/config/handler.rs +index 4ff91fe0..75f21dc5 100644 +--- a/src/config/handler.rs ++++ b/src/config/handler.rs +@@ -2466,6 +2466,7 @@ impl ConfigProxyHandler { + BORDER_WIDTH => ThemeSized::border_width, + BAR_HEIGHT => ThemeSized::bar_height, + BAR_SEPARATOR_WIDTH => ThemeSized::bar_separator_width, ++ GAP => ThemeSized::gap, + _ => return Err(CphError::UnknownSized(sized.0)), + }; + Ok(sized) +diff --git a/src/theme.rs b/src/theme.rs +index f470993e..5a895623 100644 +--- a/src/theme.rs ++++ b/src/theme.rs +@@ -512,6 +512,7 @@ sizes! { + bar_height = (0, 1000, 17), + border_width = (0, 1000, 4), + bar_separator_width = (0, 1000, 1), ++ gap = (0, 1000, 0), + } + + pub const DEFAULT_FONT: &str = "monospace 8"; +diff --git a/src/tree/container.rs b/src/tree/container.rs +index 7f48f2b1..587721ce 100644 +--- a/src/tree/container.rs ++++ b/src/tree/container.rs +@@ -447,6 +447,7 @@ impl ContainerNode { + fn perform_split_layout(self: &Rc) { + let sum_factors = self.sum_factors.get(); + let border_width = self.state.theme.sizes.border_width.get(); ++ let spacing = self.state.theme.sizes.gap.get().max(border_width); + let title_height_tmp = self.state.theme.title_height(); + let title_plus_underline_height = self.state.theme.title_plus_underline_height(); + let split = self.split.get(); +@@ -482,7 +483,7 @@ impl ContainerNode { + }; + let body = Rect::new_sized_saturating(x1, y1, width, height); + child.body.set(body); +- pos += body_size + border_width; ++ pos += body_size + spacing; + if split == ContainerSplit::Vertical { + pos += title_plus_underline_height; + } +@@ -522,7 +523,7 @@ impl ContainerNode { + }; + body = Rect::new_sized_saturating(x1, y1, width, height); + child.body.set(body); +- pos += size + border_width; ++ pos += size + spacing; + if split == ContainerSplit::Vertical { + pos += title_plus_underline_height; + } +@@ -545,11 +546,12 @@ impl ContainerNode { + + fn update_content_size(&self) { + let border_width = self.state.theme.sizes.border_width.get(); ++ let spacing = self.state.theme.sizes.gap.get().max(border_width); + let title_plus_underline_height = self.state.theme.title_plus_underline_height(); + let nc = self.num_children.get(); + match self.split.get() { + ContainerSplit::Horizontal => { +- let new_content_size = self.width.get().sub((nc - 1) as i32 * border_width).max(0); ++ let new_content_size = self.width.get().sub((nc - 1) as i32 * spacing).max(0); + self.content_width.set(new_content_size); + self.content_height + .set(self.height.get().sub(title_plus_underline_height).max(0)); +@@ -560,7 +562,7 @@ impl ContainerNode { + .get() + .sub( + title_plus_underline_height +- + (nc - 1) as i32 * (border_width + title_plus_underline_height), ++ + (nc - 1) as i32 * (spacing + title_plus_underline_height), + ) + .max(0); + self.content_height.set(new_content_size); +diff --git a/src/tree/output.rs b/src/tree/output.rs +index fd232355..c3e2ae3a 100644 +--- a/src/tree/output.rs ++++ b/src/tree/output.rs +@@ -814,6 +814,21 @@ impl OutputNode { + .set(bar_rect_with_separator_rel); + self.bar_separator_rect.set(bar_separator_rect); + self.bar_separator_rect_rel.set(bar_separator_rect_rel); ++ let gap = self.state.theme.sizes.gap.get(); ++ if gap > 0 { ++ workspace_rect = Rect::new_sized_saturating( ++ workspace_rect.x1() + gap, ++ workspace_rect.y1() + gap, ++ (workspace_rect.width() - 2 * gap).max(0), ++ (workspace_rect.height() - 2 * gap).max(0), ++ ); ++ workspace_rect_rel = Rect::new_sized_saturating( ++ workspace_rect_rel.x1() + gap, ++ workspace_rect_rel.y1() + gap, ++ (workspace_rect_rel.width() - 2 * gap).max(0), ++ (workspace_rect_rel.height() - 2 * gap).max(0), ++ ); ++ } + self.workspace_rect.set(workspace_rect); + self.workspace_rect_rel.set(workspace_rect_rel); + self.update_tray_positions(); +diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs +index 45a62ab6..6f8d1277 100644 +--- a/toml-config/src/config.rs ++++ b/toml-config/src/config.rs +@@ -209,6 +209,7 @@ pub struct Theme { + pub bar_font: Option, + pub bar_position: Option, + pub bar_separator_width: Option, ++ pub gap: Option, + } + + #[derive(Debug, Clone)] +diff --git a/toml-config/src/config/parsers/theme.rs b/toml-config/src/config/parsers/theme.rs +index efcf6110..dae2674d 100644 +--- a/toml-config/src/config/parsers/theme.rs ++++ b/toml-config/src/config/parsers/theme.rs +@@ -63,7 +63,7 @@ impl Parser for ThemeParser<'_> { + font, + title_font, + ), +- (bar_font, bar_position_val, bar_separator_width), ++ (bar_font, bar_position_val, bar_separator_width, gap), + ) = ext.extract(( + ( + opt(val("attention-requested-bg-color")), +@@ -93,6 +93,7 @@ impl Parser for ThemeParser<'_> { + recover(opt(str("bar-font"))), + recover(opt(str("bar-position"))), + recover(opt(s32("bar-separator-width"))), ++ recover(opt(s32("gap"))), + ), + ))?; + macro_rules! color { +@@ -146,6 +147,7 @@ impl Parser for ThemeParser<'_> { + bar_font: bar_font.map(|f| f.value.to_string()), + bar_position, + bar_separator_width: bar_separator_width.despan(), ++ gap: gap.despan(), + }) + } + } +diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs +index db9058e6..72c29b30 100644 +--- a/toml-config/src/lib.rs ++++ b/toml-config/src/lib.rs +@@ -980,6 +980,7 @@ impl State { + size!(TITLE_HEIGHT, title_height); + size!(BAR_HEIGHT, bar_height); + size!(BAR_SEPARATOR_WIDTH, bar_separator_width); ++ size!(GAP, gap); + macro_rules! font { + ($fun:ident, $field:ident) => { + if let Some(font) = &theme.$field { +-- +2.53.0 + diff --git a/graphical/desktop/patches/0002-add-position-and-size-animations-for-tiled-windows.patch b/graphical/desktop/patches/0002-add-position-and-size-animations-for-tiled-windows.patch new file mode 100644 index 0000000..1c0c1d4 --- /dev/null +++ b/graphical/desktop/patches/0002-add-position-and-size-animations-for-tiled-windows.patch @@ -0,0 +1,576 @@ +From bb20a9359b5f520aa3d52803ce4e7fc565c74a93 Mon Sep 17 00:00:00 2001 +From: atagen +Date: Sat, 7 Mar 2026 13:32:29 +1100 +Subject: [PATCH 02/10] add position and size animations for tiled windows + +Animate window position and size changes when layout shifts (e.g. +closing +a window causes neighbors to slide and resize). Uses ease-out-cubic +easing +with configurable duration via theme.animation-duration. Animation +deltas +are matched by node ID to handle insertions/removals correctly. Includes +seed_child_body() for cross-container moves. +--- + jay-config/src/theme.rs | 6 + + src/animation.rs | 37 ++++++ + src/config/handler.rs | 1 + + src/main.rs | 1 + + src/renderer.rs | 154 +++++++++++++++++++++++- + src/theme.rs | 1 + + src/tree/container.rs | 105 ++++++++++++++-- + toml-config/src/config.rs | 1 + + toml-config/src/config/parsers/theme.rs | 4 +- + toml-config/src/lib.rs | 1 + + 10 files changed, 298 insertions(+), 13 deletions(-) + create mode 100644 src/animation.rs + +diff --git a/jay-config/src/theme.rs b/jay-config/src/theme.rs +index 134498d2..53c53f8f 100644 +--- a/jay-config/src/theme.rs ++++ b/jay-config/src/theme.rs +@@ -370,5 +370,11 @@ pub mod sized { + /// + /// Default: 0 + const 05 => GAP, ++ /// The duration of window position/size animations in milliseconds. ++ /// ++ /// Set to 0 to disable animations. ++ /// ++ /// Default: 0 ++ const 06 => ANIMATION_DURATION, + } + } +diff --git a/src/animation.rs b/src/animation.rs +new file mode 100644 +index 00000000..328540b6 +--- /dev/null ++++ b/src/animation.rs +@@ -0,0 +1,37 @@ ++#[derive(Copy, Clone, Debug)] ++pub struct PositionAnimation { ++ pub offset_x: f64, ++ pub offset_y: f64, ++ pub offset_w: f64, ++ pub offset_h: f64, ++ pub start_usec: u64, ++ pub duration_usec: u64, ++} ++ ++fn ease_out_cubic(t: f64) -> f64 { ++ let t = 1.0 - t; ++ 1.0 - t * t * t ++} ++ ++impl PositionAnimation { ++ /// Returns the current animation offset, or None if the animation is complete. ++ pub fn current_offset(&self, now_usec: u64) -> Option<(i32, i32, i32, i32)> { ++ if self.duration_usec == 0 { ++ return None; ++ } ++ let elapsed = now_usec.saturating_sub(self.start_usec); ++ if elapsed >= self.duration_usec { ++ return None; ++ } ++ let t = elapsed as f64 / self.duration_usec as f64; ++ let remaining = 1.0 - ease_out_cubic(t); ++ let dx = (self.offset_x * remaining).round() as i32; ++ let dy = (self.offset_y * remaining).round() as i32; ++ let dw = (self.offset_w * remaining).round() as i32; ++ let dh = (self.offset_h * remaining).round() as i32; ++ if dx == 0 && dy == 0 && dw == 0 && dh == 0 { ++ return None; ++ } ++ Some((dx, dy, dw, dh)) ++ } ++} +diff --git a/src/config/handler.rs b/src/config/handler.rs +index 75f21dc5..56135590 100644 +--- a/src/config/handler.rs ++++ b/src/config/handler.rs +@@ -2467,6 +2467,7 @@ impl ConfigProxyHandler { + BAR_HEIGHT => ThemeSized::bar_height, + BAR_SEPARATOR_WIDTH => ThemeSized::bar_separator_width, + GAP => ThemeSized::gap, ++ ANIMATION_DURATION => ThemeSized::animation_duration, + _ => return Err(CphError::UnknownSized(sized.0)), + }; + Ok(sized) +diff --git a/src/main.rs b/src/main.rs +index 129978a5..b892bc44 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -48,6 +48,7 @@ mod leaks; + mod tracy; + mod acceptor; + mod allocator; ++mod animation; + mod async_engine; + mod backend; + mod backends; +diff --git a/src/renderer.rs b/src/renderer.rs +index 6acd59e5..a0e81fca 100644 +--- a/src/renderer.rs ++++ b/src/renderer.rs +@@ -13,6 +13,7 @@ use { + scale::Scale, + state::State, + theme::Color, ++ time::Time, + tree::{ + ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, + ToplevelNodeBase, WorkspaceNode, +@@ -262,7 +263,7 @@ impl Renderer<'_> { + } + + pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) { +- { ++ if container.mono_child.is_some() { + let srgb_srgb = self.state.color_manager.srgb_gamma22(); + let srgb = &srgb_srgb.linear; + let rd = container.render_data.borrow_mut(); +@@ -320,17 +321,162 @@ impl Renderer<'_> { + .node + .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); + } else { ++ let srgb_srgb = self.state.color_manager.srgb_gamma22(); ++ let srgb = &srgb_srgb.linear; ++ let theme = &self.state.theme; ++ let colors = &theme.colors; ++ let border_color = colors.border.get(); ++ let unfocused_title_bg = colors.unfocused_title_background.get(); ++ let focused_title_bg = colors.focused_title_background.get(); ++ let focused_inactive_title_bg = colors.focused_inactive_title_background.get(); ++ let attention_title_bg = colors.attention_requested_background.get(); ++ let separator_color = colors.separator.get(); ++ let tpuh = theme.title_plus_underline_height(); ++ let tuh = theme.title_underline_height(); ++ let have_active = container.children.iter().any(|c| c.active.get()); ++ let gap = theme.sizes.gap.get(); ++ let bw = theme.sizes.border_width.get(); ++ let rd = container.render_data.borrow(); ++ self.base ++ .fill_boxes2(&rd.border_rects, &border_color, srgb, x, y); ++ let last_active_rect = rd.last_active_rect; ++ drop(rd); ++ let now_usec = Time::now_unchecked().usec(); ++ let mut has_active_animation = false; + for child in container.children.iter() { + let body = child.body.get(); + if body.x1() >= container.width.get() || body.y1() >= container.height.get() { + break; + } ++ let (anim_dx, anim_dy, anim_dw, anim_dh) = ++ if let Some(anim) = child.body_animation.get() { ++ match anim.current_offset(now_usec) { ++ Some(offsets) => { ++ has_active_animation = true; ++ offsets ++ } ++ None => { ++ child.body_animation.set(None); ++ has_active_animation = true; ++ (0, 0, 0, 0) ++ } ++ } ++ } else { ++ (0, 0, 0, 0) ++ }; ++ // Render title background and underline with animation offset. ++ let title_rect = child.title_rect.get(); ++ if tpuh > 0 { ++ let anim_title = title_rect.move_(anim_dx, anim_dy); ++ let title_bg = if child.active.get() { ++ &focused_title_bg ++ } else if child.attention_requested.get() { ++ &attention_title_bg ++ } else if !have_active ++ && last_active_rect == Some(title_rect) ++ { ++ &focused_inactive_title_bg ++ } else { ++ &unfocused_title_bg ++ }; ++ self.base.fill_boxes2( ++ std::slice::from_ref(&anim_title), ++ title_bg, ++ srgb, ++ x, ++ y, ++ ); ++ if tuh > 0 { ++ let underline = Rect::new_sized_saturating( ++ anim_title.x1(), ++ anim_title.y2(), ++ anim_title.width(), ++ tuh, ++ ); ++ self.base.fill_boxes2( ++ std::slice::from_ref(&underline), ++ &separator_color, ++ srgb, ++ x, ++ y, ++ ); ++ } ++ let tt = &*child.title_tex.borrow(); ++ for (scale, tex) in tt { ++ if *scale != self.base.scale { ++ continue; ++ } ++ if let Some(tex) = tex.texture() { ++ let rect = anim_title.move_(x, y); ++ let bounds = self.base.scale_rect(rect); ++ let (tx, ty) = self.base.scale_point(rect.x1(), rect.y1()); ++ self.base.render_texture( ++ &tex, ++ None, ++ tx, ++ ty, ++ None, ++ None, ++ self.base.scale, ++ Some(&bounds), ++ None, ++ AcquireSync::None, ++ ReleaseSync::None, ++ false, ++ srgb_srgb, ++ AlphaMode::PremultipliedElectrical, ++ ); ++ } ++ } ++ } ++ let anim_w = (body.width() + anim_dw).max(1); ++ let anim_h = (body.height() + anim_dh).max(1); ++ let anim_body = Rect::new_sized_saturating( ++ body.x1() + anim_dx, ++ body.y1() + anim_dy, ++ anim_w, ++ anim_h, ++ ); ++ // Fill the animated body so that shrinking windows show a ++ // solid background instead of the underlying content in the ++ // gap between the committed surface and the animated border. ++ if anim_w > body.width() || anim_h > body.height() { ++ self.base.fill_boxes2( ++ std::slice::from_ref(&anim_body), ++ &border_color, ++ srgb, ++ x, ++ y, ++ ); ++ } ++ let clip_w = anim_w.min(body.width()); ++ let clip_h = anim_h.min(body.height()); ++ let body = Rect::new_sized_saturating( ++ anim_body.x1(), ++ anim_body.y1(), ++ clip_w, ++ clip_h, ++ ); + let body = body.move_(x, y); + let body = self.base.scale_rect(body); + let content = child.content.get(); +- child +- .node +- .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); ++ child.node.node_render( ++ self, ++ x + content.x1() + anim_dx, ++ y + content.y1() + anim_dy, ++ Some(&body), ++ ); ++ } ++ if has_active_animation { ++ let abs_x = container.abs_x1.get(); ++ let abs_y = container.abs_y1.get(); ++ let bw = if gap > 0 { bw } else { 0 }; ++ self.state.damage(Rect::new_sized_saturating( ++ abs_x - bw, ++ abs_y - bw, ++ container.width.get() + 2 * bw, ++ container.height.get() + 2 * bw, ++ )); + } + } + self.render_tl_aux(container.tl_data(), None, false); +diff --git a/src/theme.rs b/src/theme.rs +index 5a895623..52c6392d 100644 +--- a/src/theme.rs ++++ b/src/theme.rs +@@ -513,6 +513,7 @@ sizes! { + border_width = (0, 1000, 4), + bar_separator_width = (0, 1000, 1), + gap = (0, 1000, 0), ++ animation_duration = (0, 5000, 0), + } + + pub const DEFAULT_FONT: &str = "monospace 8"; +diff --git a/src/tree/container.rs b/src/tree/container.rs +index 587721ce..ea9fb62b 100644 +--- a/src/tree/container.rs ++++ b/src/tree/container.rs +@@ -1,5 +1,6 @@ + use { + crate::{ ++ animation::PositionAnimation, + backend::ButtonState, + cursor::KnownCursor, + cursor_user::CursorUser, +@@ -16,6 +17,7 @@ use { + scale::Scale, + state::State, + text::TextTexture, ++ time::Time, + tree::{ + ContainingNode, Direction, FindTreeResult, FindTreeUsecase, FloatNode, FoundNode, Node, + NodeId, NodeLayerLink, NodeLocation, OutputNode, TddType, TileDragDestination, +@@ -125,7 +127,7 @@ pub struct ContainerNode { + layout_scheduled: Cell, + compute_render_positions_scheduled: Cell, + render_titles_scheduled: Cell, +- num_children: NumCell, ++ pub num_children: NumCell, + pub children: LinkedList, + focus_history: LinkedList>, + child_nodes: RefCell>>, +@@ -158,6 +160,7 @@ pub struct ContainerChild { + pub body: Cell, + pub content: Cell, + factor: Cell, ++ pub body_animation: Cell>, + } + + #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +@@ -211,6 +214,7 @@ impl ContainerNode { + title_rect: Default::default(), + focus_history: Default::default(), + attention_requested: Cell::new(false), ++ body_animation: Default::default(), + }); + let child_node_ref = child_node.clone(); + let mut child_nodes = AHashMap::new(); +@@ -332,6 +336,7 @@ impl ContainerNode { + title_rect: Default::default(), + focus_history: Default::default(), + attention_requested: Default::default(), ++ body_animation: Default::default(), + }); + let r = link.to_ref(); + links.insert(new.node_id(), link); +@@ -362,6 +367,27 @@ impl ContainerNode { + self.cancel_seat_ops(); + } + ++ /// Seed a child's body position from an absolute rect, converting to ++ /// container-local coordinates. This allows `perform_layout` to compute ++ /// a meaningful animation delta for children that just moved between ++ /// containers. ++ fn seed_child_body(&self, child_id: NodeId, abs_body: Rect) { ++ if let Some(link) = self.child_nodes.borrow().get(&child_id) { ++ let local = abs_body.move_(-self.abs_x1.get(), -self.abs_y1.get()); ++ link.to_ref().body.set(local); ++ } ++ } ++ ++ /// Get a child's absolute body rect. ++ fn child_abs_body(&self, child_id: NodeId) -> Option { ++ self.child_nodes.borrow().get(&child_id).map(|link| { ++ link.to_ref() ++ .body ++ .get() ++ .move_(self.abs_x1.get(), self.abs_y1.get()) ++ }) ++ } ++ + fn cancel_seat_ops(&self) { + let mut seats = self.cursors.borrow_mut(); + for seat in seats.values_mut() { +@@ -382,11 +408,16 @@ impl ContainerNode { + } + + fn damage(&self) { ++ let bw = if self.state.theme.sizes.gap.get() > 0 { ++ self.state.theme.sizes.border_width.get() ++ } else { ++ 0 ++ }; + self.state.damage(Rect::new_sized_saturating( +- self.abs_x1.get(), +- self.abs_y1.get(), +- self.width.get(), +- self.height.get(), ++ self.abs_x1.get() - bw, ++ self.abs_y1.get() - bw, ++ self.width.get() + 2 * bw, ++ self.height.get() + 2 * bw, + )); + } + +@@ -404,11 +435,60 @@ impl ContainerNode { + return; + } + self.layout_scheduled.set(false); ++ let animation_duration = self.state.theme.sizes.animation_duration.get(); ++ let is_split = self.mono_child.is_none(); ++ let old_bodies: SmallVec<[(NodeId, Rect); 8]> = if animation_duration > 0 && is_split { ++ self.children ++ .iter() ++ .map(|c| (c.node.node_id(), c.body.get())) ++ .collect() ++ } else { ++ SmallVec::new() ++ }; + if let Some(child) = self.mono_child.get() { + self.perform_mono_layout(&child); + } else { + self.perform_split_layout(); + } ++ if animation_duration > 0 && is_split && !old_bodies.is_empty() { ++ let now_usec = Time::now_unchecked().usec(); ++ for child in self.children.iter() { ++ let child_id = child.node.node_id(); ++ let old = match old_bodies.iter().find(|(id, _)| *id == child_id) { ++ Some((_, rect)) if !rect.is_empty() => *rect, ++ _ => continue, ++ }; ++ let new_body = child.body.get(); ++ let dx = old.x1() - new_body.x1(); ++ let dy = old.y1() - new_body.y1(); ++ let dw = old.width() - new_body.width(); ++ let dh = old.height() - new_body.height(); ++ if dx == 0 && dy == 0 && dw == 0 && dh == 0 { ++ continue; ++ } ++ let (start_dx, start_dy, start_dw, start_dh) = ++ if let Some(prev) = child.body_animation.get() { ++ let (cur_dx, cur_dy, cur_dw, cur_dh) = ++ prev.current_offset(now_usec).unwrap_or((0, 0, 0, 0)); ++ ( ++ dx as f64 + cur_dx as f64, ++ dy as f64 + cur_dy as f64, ++ dw as f64 + cur_dw as f64, ++ dh as f64 + cur_dh as f64, ++ ) ++ } else { ++ (dx as f64, dy as f64, dw as f64, dh as f64) ++ }; ++ child.body_animation.set(Some(PositionAnimation { ++ offset_x: start_dx, ++ offset_y: start_dy, ++ offset_w: start_dw, ++ offset_h: start_dh, ++ start_usec: now_usec, ++ duration_usec: (animation_duration as u64) * 1000, ++ })); ++ } ++ } + self.state.tree_changed(); + // log::info!("perform_layout"); + self.schedule_render_titles(); +@@ -856,8 +936,8 @@ impl ContainerNode { + } else { + rd.title_rects.push(rect); + } +- if !mono { +- let rect = Rect::new_sized_saturating(rect.x1(), rect.y2(), rect.width(), 1); ++ if !mono && tuh > 0 { ++ let rect = Rect::new_sized_saturating(rect.x1(), rect.y2(), rect.width(), tuh); + rd.underline_rects.push(rect); + } + let tt = &*child.title_tex.borrow(); +@@ -1103,8 +1183,12 @@ impl ContainerNode { + { + self.activate_child2(&neighbor, true); + } ++ let abs_body = self.child_abs_body(child.node_id()); + self.cnode_remove_child2(&*child, true); +- cn.insert_child(child, direction); ++ cn.insert_child(child.clone(), direction); ++ if let Some(ab) = abs_body { ++ cn.seed_child_body(child.node_id(), ab); ++ } + return; + } + match prev { +@@ -1133,11 +1217,15 @@ impl ContainerNode { + return; + } + }; ++ let abs_body = self.child_abs_body(child.node_id()); + self.cnode_remove_child2(&*child, true); + match prev { + true => parent.add_child_before(&*neighbor, child.clone()), + false => parent.add_child_after(&*neighbor, child.clone()), + } ++ if let Some(ab) = abs_body { ++ parent.seed_child_body(child.node_id(), ab); ++ } + } + + pub fn insert_child(self: &Rc, node: Rc, direction: Direction) { +@@ -1915,6 +2003,7 @@ impl ContainingNode for ContainerNode { + title_rect: Cell::new(node.title_rect.get()), + focus_history: Cell::new(None), + attention_requested: Cell::new(false), ++ body_animation: Default::default(), + }); + if let Some(fh) = node.focus_history.take() { + link.focus_history.set(Some(fh.append(link.to_ref()))); +diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs +index 6f8d1277..03f3a0ca 100644 +--- a/toml-config/src/config.rs ++++ b/toml-config/src/config.rs +@@ -210,6 +210,7 @@ pub struct Theme { + pub bar_position: Option, + pub bar_separator_width: Option, + pub gap: Option, ++ pub animation_duration: Option, + } + + #[derive(Debug, Clone)] +diff --git a/toml-config/src/config/parsers/theme.rs b/toml-config/src/config/parsers/theme.rs +index dae2674d..977dbc14 100644 +--- a/toml-config/src/config/parsers/theme.rs ++++ b/toml-config/src/config/parsers/theme.rs +@@ -63,7 +63,7 @@ impl Parser for ThemeParser<'_> { + font, + title_font, + ), +- (bar_font, bar_position_val, bar_separator_width, gap), ++ (bar_font, bar_position_val, bar_separator_width, gap, animation_duration), + ) = ext.extract(( + ( + opt(val("attention-requested-bg-color")), +@@ -94,6 +94,7 @@ impl Parser for ThemeParser<'_> { + recover(opt(str("bar-position"))), + recover(opt(s32("bar-separator-width"))), + recover(opt(s32("gap"))), ++ recover(opt(s32("animation-duration"))), + ), + ))?; + macro_rules! color { +@@ -148,6 +149,7 @@ impl Parser for ThemeParser<'_> { + bar_position, + bar_separator_width: bar_separator_width.despan(), + gap: gap.despan(), ++ animation_duration: animation_duration.despan(), + }) + } + } +diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs +index 72c29b30..e60d6837 100644 +--- a/toml-config/src/lib.rs ++++ b/toml-config/src/lib.rs +@@ -981,6 +981,7 @@ impl State { + size!(BAR_HEIGHT, bar_height); + size!(BAR_SEPARATOR_WIDTH, bar_separator_width); + size!(GAP, gap); ++ size!(ANIMATION_DURATION, animation_duration); + macro_rules! font { + ($fun:ident, $field:ident) => { + if let Some(font) = &theme.$field { +-- +2.53.0 + diff --git a/graphical/desktop/patches/0003-add-window-border-frames-when-gaps-are-enabled.patch b/graphical/desktop/patches/0003-add-window-border-frames-when-gaps-are-enabled.patch new file mode 100644 index 0000000..2bff207 --- /dev/null +++ b/graphical/desktop/patches/0003-add-window-border-frames-when-gaps-are-enabled.patch @@ -0,0 +1,201 @@ +From 595b88b6a1d321086f33e213f86e80055e26bbe9 Mon Sep 17 00:00:00 2001 +From: atagen +Date: Sat, 7 Mar 2026 13:35:58 +1100 +Subject: [PATCH 03/10] add window border frames when gaps are enabled + +When gap > 0, draw a full border frame (left/right/top/bottom) around +each non-container child window instead of only separator borders +between +windows. Border rects are computed per-child in compute_render_positions +and stored on ContainerChild, then rendered with animation offsets in +the +renderer loop. Focus changes trigger damage when gaps are enabled so +border color updates are visible. +--- + src/renderer.rs | 40 ++++++++++++++++++++++++++++++++++++ + src/tree/container.rs | 48 ++++++++++++++++++++++++++++++++++++++++--- + 2 files changed, 85 insertions(+), 3 deletions(-) + +diff --git a/src/renderer.rs b/src/renderer.rs +index a0e81fca..c2622fac 100644 +--- a/src/renderer.rs ++++ b/src/renderer.rs +@@ -326,6 +326,7 @@ impl Renderer<'_> { + let theme = &self.state.theme; + let colors = &theme.colors; + let border_color = colors.border.get(); ++ let focused_border_color = colors.focused_title_background.get(); + let unfocused_title_bg = colors.unfocused_title_background.get(); + let focused_title_bg = colors.focused_title_background.get(); + let focused_inactive_title_bg = colors.focused_inactive_title_background.get(); +@@ -429,6 +430,7 @@ impl Renderer<'_> { + } + } + } ++ // Compute the animated body rect. + let anim_w = (body.width() + anim_dw).max(1); + let anim_h = (body.height() + anim_dh).max(1); + let anim_body = Rect::new_sized_saturating( +@@ -437,6 +439,44 @@ impl Renderer<'_> { + anim_w, + anim_h, + ); ++ // Draw per-child border frames at the animated position/size. ++ if gap > 0 && !child.node.node_is_container() { ++ let c = if child.border_color_is_focused.get() { ++ &focused_border_color ++ } else { ++ &border_color ++ }; ++ let top_y = if tpuh > 0 { ++ title_rect.y1() + anim_dy ++ } else { ++ anim_body.y1() ++ }; ++ let full_h = anim_body.y2() - top_y; ++ let full_w = anim_body.width(); ++ let frame_rects = [ ++ Rect::new_sized_saturating(anim_body.x1() - bw, top_y, bw, full_h), ++ Rect::new_sized_saturating(anim_body.x2(), top_y, bw, full_h), ++ Rect::new_sized_saturating(anim_body.x1() - bw, top_y - bw, full_w + 2 * bw, bw), ++ Rect::new_sized_saturating(anim_body.x1() - bw, anim_body.y2(), full_w + 2 * bw, bw), ++ ]; ++ self.base.fill_boxes2(&frame_rects, c, srgb, x, y); ++ } else if gap == 0 { ++ let borders = child.border_rects.borrow(); ++ if !borders.is_empty() { ++ let c = if child.border_color_is_focused.get() { ++ &focused_border_color ++ } else { ++ &border_color ++ }; ++ self.base.fill_boxes2( ++ &borders, ++ c, ++ srgb, ++ x + anim_dx, ++ y + anim_dy, ++ ); ++ } ++ } + // Fill the animated body so that shrinking windows show a + // solid background instead of the underlying content in the + // gap between the committed surface and the animated border. +diff --git a/src/tree/container.rs b/src/tree/container.rs +index ea9fb62b..25f95415 100644 +--- a/src/tree/container.rs ++++ b/src/tree/container.rs +@@ -161,6 +161,8 @@ pub struct ContainerChild { + pub content: Cell, + factor: Cell, + pub body_animation: Cell>, ++ pub border_rects: RefCell>, ++ pub border_color_is_focused: Cell, + } + + #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +@@ -215,6 +217,8 @@ impl ContainerNode { + focus_history: Default::default(), + attention_requested: Cell::new(false), + body_animation: Default::default(), ++ border_rects: Default::default(), ++ border_color_is_focused: Default::default(), + }); + let child_node_ref = child_node.clone(); + let mut child_nodes = AHashMap::new(); +@@ -337,6 +341,8 @@ impl ContainerNode { + focus_history: Default::default(), + attention_requested: Default::default(), + body_animation: Default::default(), ++ border_rects: Default::default(), ++ border_color_is_focused: Default::default(), + }); + let r = link.to_ref(); + links.insert(new.node_id(), link); +@@ -905,6 +911,7 @@ impl ContainerNode { + let mono = self.mono_child.is_some(); + let split = self.split.get(); + let have_active = self.children.iter().any(|c| c.active.get()); ++ let gap = theme.sizes.gap.get(); + let abs_x = self.abs_x1.get(); + let abs_y = self.abs_y1.get(); + for (i, child) in self.children.iter().enumerate() { +@@ -917,16 +924,36 @@ impl ContainerNode { + rect.height() + tuh, + )); + } +- if i > 0 { +- let rect = if mono { ++ // Per-child border rects ++ let mut child_borders = child.border_rects.borrow_mut(); ++ child_borders.clear(); ++ if gap > 0 && !mono && !child.node.node_is_container() { ++ // Frame around each window (title + body) ++ let body = child.body.get(); ++ let top_y = if tpuh > 0 { rect.y1() } else { body.y1() }; ++ let full_h = body.y2() - top_y; ++ let full_w = body.width(); ++ // Left ++ child_borders.push(Rect::new_sized_saturating(body.x1() - bw, top_y, bw, full_h)); ++ // Right ++ child_borders.push(Rect::new_sized_saturating(body.x2(), top_y, bw, full_h)); ++ // Top ++ child_borders.push(Rect::new_sized_saturating(body.x1() - bw, top_y - bw, full_w + 2 * bw, bw)); ++ // Bottom ++ child_borders.push(Rect::new_sized_saturating(body.x1() - bw, body.y2(), full_w + 2 * bw, bw)); ++ } else if gap == 0 && i > 0 { ++ // Separator borders when gap == 0 ++ let sep = if mono { + Rect::new_sized_saturating(rect.x1() - bw, 0, bw, th) + } else if split == ContainerSplit::Horizontal { + Rect::new_sized_saturating(rect.x1() - bw, 0, bw, cheight) + } else { + Rect::new_sized_saturating(0, rect.y1() - bw, cwidth, bw) + }; +- rd.border_rects.push(rect); ++ rd.border_rects.push(sep); + } ++ drop(child_borders); ++ child.border_color_is_focused.set(child.active.get()); + if child.active.get() { + rd.active_title_rects.push(rect); + } else if child.attention_requested.get() { +@@ -1267,6 +1294,9 @@ impl ContainerNode { + // log::info!("node_child_active_changed"); + self.schedule_render_titles(); + self.schedule_compute_render_positions(); ++ if self.state.theme.sizes.gap.get() > 0 && self.toplevel_data.visible.get() { ++ self.damage(); ++ } + if let Some(parent) = self.toplevel_data.parent.get() { + parent.node_child_active_changed(self.deref(), active, depth + 1); + } +@@ -2004,6 +2034,8 @@ impl ContainingNode for ContainerNode { + focus_history: Cell::new(None), + attention_requested: Cell::new(false), + body_animation: Default::default(), ++ border_rects: Default::default(), ++ border_color_is_focused: Default::default(), + }); + if let Some(fh) = node.focus_history.take() { + link.focus_history.set(Some(fh.append(link.to_ref()))); +@@ -2058,6 +2090,16 @@ impl ContainingNode for ContainerNode { + }; + let num_children = self.num_children.fetch_sub(1) - 1; + if num_children == 0 { ++ let gap = self.state.theme.sizes.gap.get(); ++ if gap > 0 { ++ let bw = self.state.theme.sizes.border_width.get(); ++ self.state.damage(Rect::new_sized_saturating( ++ self.abs_x1.get() - bw, ++ self.abs_y1.get() - bw, ++ self.width.get() + 2 * bw, ++ self.height.get() + 2 * bw, ++ )); ++ } + self.tl_destroy(); + return; + } +-- +2.53.0 + diff --git a/graphical/desktop/patches/0004-add-sequential-animation-mode-for-tiled-windows.patch b/graphical/desktop/patches/0004-add-sequential-animation-mode-for-tiled-windows.patch new file mode 100644 index 0000000..89640d8 --- /dev/null +++ b/graphical/desktop/patches/0004-add-sequential-animation-mode-for-tiled-windows.patch @@ -0,0 +1,545 @@ +From 4555defec92f59b019f116b3fae0d914f171eea2 Mon Sep 17 00:00:00 2001 +From: atagen +Date: Sat, 7 Mar 2026 13:40:54 +1100 +Subject: [PATCH 04/10] add sequential animation mode for tiled windows + +Introduces AnimationMode enum with Concurrent (default) and Sequential +variants. Sequential mode automatically picks the phase order per-window +based on whether it is growing or shrinking: growing windows move first +then resize, shrinking windows resize first then move. +--- + jay-config/src/_private/client.rs | 12 +++- + jay-config/src/_private/ipc.rs | 9 ++- + jay-config/src/theme.rs | 24 ++++++++ + src/animation.rs | 48 +++++++++++++-- + src/config/handler.rs | 22 ++++++- + src/theme.rs | 35 ++++++++++- + src/tree/container.rs | 82 ++++++++++++++++++++++++- + toml-config/src/config.rs | 3 +- + toml-config/src/config/parsers/theme.rs | 19 +++++- + toml-config/src/lib.rs | 7 ++- + 10 files changed, 244 insertions(+), 17 deletions(-) + +diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs +index 7c71461e..c72c17f1 100644 +--- a/jay-config/src/_private/client.rs ++++ b/jay-config/src/_private/client.rs +@@ -26,7 +26,7 @@ use { + }, + logging::LogLevel, + tasks::{JoinHandle, JoinSlot}, +- theme::{BarPosition, Color, colors::Colorable, sized::Resizable}, ++ theme::{AnimationMode, BarPosition, Color, colors::Colorable, sized::Resizable}, + timer::Timer, + video::{ + BlendSpace, ColorSpace, Connector, DrmDevice, Eotf, Format, GfxApi, Mode, TearingMode, +@@ -1035,6 +1035,16 @@ impl ConfigClient { + position + } + ++ pub fn set_animation_mode(&self, mode: AnimationMode) { ++ self.send(&ClientMessage::SetAnimationMode { mode }); ++ } ++ ++ pub fn get_animation_mode(&self) -> AnimationMode { ++ let res = self.send_with_response(&ClientMessage::GetAnimationMode); ++ get_response!(res, AnimationMode::Concurrent, GetAnimationMode { mode }); ++ mode ++ } ++ + pub fn set_middle_click_paste_enabled(&self, enabled: bool) { + self.send(&ClientMessage::SetMiddleClickPasteEnabled { enabled }); + } +diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs +index ab1de845..b0273f91 100644 +--- a/jay-config/src/_private/ipc.rs ++++ b/jay-config/src/_private/ipc.rs +@@ -10,7 +10,7 @@ use { + }, + keyboard::{Group, Keymap, mods::Modifiers, syms::KeySym}, + logging::LogLevel, +- theme::{BarPosition, Color, colors::Colorable, sized::Resizable}, ++ theme::{AnimationMode, BarPosition, Color, colors::Colorable, sized::Resizable}, + timer::Timer, + video::{ + BlendSpace, ColorSpace, Connector, DrmDevice, Eotf, Format, GfxApi, TearingMode, +@@ -817,6 +817,10 @@ pub enum ClientMessage<'a> { + position: BarPosition, + }, + GetBarPosition, ++ SetAnimationMode { ++ mode: AnimationMode, ++ }, ++ GetAnimationMode, + ConnectorSetUseNativeGamut { + connector: Connector, + use_native_gamut: bool, +@@ -1088,6 +1092,9 @@ pub enum Response { + GetBarPosition { + position: BarPosition, + }, ++ GetAnimationMode { ++ mode: AnimationMode, ++ }, + KeymapFromNames { + keymap: Keymap, + }, +diff --git a/jay-config/src/theme.rs b/jay-config/src/theme.rs +index 53c53f8f..90945b47 100644 +--- a/jay-config/src/theme.rs ++++ b/jay-config/src/theme.rs +@@ -197,6 +197,30 @@ pub fn get_bar_position() -> BarPosition { + get!(BarPosition::Top).get_bar_position() + } + ++/// The animation mode for window position/size transitions. ++#[non_exhaustive] ++#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Default)] ++pub enum AnimationMode { ++ /// Position and size animate together. ++ #[default] ++ Concurrent, ++ /// Position and size animate sequentially, with the phase order chosen ++ /// automatically per window based on whether it is growing or shrinking. ++ Sequential, ++} ++ ++/// Sets the animation mode for window transitions. ++/// ++/// Default: `Concurrent`. ++pub fn set_animation_mode(mode: AnimationMode) { ++ get!().set_animation_mode(mode); ++} ++ ++/// Gets the current animation mode. ++pub fn get_animation_mode() -> AnimationMode { ++ get!(AnimationMode::Concurrent).get_animation_mode() ++} ++ + /// Elements of the compositor whose color can be changed. + pub mod colors { + use { +diff --git a/src/animation.rs b/src/animation.rs +index 328540b6..02d034a7 100644 +--- a/src/animation.rs ++++ b/src/animation.rs +@@ -1,3 +1,11 @@ ++#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] ++pub enum AnimationMode { ++ #[default] ++ Concurrent, ++ MoveFirst, ++ ResizeFirst, ++} ++ + #[derive(Copy, Clone, Debug)] + pub struct PositionAnimation { + pub offset_x: f64, +@@ -6,6 +14,8 @@ pub struct PositionAnimation { + pub offset_h: f64, + pub start_usec: u64, + pub duration_usec: u64, ++ pub mode_x: AnimationMode, ++ pub mode_y: AnimationMode, + } + + fn ease_out_cubic(t: f64) -> f64 { +@@ -13,6 +23,33 @@ fn ease_out_cubic(t: f64) -> f64 { + 1.0 - t * t * t + } + ++fn sequential_factors(t: f64, mode: AnimationMode) -> (f64, f64) { ++ match mode { ++ AnimationMode::Concurrent => { ++ let r = 1.0 - ease_out_cubic(t); ++ (r, r) ++ } ++ AnimationMode::MoveFirst => { ++ if t < 0.5 { ++ let r = 1.0 - ease_out_cubic(t * 2.0); ++ (r, 1.0) ++ } else { ++ let r = 1.0 - ease_out_cubic((t - 0.5) * 2.0); ++ (0.0, r) ++ } ++ } ++ AnimationMode::ResizeFirst => { ++ if t < 0.5 { ++ let r = 1.0 - ease_out_cubic(t * 2.0); ++ (1.0, r) ++ } else { ++ let r = 1.0 - ease_out_cubic((t - 0.5) * 2.0); ++ (r, 0.0) ++ } ++ } ++ } ++} ++ + impl PositionAnimation { + /// Returns the current animation offset, or None if the animation is complete. + pub fn current_offset(&self, now_usec: u64) -> Option<(i32, i32, i32, i32)> { +@@ -24,11 +61,12 @@ impl PositionAnimation { + return None; + } + let t = elapsed as f64 / self.duration_usec as f64; +- let remaining = 1.0 - ease_out_cubic(t); +- let dx = (self.offset_x * remaining).round() as i32; +- let dy = (self.offset_y * remaining).round() as i32; +- let dw = (self.offset_w * remaining).round() as i32; +- let dh = (self.offset_h * remaining).round() as i32; ++ let (move_remaining_x, size_remaining_x) = sequential_factors(t, self.mode_x); ++ let (move_remaining_y, size_remaining_y) = sequential_factors(t, self.mode_y); ++ let dx = (self.offset_x * move_remaining_x).round() as i32; ++ let dy = (self.offset_y * move_remaining_y).round() as i32; ++ let dw = (self.offset_w * size_remaining_x).round() as i32; ++ let dh = (self.offset_h * size_remaining_y).round() as i32; + if dx == 0 && dy == 0 && dw == 0 && dh == 0 { + return None; + } +diff --git a/src/config/handler.rs b/src/config/handler.rs +index 56135590..78f6281c 100644 +--- a/src/config/handler.rs ++++ b/src/config/handler.rs +@@ -67,7 +67,7 @@ use { + }, + keyboard::{Group, Keymap, mods::Modifiers, syms::KeySym}, + logging::LogLevel, +- theme::{BarPosition, colors::Colorable, sized::Resizable}, ++ theme::{AnimationMode, BarPosition, colors::Colorable, sized::Resizable}, + timer::Timer as JayTimer, + video::{ + BlendSpace as ConfigBlendSpace, ColorSpace, Connector, DrmDevice, Eotf as ConfigEotf, +@@ -1456,6 +1456,20 @@ impl ConfigProxyHandler { + }); + } + ++ fn handle_set_animation_mode(&self, mode: AnimationMode) -> Result<(), CphError> { ++ let Ok(mode) = mode.try_into() else { ++ return Err(CphError::UnknownAnimationMode(mode)); ++ }; ++ self.state.theme.animation_mode.set(mode); ++ Ok(()) ++ } ++ ++ fn handle_get_animation_mode(&self) { ++ self.respond(Response::GetAnimationMode { ++ mode: self.state.theme.animation_mode.get().into(), ++ }); ++ } ++ + fn handle_set_show_float_pin_icon(&self, show: bool) { + self.state.show_pin_icon.set(show); + for stacked in self.state.root.stacked.iter() { +@@ -3320,6 +3334,10 @@ impl ConfigProxyHandler { + .handle_set_bar_position(position) + .wrn("set_bar_position")?, + ClientMessage::GetBarPosition => self.handle_get_bar_position(), ++ ClientMessage::SetAnimationMode { mode } => self ++ .handle_set_animation_mode(mode) ++ .wrn("set_animation_mode")?, ++ ClientMessage::GetAnimationMode => self.handle_get_animation_mode(), + ClientMessage::SeatFocusHistory { seat, timeline } => self + .handle_seat_focus_history(seat, timeline) + .wrn("seat_focus_history")?, +@@ -3562,6 +3580,8 @@ enum CphError { + UnknownBlendSpace(ConfigBlendSpace), + #[error("Unknown bar position {0:?}")] + UnknownBarPosition(BarPosition), ++ #[error("Unknown animation mode {0:?}")] ++ UnknownAnimationMode(AnimationMode), + #[error("Unknown gfx API {0:?}")] + UnknownGfxApi(GfxApi), + #[error("Unknown fallback output mode {0:?}")] +diff --git a/src/theme.rs b/src/theme.rs +index 52c6392d..0709f3d4 100644 +--- a/src/theme.rs ++++ b/src/theme.rs +@@ -6,7 +6,9 @@ use { + gfx_api::AlphaMode, + utils::clonecell::CloneCell, + }, +- jay_config::theme::BarPosition as ConfigBarPosition, ++ jay_config::theme::{ ++ AnimationMode as ConfigAnimationMode, BarPosition as ConfigBarPosition, ++ }, + linearize::Linearize, + num_traits::Float, + std::{cell::Cell, cmp::Ordering, ops::Mul, sync::Arc}, +@@ -538,6 +540,28 @@ impl TryFrom for BarPosition { + } + } + ++impl TryFrom for AnimationMode { ++ type Error = (); ++ ++ fn try_from(value: ConfigAnimationMode) -> Result { ++ let v = match value { ++ ConfigAnimationMode::Concurrent => Self::Concurrent, ++ ConfigAnimationMode::Sequential => Self::Sequential, ++ _ => return Err(()), ++ }; ++ Ok(v) ++ } ++} ++ ++impl Into for AnimationMode { ++ fn into(self) -> ConfigAnimationMode { ++ match self { ++ AnimationMode::Concurrent => ConfigAnimationMode::Concurrent, ++ AnimationMode::Sequential => ConfigAnimationMode::Sequential, ++ } ++ } ++} ++ + impl Into for BarPosition { + fn into(self) -> ConfigBarPosition { + match self { +@@ -547,6 +571,13 @@ impl Into for BarPosition { + } + } + ++#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] ++pub enum AnimationMode { ++ #[default] ++ Concurrent, ++ Sequential, ++} ++ + pub struct Theme { + pub colors: ThemeColors, + pub sizes: ThemeSizes, +@@ -556,6 +587,7 @@ pub struct Theme { + pub default_font: Arc, + pub show_titles: Cell, + pub bar_position: Cell, ++ pub animation_mode: Cell, + } + + impl Default for Theme { +@@ -570,6 +602,7 @@ impl Default for Theme { + default_font, + show_titles: Cell::new(true), + bar_position: Default::default(), ++ animation_mode: Default::default(), + } + } + } +diff --git a/src/tree/container.rs b/src/tree/container.rs +index 25f95415..85a0b97d 100644 +--- a/src/tree/container.rs ++++ b/src/tree/container.rs +@@ -377,7 +377,7 @@ impl ContainerNode { + /// container-local coordinates. This allows `perform_layout` to compute + /// a meaningful animation delta for children that just moved between + /// containers. +- fn seed_child_body(&self, child_id: NodeId, abs_body: Rect) { ++ pub(super) fn seed_child_body(&self, child_id: NodeId, abs_body: Rect) { + if let Some(link) = self.child_nodes.borrow().get(&child_id) { + let local = abs_body.move_(-self.abs_x1.get(), -self.abs_y1.get()); + link.to_ref().body.set(local); +@@ -458,6 +458,42 @@ impl ContainerNode { + } + if animation_duration > 0 && is_split && !old_bodies.is_empty() { + let now_usec = Time::now_unchecked().usec(); ++ let has_spawn = self.children.iter().any(|c| { ++ !old_bodies ++ .iter() ++ .any(|(id, rect)| *id == c.node.node_id() && !rect.is_empty()) ++ }); ++ // Determine per-child animation modes based on split-axis analysis. ++ let use_sequential = !has_spawn ++ && matches!( ++ self.state.theme.animation_mode.get(), ++ crate::theme::AnimationMode::Sequential ++ ); ++ // Check if any child has a two-phase animation (both position and ++ // size change along the split axis). Single-phase children will ++ // delay their start to synchronise with the second phase. ++ let has_two_phase = use_sequential ++ && self.children.iter().any(|c| { ++ let child_id = c.node.node_id(); ++ if let Some((_, old)) = old_bodies.iter().find(|(id, _)| *id == child_id) { ++ if old.is_empty() { ++ return false; ++ } ++ let new_body = c.body.get(); ++ let split = self.split.get(); ++ let (dp, ds) = match split { ++ ContainerSplit::Horizontal => { ++ (old.x1() - new_body.x1(), old.width() - new_body.width()) ++ } ++ ContainerSplit::Vertical => { ++ (old.y1() - new_body.y1(), old.height() - new_body.height()) ++ } ++ }; ++ dp != 0 && ds != 0 ++ } else { ++ false ++ } ++ }); + for child in self.children.iter() { + let child_id = child.node.node_id(); + let old = match old_bodies.iter().find(|(id, _)| *id == child_id) { +@@ -485,13 +521,53 @@ impl ContainerNode { + } else { + (dx as f64, dy as f64, dw as f64, dh as f64) + }; ++ // Determine per-child, per-axis animation mode. ++ let (mode_x, mode_y) = if use_sequential { ++ use crate::animation::AnimationMode::*; ++ let split = self.split.get(); ++ let (delta_pos, delta_size) = match split { ++ ContainerSplit::Horizontal => (dx, dw), ++ ContainerSplit::Vertical => (dy, dh), ++ }; ++ let child_mode = if delta_pos == 0 || delta_size == 0 { ++ Concurrent ++ } else if delta_size < 0 { ++ // Shrinking: resize first to free space, then move. ++ ResizeFirst ++ } else { ++ // Growing: move into freed space first, then grow. ++ MoveFirst ++ }; ++ match split { ++ ContainerSplit::Horizontal => (child_mode, Concurrent), ++ ContainerSplit::Vertical => (Concurrent, child_mode), ++ } ++ } else { ++ use crate::animation::AnimationMode::Concurrent; ++ (Concurrent, Concurrent) ++ }; ++ // In sequential mode, single-phase (Concurrent) children wait ++ // for double-phase children to complete their first phase. ++ let duration_usec = (animation_duration as u64) * 1000; ++ let (child_start, child_duration) = if use_sequential ++ && has_two_phase ++ && mode_x == crate::animation::AnimationMode::Concurrent ++ && mode_y == crate::animation::AnimationMode::Concurrent ++ { ++ (now_usec + duration_usec / 2, duration_usec / 2) ++ } else { ++ (now_usec, duration_usec) ++ }; + child.body_animation.set(Some(PositionAnimation { + offset_x: start_dx, + offset_y: start_dy, + offset_w: start_dw, + offset_h: start_dh, +- start_usec: now_usec, +- duration_usec: (animation_duration as u64) * 1000, ++ start_usec: child_start, ++ duration_usec: child_duration, ++ mode_x, ++ mode_y, ++ reverse: false, + })); + } + } +diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs +index 03f3a0ca..d21f7556 100644 +--- a/toml-config/src/config.rs ++++ b/toml-config/src/config.rs +@@ -32,7 +32,7 @@ use { + keyboard::{Keymap, ModifiedKeySym, mods::Modifiers, syms::KeySym}, + logging::LogLevel, + status::MessageFormat, +- theme::{BarPosition, Color}, ++ theme::{AnimationMode, BarPosition, Color}, + video::{BlendSpace, ColorSpace, Eotf, Format, GfxApi, TearingMode, Transform, VrrMode}, + window::{ContentType, TileState, WindowType}, + workspace::WorkspaceDisplayOrder, +@@ -211,6 +211,7 @@ pub struct Theme { + pub bar_separator_width: Option, + pub gap: Option, + pub animation_duration: Option, ++ pub animation_mode: Option, + } + + #[derive(Debug, Clone)] +diff --git a/toml-config/src/config/parsers/theme.rs b/toml-config/src/config/parsers/theme.rs +index 977dbc14..ae9b7ccd 100644 +--- a/toml-config/src/config/parsers/theme.rs ++++ b/toml-config/src/config/parsers/theme.rs +@@ -13,7 +13,7 @@ use { + }, + }, + indexmap::IndexMap, +- jay_config::theme::BarPosition, ++ jay_config::theme::{AnimationMode, BarPosition}, + thiserror::Error, + }; + +@@ -63,7 +63,7 @@ impl Parser for ThemeParser<'_> { + font, + title_font, + ), +- (bar_font, bar_position_val, bar_separator_width, gap, animation_duration), ++ (bar_font, bar_position_val, bar_separator_width, gap, animation_duration, animation_mode_val), + ) = ext.extract(( + ( + opt(val("attention-requested-bg-color")), +@@ -95,6 +95,7 @@ impl Parser for ThemeParser<'_> { + recover(opt(s32("bar-separator-width"))), + recover(opt(s32("gap"))), + recover(opt(s32("animation-duration"))), ++ recover(opt(str("animation-mode"))), + ), + ))?; + macro_rules! color { +@@ -124,6 +125,19 @@ impl Parser for ThemeParser<'_> { + None + } + }); ++ let animation_mode = ++ animation_mode_val.and_then(|value| match value.value.to_lowercase().as_str() { ++ "concurrent" => Some(AnimationMode::Concurrent), ++ "sequential" => Some(AnimationMode::Sequential), ++ _ => { ++ log::warn!( ++ "Unknown animation mode '{}': {}", ++ value.value, ++ self.0.error3(value.span) ++ ); ++ None ++ } ++ }); + Ok(Theme { + attention_requested_bg_color: color!(attention_requested_bg_color), + bg_color: color!(bg_color), +@@ -150,6 +164,7 @@ impl Parser for ThemeParser<'_> { + bar_separator_width: bar_separator_width.despan(), + gap: gap.despan(), + animation_duration: animation_duration.despan(), ++ animation_mode, + }) + } + } +diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs +index e60d6837..4f9c6a4d 100644 +--- a/toml-config/src/lib.rs ++++ b/toml-config/src/lib.rs +@@ -44,8 +44,8 @@ use { + switch_to_vt, + tasks::{self, JoinHandle}, + theme::{ +- reset_colors, reset_font, reset_sizes, set_bar_font, set_bar_position, set_font, +- set_title_font, ++ reset_colors, reset_font, reset_sizes, set_animation_mode, set_bar_font, ++ set_bar_position, set_font, set_title_font, + }, + toggle_float_above_fullscreen, toggle_show_bar, toggle_show_titles, + video::{ +@@ -1613,6 +1613,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc +Date: Sat, 7 Mar 2026 13:47:02 +1100 +Subject: [PATCH 05/10] add cursor-follows-focus setting + +When enabled, the cursor warps to the center of the focused window on +keyboard-driven focus changes (move_focus). This is a per-seat boolean +setting, configurable via the Seat API and cursor-follows-focus in TOML. +Defaults to false. +--- + jay-config/src/_private/client.rs | 10 +++++++++ + jay-config/src/_private/ipc.rs | 10 +++++++++ + jay-config/src/input.rs | 13 ++++++++++++ + src/config/handler.rs | 24 ++++++++++++++++++++++ + src/ifs/wl_seat.rs | 26 ++++++++++++++++++++++++ + src/tree/output.rs | 7 +++++-- + toml-config/src/config.rs | 1 + + toml-config/src/config/parsers/config.rs | 3 +++ + toml-config/src/lib.rs | 3 +++ + 9 files changed, 95 insertions(+), 2 deletions(-) + +diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs +index c72c17f1..9a83a87e 100644 +--- a/jay-config/src/_private/client.rs ++++ b/jay-config/src/_private/client.rs +@@ -1387,6 +1387,16 @@ impl ConfigClient { + self.send(&ClientMessage::SetFocusFollowsMouseMode { seat, mode }) + } + ++ pub fn set_cursor_follows_focus(&self, seat: Seat, enabled: bool) { ++ self.send(&ClientMessage::SetCursorFollowsFocus { seat, enabled }); ++ } ++ ++ pub fn get_cursor_follows_focus(&self, seat: Seat) -> bool { ++ let res = self.send_with_response(&ClientMessage::GetCursorFollowsFocus { seat }); ++ get_response!(res, false, GetCursorFollowsFocus { enabled }); ++ enabled ++ } ++ + pub fn set_fallback_output_mode(&self, seat: Seat, mode: FallbackOutputMode) { + self.send(&ClientMessage::SetFallbackOutputMode { seat, mode }) + } +diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs +index b0273f91..74e27f76 100644 +--- a/jay-config/src/_private/ipc.rs ++++ b/jay-config/src/_private/ipc.rs +@@ -501,6 +501,13 @@ pub enum ClientMessage<'a> { + seat: Seat, + mode: FocusFollowsMouseMode, + }, ++ SetCursorFollowsFocus { ++ seat: Seat, ++ enabled: bool, ++ }, ++ GetCursorFollowsFocus { ++ seat: Seat, ++ }, + SetInputDeviceConnector { + input_device: InputDevice, + connector: Connector, +@@ -1095,6 +1102,9 @@ pub enum Response { + GetAnimationMode { + mode: AnimationMode, + }, ++ GetCursorFollowsFocus { ++ enabled: bool, ++ }, + KeymapFromNames { + keymap: Keymap, + }, +diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs +index 2e985766..23162537 100644 +--- a/jay-config/src/input.rs ++++ b/jay-config/src/input.rs +@@ -503,6 +503,19 @@ impl Seat { + get!().set_focus_follows_mouse_mode(self, mode); + } + ++ /// Sets whether the cursor warps to the center of a window when focus ++ /// changes via keyboard. ++ /// ++ /// Default: `false`. ++ pub fn set_cursor_follows_focus(self, enabled: bool) { ++ get!().set_cursor_follows_focus(self, enabled); ++ } ++ ++ /// Gets the cursor-follows-focus setting. ++ pub fn get_cursor_follows_focus(self) -> bool { ++ get!(false).get_cursor_follows_focus(self) ++ } ++ + /// Sets the fallback output mode. + /// + /// The default is `Cursor`. +diff --git a/src/config/handler.rs b/src/config/handler.rs +index 78f6281c..9ab0934a 100644 +--- a/src/config/handler.rs ++++ b/src/config/handler.rs +@@ -520,6 +520,24 @@ impl ConfigProxyHandler { + Ok(()) + } + ++ fn handle_set_cursor_follows_focus( ++ &self, ++ seat: Seat, ++ enabled: bool, ++ ) -> Result<(), CphError> { ++ let seat = self.get_seat(seat)?; ++ seat.set_cursor_follows_focus(enabled); ++ Ok(()) ++ } ++ ++ fn handle_get_cursor_follows_focus(&self, seat: Seat) -> Result<(), CphError> { ++ let seat = self.get_seat(seat)?; ++ self.respond(Response::GetCursorFollowsFocus { ++ enabled: seat.get_cursor_follows_focus(), ++ }); ++ Ok(()) ++ } ++ + fn handle_set_fallback_output_mode( + &self, + seat: Seat, +@@ -3115,6 +3133,12 @@ impl ConfigProxyHandler { + ClientMessage::SetFocusFollowsMouseMode { seat, mode } => self + .handle_set_focus_follows_mouse_mode(seat, mode) + .wrn("set_focus_follows_mouse_mode")?, ++ ClientMessage::SetCursorFollowsFocus { seat, enabled } => self ++ .handle_set_cursor_follows_focus(seat, enabled) ++ .wrn("set_cursor_follows_focus")?, ++ ClientMessage::GetCursorFollowsFocus { seat } => self ++ .handle_get_cursor_follows_focus(seat) ++ .wrn("get_cursor_follows_focus")?, + ClientMessage::SetInputDeviceConnector { + input_device, + connector, +diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs +index e3f930e0..68fe5366 100644 +--- a/src/ifs/wl_seat.rs ++++ b/src/ifs/wl_seat.rs +@@ -230,6 +230,7 @@ pub struct WlSeatGlobal { + input_method_grab: CloneCell>>, + forward: Cell, + focus_follows_mouse: Cell, ++ cursor_follows_focus: Cell, + fallback_output_mode: Cell, + swipe_bindings: PerClientBindings, + pinch_bindings: PerClientBindings, +@@ -358,6 +359,7 @@ impl WlSeatGlobal { + input_method_grab: Default::default(), + forward: Cell::new(false), + focus_follows_mouse: Cell::new(true), ++ cursor_follows_focus: Cell::new(false), + fallback_output_mode: Cell::new(FallbackOutputMode::Cursor), + swipe_bindings: Default::default(), + pinch_bindings: Default::default(), +@@ -776,6 +778,7 @@ impl WlSeatGlobal { + .find_output_in_direction(&ws.output.get(), direction) + { + target.take_keyboard_navigation_focus(self, direction); ++ self.warp_cursor_to_focus(); + } + return; + } +@@ -795,6 +798,7 @@ impl WlSeatGlobal { + c.move_focus_from_child(self, tl.deref(), direction); + } + } ++ self.warp_cursor_to_focus(); + } + + pub fn move_focused(self: &Rc, direction: Direction) { +@@ -1460,6 +1464,28 @@ impl WlSeatGlobal { + self.focus_follows_mouse.set(focus_follows_mouse); + } + ++ pub fn set_cursor_follows_focus(&self, enabled: bool) { ++ self.cursor_follows_focus.set(enabled); ++ } ++ ++ pub fn get_cursor_follows_focus(&self) -> bool { ++ self.cursor_follows_focus.get() ++ } ++ ++ pub fn warp_cursor_to_focus(&self) { ++ if !self.cursor_follows_focus.get() { ++ return; ++ } ++ if let Some(tl) = self.keyboard_node.get().node_toplevel() { ++ let rect = tl.node_absolute_position(); ++ if !rect.is_empty() { ++ let cx = Fixed::from_int(rect.x1() + rect.width() / 2); ++ let cy = Fixed::from_int(rect.y1() + rect.height() / 2); ++ self.pointer_cursor.set_position(cx, cy); ++ } ++ } ++ } ++ + pub fn set_fallback_output_mode(&self, fallback_output_mode: FallbackOutputMode) { + self.fallback_output_mode.set(fallback_output_mode); + } +diff --git a/src/tree/output.rs b/src/tree/output.rs +index c3e2ae3a..b43b387e 100644 +--- a/src/tree/output.rs ++++ b/src/tree/output.rs +@@ -711,8 +711,11 @@ impl OutputNode { + fs.tl_change_extents(&self.global.pos.get()); + } + ws.change_extents(&self.workspace_rect.get()); +- for seat in seats { +- ws.clone().node_do_focus(&seat, Direction::Unspecified); ++ for seat in &seats { ++ ws.clone().node_do_focus(seat, Direction::Unspecified); ++ } ++ for seat in &seats { ++ seat.warp_cursor_to_focus(); + } + if self.node_visible() { + self.state.damage(self.global.pos.get()); +diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs +index d21f7556..86120f08 100644 +--- a/toml-config/src/config.rs ++++ b/toml-config/src/config.rs +@@ -522,6 +522,7 @@ pub struct Config { + pub grace_period: Option, + pub explicit_sync_enabled: Option, + pub focus_follows_mouse: bool, ++ pub cursor_follows_focus: bool, + pub window_management_key: Option, + pub vrr: Option, + pub tearing: Option, +diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs +index 10e12fca..9c0e1140 100644 +--- a/toml-config/src/config/parsers/config.rs ++++ b/toml-config/src/config/parsers/config.rs +@@ -150,6 +150,7 @@ impl Parser for ConfigParser<'_> { + simple_im_val, + show_titles, + fallback_output_mode_val, ++ cursor_follows_focus, + ), + ) = ext.extract(( + ( +@@ -208,6 +209,7 @@ impl Parser for ConfigParser<'_> { + opt(val("simple-im")), + recover(opt(bol("show-titles"))), + opt(val("fallback-output-mode")), ++ recover(opt(bol("cursor-follows-focus"))), + ), + ))?; + let mut keymap = None; +@@ -565,6 +567,7 @@ impl Parser for ConfigParser<'_> { + idle, + grace_period, + focus_follows_mouse: focus_follows_mouse.despan().unwrap_or(true), ++ cursor_follows_focus: cursor_follows_focus.despan().unwrap_or(false), + window_management_key, + vrr, + tearing, +diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs +index 4f9c6a4d..b2fd985f 100644 +--- a/toml-config/src/lib.rs ++++ b/toml-config/src/lib.rs +@@ -1555,6 +1555,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc FocusFollowsMouseMode::True, + false => FocusFollowsMouseMode::False, + }); ++ persistent ++ .seat ++ .set_cursor_follows_focus(config.cursor_follows_focus); + if let Some(window_management_key) = config.window_management_key { + persistent + .seat +-- +2.53.0 + diff --git a/graphical/desktop/patches/0006-add-toggle-focus-between-floating-and-tiled-layers.patch b/graphical/desktop/patches/0006-add-toggle-focus-between-floating-and-tiled-layers.patch new file mode 100644 index 0000000..de5ed93 --- /dev/null +++ b/graphical/desktop/patches/0006-add-toggle-focus-between-floating-and-tiled-layers.patch @@ -0,0 +1,199 @@ +From 05e9f39292784d9aa6295517c1aa3ca20fad0e46 Mon Sep 17 00:00:00 2001 +From: atagen +Date: Sat, 7 Mar 2026 14:55:31 +1100 +Subject: [PATCH 06/10] add toggle focus between floating and tiled layers + +Adds focus_floats(), toggle_focus_float_tiled(), and their IPC +bindings so users can bind a key to swap focus between the floating +and tiled layers. +--- + jay-config/src/_private/client.rs | 8 +++++++ + jay-config/src/_private/ipc.rs | 6 +++++ + jay-config/src/input.rs | 13 ++++++++++ + src/config/handler.rs | 19 +++++++++++++++ + src/ifs/wl_seat.rs | 30 ++++++++++++++++++++++++ + toml-config/src/config.rs | 1 + + toml-config/src/config/parsers/action.rs | 1 + + toml-config/src/lib.rs | 4 ++++ + 8 files changed, 82 insertions(+) + +diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs +index 9a83a87e..9581b771 100644 +--- a/jay-config/src/_private/client.rs ++++ b/jay-config/src/_private/client.rs +@@ -399,6 +399,14 @@ impl ConfigClient { + self.send(&ClientMessage::SeatFocusTiles { seat }); + } + ++ pub fn seat_focus_floats(&self, seat: Seat) { ++ self.send(&ClientMessage::SeatFocusFloats { seat }); ++ } ++ ++ pub fn seat_toggle_focus_float_tiled(&self, seat: Seat) { ++ self.send(&ClientMessage::SeatToggleFocusFloatTiled { seat }); ++ } ++ + pub fn seat_focus(&self, seat: Seat, direction: Direction) { + self.send(&ClientMessage::SeatFocus { seat, direction }); + } +diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs +index 74e27f76..a4ebc27c 100644 +--- a/jay-config/src/_private/ipc.rs ++++ b/jay-config/src/_private/ipc.rs +@@ -754,6 +754,12 @@ pub enum ClientMessage<'a> { + SeatFocusTiles { + seat: Seat, + }, ++ SeatFocusFloats { ++ seat: Seat, ++ }, ++ SeatToggleFocusFloatTiled { ++ seat: Seat, ++ }, + SetMiddleClickPasteEnabled { + enabled: bool, + }, +diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs +index 23162537..8f3d4d27 100644 +--- a/jay-config/src/input.rs ++++ b/jay-config/src/input.rs +@@ -321,6 +321,19 @@ impl Seat { + get!().seat_focus_tiles(self) + } + ++ /// Moves the keyboard focus to the topmost floating window. ++ pub fn focus_floats(self) { ++ get!().seat_focus_floats(self) ++ } ++ ++ /// Toggles keyboard focus between the floating and tiled layers. ++ /// ++ /// If focus is on the tiled or fullscreen layer, focus moves to the topmost float. ++ /// If focus is on the floating layer, focus moves to the tiled layer. ++ pub fn toggle_focus_float_tiled(self) { ++ get!().seat_toggle_focus_float_tiled(self) ++ } ++ + /// Moves the keyboard focus of the seat in the specified direction. + pub fn focus(self, direction: Direction) { + get!().seat_focus(self, direction) +diff --git a/src/config/handler.rs b/src/config/handler.rs +index 9ab0934a..88d801b7 100644 +--- a/src/config/handler.rs ++++ b/src/config/handler.rs +@@ -2397,6 +2397,18 @@ impl ConfigProxyHandler { + Ok(()) + } + ++ fn handle_seat_focus_floats(&self, seat: Seat) -> Result<(), CphError> { ++ let seat = self.get_seat(seat)?; ++ seat.focus_floats(); ++ Ok(()) ++ } ++ ++ fn handle_seat_toggle_focus_float_tiled(&self, seat: Seat) -> Result<(), CphError> { ++ let seat = self.get_seat(seat)?; ++ seat.toggle_focus_float_tiled(); ++ Ok(()) ++ } ++ + fn handle_set_middle_click_paste_enabled(&self, enabled: bool) { + self.state.set_primary_selection_enabled(enabled); + } +@@ -3380,6 +3392,13 @@ impl ConfigProxyHandler { + ClientMessage::SeatFocusTiles { seat } => { + self.handle_seat_focus_tiles(seat).wrn("seat_focus_tiles")? + } ++ ClientMessage::SeatFocusFloats { seat } => { ++ self.handle_seat_focus_floats(seat).wrn("seat_focus_floats")? ++ } ++ ClientMessage::SeatToggleFocusFloatTiled { seat } => { ++ self.handle_seat_toggle_focus_float_tiled(seat) ++ .wrn("seat_toggle_focus_float_tiled")? ++ } + ClientMessage::SetMiddleClickPasteEnabled { enabled } => { + self.handle_set_middle_click_paste_enabled(enabled) + } +diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs +index 68fe5366..31084948 100644 +--- a/src/ifs/wl_seat.rs ++++ b/src/ifs/wl_seat.rs +@@ -1103,6 +1103,36 @@ impl WlSeatGlobal { + ); + } + ++ pub fn toggle_focus_float_tiled(self: &Rc) { ++ let current = self.keyboard_node.get(); ++ match current.node_layer().layer() { ++ NodeLayer::Tiled | NodeLayer::Fullscreen => self.focus_floats(), ++ _ => self.focus_tiles(), ++ } ++ self.warp_cursor_to_focus(); ++ } ++ ++ pub fn focus_floats(self: &Rc) { ++ let current = self.keyboard_node.get(); ++ if current.node_layer().layer() == NodeLayer::Stacked { ++ return; ++ } ++ let Some(output) = current.node_output() else { ++ return; ++ }; ++ let Some(ws) = output.workspace.get() else { ++ return; ++ }; ++ if let Some(child) = ws ++ .stacked ++ .rev_iter() ++ .filter_map(|node| (*node).clone().node_into_float()) ++ .find_map(|float| float.child.get()) ++ { ++ child.node_do_focus(self, Direction::Unspecified); ++ } ++ } ++ + pub fn focus_tiles(self: &Rc) { + let current = self.keyboard_node.get(); + if matches!( +diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs +index 86120f08..cedcf78f 100644 +--- a/toml-config/src/config.rs ++++ b/toml-config/src/config.rs +@@ -83,6 +83,7 @@ pub enum SimpleCommand { + FocusHistory(Timeline), + FocusLayerRel(LayerDirection), + FocusTiles, ++ ToggleFocusFloatTiled, + CreateMark, + JumpToMark, + PopMode(bool), +diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs +index 7ea8fec4..43b53774 100644 +--- a/toml-config/src/config/parsers/action.rs ++++ b/toml-config/src/config/parsers/action.rs +@@ -158,6 +158,7 @@ impl ActionParser<'_> { + "focus-below" => FocusLayerRel(LayerDirection::Below), + "focus-above" => FocusLayerRel(LayerDirection::Above), + "focus-tiles" => FocusTiles, ++ "toggle-float-focus" => ToggleFocusFloatTiled, + "create-mark" => CreateMark, + "jump-to-mark" => JumpToMark, + "clear-modes" => PopMode(false), +diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs +index b2fd985f..34c69903 100644 +--- a/toml-config/src/lib.rs ++++ b/toml-config/src/lib.rs +@@ -217,6 +217,10 @@ impl Action { + let persistent = state.persistent.clone(); + b.new(move || persistent.seat.focus_tiles()) + } ++ SimpleCommand::ToggleFocusFloatTiled => { ++ let persistent = state.persistent.clone(); ++ b.new(move || persistent.seat.toggle_focus_float_tiled()) ++ } + SimpleCommand::CreateMark => { + let persistent = state.persistent.clone(); + b.new(move || persistent.seat.create_mark(None)) +-- +2.53.0 + diff --git a/graphical/desktop/patches/0007-add-open-close-animations-for-tiled-windows.patch b/graphical/desktop/patches/0007-add-open-close-animations-for-tiled-windows.patch new file mode 100644 index 0000000..9ee54ef --- /dev/null +++ b/graphical/desktop/patches/0007-add-open-close-animations-for-tiled-windows.patch @@ -0,0 +1,975 @@ +From 424a39fb0a9f3d84a483fc99f7edf274536627d8 Mon Sep 17 00:00:00 2001 +From: atagen +Date: Sat, 7 Mar 2026 18:40:54 +1100 +Subject: [PATCH 07/10] add open/close animations for tiled windows + +--- + jay-config/src/_private/client.rs | 14 +- + jay-config/src/_private/ipc.rs | 11 +- + jay-config/src/theme.rs | 31 +++++ + src/animation.rs | 21 ++- + src/config/handler.rs | 24 +++- + src/ifs/wl_seat/pointer_owner.rs | 3 +- + src/renderer.rs | 130 +++++++++++++++++-- + src/state.rs | 2 +- + src/theme.rs | 44 +++++++ + src/tree/container.rs | 163 ++++++++++++++++++++++-- + src/tree/float.rs | 3 + + src/tree/toplevel.rs | 49 ++++++- + src/tree/workspace.rs | 8 +- + toml-config/src/config.rs | 3 +- + toml-config/src/config/parsers/theme.rs | 23 +++- + toml-config/src/lib.rs | 5 +- + 16 files changed, 494 insertions(+), 40 deletions(-) + +diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs +index 9581b771..2a1ee047 100644 +--- a/jay-config/src/_private/client.rs ++++ b/jay-config/src/_private/client.rs +@@ -26,7 +26,9 @@ use { + }, + logging::LogLevel, + tasks::{JoinHandle, JoinSlot}, +- theme::{AnimationMode, BarPosition, Color, colors::Colorable, sized::Resizable}, ++ theme::{ ++ AnimationMode, BarPosition, Color, SpawnAnimation, colors::Colorable, sized::Resizable, ++ }, + timer::Timer, + video::{ + BlendSpace, ColorSpace, Connector, DrmDevice, Eotf, Format, GfxApi, Mode, TearingMode, +@@ -1053,6 +1055,16 @@ impl ConfigClient { + mode + } + ++ pub fn set_spawn_animation(&self, animation: SpawnAnimation) { ++ self.send(&ClientMessage::SetSpawnAnimation { animation }); ++ } ++ ++ pub fn get_spawn_animation(&self) -> SpawnAnimation { ++ let res = self.send_with_response(&ClientMessage::GetSpawnAnimation); ++ get_response!(res, SpawnAnimation::None, GetSpawnAnimation { animation }); ++ animation ++ } ++ + pub fn set_middle_click_paste_enabled(&self, enabled: bool) { + self.send(&ClientMessage::SetMiddleClickPasteEnabled { enabled }); + } +diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs +index a4ebc27c..9ed55666 100644 +--- a/jay-config/src/_private/ipc.rs ++++ b/jay-config/src/_private/ipc.rs +@@ -10,7 +10,9 @@ use { + }, + keyboard::{Group, Keymap, mods::Modifiers, syms::KeySym}, + logging::LogLevel, +- theme::{AnimationMode, BarPosition, Color, colors::Colorable, sized::Resizable}, ++ theme::{ ++ AnimationMode, BarPosition, Color, SpawnAnimation, colors::Colorable, sized::Resizable, ++ }, + timer::Timer, + video::{ + BlendSpace, ColorSpace, Connector, DrmDevice, Eotf, Format, GfxApi, TearingMode, +@@ -834,6 +836,10 @@ pub enum ClientMessage<'a> { + mode: AnimationMode, + }, + GetAnimationMode, ++ SetSpawnAnimation { ++ animation: SpawnAnimation, ++ }, ++ GetSpawnAnimation, + ConnectorSetUseNativeGamut { + connector: Connector, + use_native_gamut: bool, +@@ -1108,6 +1114,9 @@ pub enum Response { + GetAnimationMode { + mode: AnimationMode, + }, ++ GetSpawnAnimation { ++ animation: SpawnAnimation, ++ }, + GetCursorFollowsFocus { + enabled: bool, + }, +diff --git a/jay-config/src/theme.rs b/jay-config/src/theme.rs +index 90945b47..18cb2ff9 100644 +--- a/jay-config/src/theme.rs ++++ b/jay-config/src/theme.rs +@@ -221,6 +221,37 @@ pub fn get_animation_mode() -> AnimationMode { + get!(AnimationMode::Concurrent).get_animation_mode() + } + ++/// The animation used when tiled windows are opened or closed. ++#[non_exhaustive] ++#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Default)] ++pub enum SpawnAnimation { ++ /// No spawn/despawn animation. ++ #[default] ++ None, ++ /// Window slides in from the left. ++ SlideLeft, ++ /// Window slides in from the right. ++ SlideRight, ++ /// Window slides in from the top. ++ SlideTop, ++ /// Window slides in from the bottom. ++ SlideBottom, ++ /// Window grows from the center. ++ Grow, ++} ++ ++/// Sets the spawn animation for tiled windows. ++/// ++/// Default: `None`. ++pub fn set_spawn_animation(animation: SpawnAnimation) { ++ get!().set_spawn_animation(animation); ++} ++ ++/// Gets the current spawn animation. ++pub fn get_spawn_animation() -> SpawnAnimation { ++ get!(SpawnAnimation::None).get_spawn_animation() ++} ++ + /// Elements of the compositor whose color can be changed. + pub mod colors { + use { +diff --git a/src/animation.rs b/src/animation.rs +index 02d034a7..ed1d1f8d 100644 +--- a/src/animation.rs ++++ b/src/animation.rs +@@ -16,6 +16,7 @@ pub struct PositionAnimation { + pub duration_usec: u64, + pub mode_x: AnimationMode, + pub mode_y: AnimationMode, ++ pub reverse: bool, + } + + fn ease_out_cubic(t: f64) -> f64 { +@@ -63,11 +64,21 @@ impl PositionAnimation { + let t = elapsed as f64 / self.duration_usec as f64; + let (move_remaining_x, size_remaining_x) = sequential_factors(t, self.mode_x); + let (move_remaining_y, size_remaining_y) = sequential_factors(t, self.mode_y); +- let dx = (self.offset_x * move_remaining_x).round() as i32; +- let dy = (self.offset_y * move_remaining_y).round() as i32; +- let dw = (self.offset_w * size_remaining_x).round() as i32; +- let dh = (self.offset_h * size_remaining_y).round() as i32; +- if dx == 0 && dy == 0 && dw == 0 && dh == 0 { ++ let (move_factor_x, size_factor_x, move_factor_y, size_factor_y) = if self.reverse { ++ ( ++ 1.0 - move_remaining_x, ++ 1.0 - size_remaining_x, ++ 1.0 - move_remaining_y, ++ 1.0 - size_remaining_y, ++ ) ++ } else { ++ (move_remaining_x, size_remaining_x, move_remaining_y, size_remaining_y) ++ }; ++ let dx = (self.offset_x * move_factor_x).round() as i32; ++ let dy = (self.offset_y * move_factor_y).round() as i32; ++ let dw = (self.offset_w * size_factor_x).round() as i32; ++ let dh = (self.offset_h * size_factor_y).round() as i32; ++ if dx == 0 && dy == 0 && dw == 0 && dh == 0 && !self.reverse { + return None; + } + Some((dx, dy, dw, dh)) +diff --git a/src/config/handler.rs b/src/config/handler.rs +index 88d801b7..5a919daa 100644 +--- a/src/config/handler.rs ++++ b/src/config/handler.rs +@@ -67,7 +67,9 @@ use { + }, + keyboard::{Group, Keymap, mods::Modifiers, syms::KeySym}, + logging::LogLevel, +- theme::{AnimationMode, BarPosition, colors::Colorable, sized::Resizable}, ++ theme::{ ++ AnimationMode, BarPosition, SpawnAnimation, colors::Colorable, sized::Resizable, ++ }, + timer::Timer as JayTimer, + video::{ + BlendSpace as ConfigBlendSpace, ColorSpace, Connector, DrmDevice, Eotf as ConfigEotf, +@@ -1488,6 +1490,20 @@ impl ConfigProxyHandler { + }); + } + ++ fn handle_set_spawn_animation(&self, animation: SpawnAnimation) -> Result<(), CphError> { ++ let Ok(animation) = animation.try_into() else { ++ return Err(CphError::UnknownSpawnAnimation(animation)); ++ }; ++ self.state.theme.spawn_animation.set(animation); ++ Ok(()) ++ } ++ ++ fn handle_get_spawn_animation(&self) { ++ self.respond(Response::GetSpawnAnimation { ++ animation: self.state.theme.spawn_animation.get().into(), ++ }); ++ } ++ + fn handle_set_show_float_pin_icon(&self, show: bool) { + self.state.show_pin_icon.set(show); + for stacked in self.state.root.stacked.iter() { +@@ -3374,6 +3390,10 @@ impl ConfigProxyHandler { + .handle_set_animation_mode(mode) + .wrn("set_animation_mode")?, + ClientMessage::GetAnimationMode => self.handle_get_animation_mode(), ++ ClientMessage::SetSpawnAnimation { animation } => self ++ .handle_set_spawn_animation(animation) ++ .wrn("set_spawn_animation")?, ++ ClientMessage::GetSpawnAnimation => self.handle_get_spawn_animation(), + ClientMessage::SeatFocusHistory { seat, timeline } => self + .handle_seat_focus_history(seat, timeline) + .wrn("seat_focus_history")?, +@@ -3625,6 +3645,8 @@ enum CphError { + UnknownBarPosition(BarPosition), + #[error("Unknown animation mode {0:?}")] + UnknownAnimationMode(AnimationMode), ++ #[error("Unknown spawn animation {0:?}")] ++ UnknownSpawnAnimation(SpawnAnimation), + #[error("Unknown gfx API {0:?}")] + UnknownGfxApi(GfxApi), + #[error("Unknown fallback output mode {0:?}")] +diff --git a/src/ifs/wl_seat/pointer_owner.rs b/src/ifs/wl_seat/pointer_owner.rs +index e9f4ced1..1a045731 100644 +--- a/src/ifs/wl_seat/pointer_owner.rs ++++ b/src/ifs/wl_seat/pointer_owner.rs +@@ -1517,6 +1517,7 @@ impl UiDragUsecase for TileDragUsecase { + &workspace, + src.clone(), + ContainerSplit::Horizontal, ++ None, + ); + workspace.set_container(&cn); + }; +@@ -1542,7 +1543,7 @@ impl UiDragUsecase for TileDragUsecase { + return; + }; + let placeholder = detach(); +- let cn = ContainerNode::new(&seat.state, &ws, node.clone(), split); ++ let cn = ContainerNode::new(&seat.state, &ws, node.clone(), split, None); + pn.cnode_replace_child(&*node, cn.clone()); + match before { + true => cn.add_child_before(&*node, src), +diff --git a/src/renderer.rs b/src/renderer.rs +index c2622fac..93178d77 100644 +--- a/src/renderer.rs ++++ b/src/renderer.rs +@@ -221,6 +221,38 @@ impl Renderer<'_> { + if let Some(node) = workspace.container.get() { + self.render_container(&node, x, y) + } ++ let now_usec = Time::now_unchecked().usec(); ++ let srgb_srgb = self.state.color_manager.srgb_gamma22(); ++ let srgb = &srgb_srgb.linear; ++ let border_color = self.state.theme.colors.border.get(); ++ let focused_border_color = self.state.theme.colors.focused_title_background.get(); ++ let mut dying = workspace.dying_children.borrow_mut(); ++ let mut has_dying = false; ++ dying.retain(|dc| { ++ let Some((dx, dy, dw, dh)) = dc.animation.current_offset(now_usec) else { ++ return false; ++ }; ++ has_dying = true; ++ let anim_w = (dc.body.width() + dw).max(1); ++ let anim_h = (dc.body.height() + dh).max(1); ++ let anim_body = Rect::new_sized_saturating( ++ dc.body.x1() + dx, ++ dc.body.y1() + dy, ++ anim_w, ++ anim_h, ++ ); ++ let c = if dc.border_color_is_focused { ++ &focused_border_color ++ } else { ++ &border_color ++ }; ++ self.base ++ .fill_boxes2(std::slice::from_ref(&anim_body), c, srgb, x, y); ++ true ++ }); ++ if has_dying { ++ self.state.damage(workspace.position.get()); ++ } + } + + pub fn render_placeholder( +@@ -314,18 +346,75 @@ impl Renderer<'_> { + } + } + if let Some(child) = container.mono_child.get() { +- let body = container.mono_body.get().move_(x, y); +- let body = self.base.scale_rect(body); +- let content = container.mono_content.get(); +- child +- .node +- .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); ++ let mb = container.mono_body.get(); ++ let now_usec = Time::now_unchecked().usec(); ++ let (anim_dx, anim_dy, anim_dw, anim_dh) = child ++ .body_animation ++ .get() ++ .and_then(|a| a.current_offset(now_usec)) ++ .unwrap_or((0, 0, 0, 0)); ++ if anim_dx != 0 || anim_dy != 0 || anim_dw != 0 || anim_dh != 0 { ++ let anim_w = (mb.width() + anim_dw).max(1); ++ let anim_h = (mb.height() + anim_dh).max(1); ++ if anim_w < mb.width() || anim_h < mb.height() { ++ let srgb_srgb = self.state.color_manager.srgb_gamma22(); ++ let srgb = &srgb_srgb.linear; ++ let border_color = self.state.theme.colors.background.get(); ++ let anim_body = Rect::new_sized_saturating( ++ mb.x1() + anim_dx, ++ mb.y1() + anim_dy, ++ anim_w, ++ anim_h, ++ ); ++ self.base.fill_boxes2( ++ std::slice::from_ref(&anim_body), ++ &border_color, ++ srgb, ++ x, ++ y, ++ ); ++ } ++ let clip_w = anim_w.min(mb.width()); ++ let clip_h = anim_h.min(mb.height()); ++ let body = Rect::new_sized_saturating( ++ mb.x1() + anim_dx, ++ mb.y1() + anim_dy, ++ clip_w, ++ clip_h, ++ ); ++ let body = body.move_(x, y); ++ let body = self.base.scale_rect(body); ++ let content = container.mono_content.get(); ++ child.node.node_render( ++ self, ++ x + content.x1() + anim_dx, ++ y + content.y1() + anim_dy, ++ Some(&body), ++ ); ++ let abs_x = container.abs_x1.get(); ++ let abs_y = container.abs_y1.get(); ++ self.state.damage(Rect::new_sized_saturating( ++ abs_x, ++ abs_y, ++ container.width.get(), ++ container.height.get(), ++ )); ++ } else { ++ child.body_animation.set(None); ++ let body = mb.move_(x, y); ++ let body = self.base.scale_rect(body); ++ let content = container.mono_content.get(); ++ child ++ .node ++ .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); ++ } + } else { + let srgb_srgb = self.state.color_manager.srgb_gamma22(); + let srgb = &srgb_srgb.linear; + let theme = &self.state.theme; + let colors = &theme.colors; + let border_color = colors.border.get(); ++ let background_color = colors.background.get(); + let focused_border_color = colors.focused_title_background.get(); + let unfocused_title_bg = colors.unfocused_title_background.get(); + let focused_title_bg = colors.focused_title_background.get(); +@@ -483,7 +572,7 @@ impl Renderer<'_> { + if anim_w > body.width() || anim_h > body.height() { + self.base.fill_boxes2( + std::slice::from_ref(&anim_body), +- &border_color, ++ &background_color, + srgb, + x, + y, +@@ -704,7 +793,32 @@ impl Renderer<'_> { + Some(c) => c, + _ => return, + }; +- let pos = floating.position.get(); ++ let now_usec = Time::now_unchecked().usec(); ++ let base_pos = floating.position.get(); ++ let (anim_dx, anim_dy, anim_dw, anim_dh) = ++ if let Some(anim) = floating.body_animation.get() { ++ match anim.current_offset(now_usec) { ++ Some(offsets) => offsets, ++ None => { ++ floating.body_animation.set(None); ++ self.state.damage(base_pos); ++ (0, 0, 0, 0) ++ } ++ } ++ } else { ++ (0, 0, 0, 0) ++ }; ++ let anim_w = (base_pos.width() + anim_dw).max(1); ++ let anim_h = (base_pos.height() + anim_dh).max(1); ++ let pos = Rect::new_sized_saturating( ++ base_pos.x1() + anim_dx, ++ base_pos.y1() + anim_dy, ++ anim_w, ++ anim_h, ++ ); ++ if anim_dx != 0 || anim_dy != 0 || anim_dw != 0 || anim_dh != 0 { ++ self.state.damage(base_pos.union(pos)); ++ } + let theme = &self.state.theme; + let th = theme.title_height(); + let tpuh = theme.title_plus_underline_height(); +diff --git a/src/state.rs b/src/state.rs +index 4eee9bf7..86a42dee 100644 +--- a/src/state.rs ++++ b/src/state.rs +@@ -813,7 +813,7 @@ impl State { + c.append_child(node); + } + } else { +- let container = ContainerNode::new(self, ws, node, ContainerSplit::Horizontal); ++ let container = ContainerNode::new(self, ws, node, ContainerSplit::Horizontal, None); + ws.set_container(&container); + } + } +diff --git a/src/theme.rs b/src/theme.rs +index 0709f3d4..f705c4fd 100644 +--- a/src/theme.rs ++++ b/src/theme.rs +@@ -8,6 +8,7 @@ use { + }, + jay_config::theme::{ + AnimationMode as ConfigAnimationMode, BarPosition as ConfigBarPosition, ++ SpawnAnimation as ConfigSpawnAnimation, + }, + linearize::Linearize, + num_traits::Float, +@@ -578,6 +579,47 @@ pub enum AnimationMode { + Sequential, + } + ++#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] ++pub enum SpawnAnimation { ++ #[default] ++ None, ++ SlideLeft, ++ SlideRight, ++ SlideTop, ++ SlideBottom, ++ Grow, ++} ++ ++impl TryFrom for SpawnAnimation { ++ type Error = (); ++ ++ fn try_from(value: ConfigSpawnAnimation) -> Result { ++ let v = match value { ++ ConfigSpawnAnimation::None => Self::None, ++ ConfigSpawnAnimation::SlideLeft => Self::SlideLeft, ++ ConfigSpawnAnimation::SlideRight => Self::SlideRight, ++ ConfigSpawnAnimation::SlideTop => Self::SlideTop, ++ ConfigSpawnAnimation::SlideBottom => Self::SlideBottom, ++ ConfigSpawnAnimation::Grow => Self::Grow, ++ _ => return Err(()), ++ }; ++ Ok(v) ++ } ++} ++ ++impl Into for SpawnAnimation { ++ fn into(self) -> ConfigSpawnAnimation { ++ match self { ++ SpawnAnimation::None => ConfigSpawnAnimation::None, ++ SpawnAnimation::SlideLeft => ConfigSpawnAnimation::SlideLeft, ++ SpawnAnimation::SlideRight => ConfigSpawnAnimation::SlideRight, ++ SpawnAnimation::SlideTop => ConfigSpawnAnimation::SlideTop, ++ SpawnAnimation::SlideBottom => ConfigSpawnAnimation::SlideBottom, ++ SpawnAnimation::Grow => ConfigSpawnAnimation::Grow, ++ } ++ } ++} ++ + pub struct Theme { + pub colors: ThemeColors, + pub sizes: ThemeSizes, +@@ -588,6 +630,7 @@ pub struct Theme { + pub show_titles: Cell, + pub bar_position: Cell, + pub animation_mode: Cell, ++ pub spawn_animation: Cell, + } + + impl Default for Theme { +@@ -603,6 +646,7 @@ impl Default for Theme { + show_titles: Cell::new(true), + bar_position: Default::default(), + animation_mode: Default::default(), ++ spawn_animation: Default::default(), + } + } + } +diff --git a/src/tree/container.rs b/src/tree/container.rs +index 85a0b97d..2d8d328d 100644 +--- a/src/tree/container.rs ++++ b/src/tree/container.rs +@@ -165,6 +165,12 @@ pub struct ContainerChild { + pub border_color_is_focused: Cell, + } + ++pub struct DyingChild { ++ pub body: Rect, ++ pub animation: PositionAnimation, ++ pub border_color_is_focused: bool, ++} ++ + #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] + enum CursorType { + Seat(SeatId), +@@ -203,12 +209,13 @@ impl ContainerNode { + workspace: &Rc, + child: Rc, + split: ContainerSplit, ++ initial_child_body: Option, + ) -> Rc { + let children = LinkedList::new(); + let child_node = children.add_last(ContainerChild { + node: child.clone(), + active: Default::default(), +- body: Default::default(), ++ body: Cell::new(initial_child_body.unwrap_or_default()), + content: Default::default(), + factor: Cell::new(1.0), + title: Default::default(), +@@ -453,6 +460,48 @@ impl ContainerNode { + }; + if let Some(child) = self.mono_child.get() { + self.perform_mono_layout(&child); ++ if animation_duration > 0 && child.body.get().is_empty() { ++ let mb = self.mono_body.get(); ++ if !mb.is_empty() { ++ let spawn = self.state.theme.spawn_animation.get(); ++ let (dx, dy, dw, dh): (f64, f64, f64, f64) = match spawn { ++ crate::theme::SpawnAnimation::None => (0.0, 0.0, 0.0, 0.0), ++ crate::theme::SpawnAnimation::SlideLeft => { ++ (-(mb.width() as f64), 0.0, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideRight => { ++ (mb.width() as f64, 0.0, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideTop => { ++ (0.0, -(mb.height() as f64), 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideBottom => { ++ (0.0, mb.height() as f64, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::Grow => ( ++ mb.width() as f64 / 2.0, ++ mb.height() as f64 / 2.0, ++ -(mb.width() as f64), ++ -(mb.height() as f64), ++ ), ++ }; ++ if dx != 0.0 || dy != 0.0 || dw != 0.0 || dh != 0.0 { ++ let now_usec = Time::now_unchecked().usec(); ++ child.body_animation.set(Some(PositionAnimation { ++ offset_x: dx, ++ offset_y: dy, ++ offset_w: dw, ++ offset_h: dh, ++ start_usec: now_usec, ++ duration_usec: (animation_duration as u64) * 1000, ++ mode_x: crate::animation::AnimationMode::Concurrent, ++ mode_y: crate::animation::AnimationMode::Concurrent, ++ reverse: false, ++ })); ++ } ++ } ++ } ++ child.body.set(self.mono_body.get()); + } else { + self.perform_split_layout(); + } +@@ -498,7 +547,46 @@ impl ContainerNode { + let child_id = child.node.node_id(); + let old = match old_bodies.iter().find(|(id, _)| *id == child_id) { + Some((_, rect)) if !rect.is_empty() => *rect, +- _ => continue, ++ _ => { ++ let new_body = child.body.get(); ++ if new_body.is_empty() { ++ continue; ++ } ++ let spawn = self.state.theme.spawn_animation.get(); ++ let (dx, dy, dw, dh): (f64, f64, f64, f64) = match spawn { ++ crate::theme::SpawnAnimation::None => continue, ++ crate::theme::SpawnAnimation::SlideLeft => { ++ (-(new_body.width() as f64), 0.0, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideRight => { ++ (new_body.width() as f64, 0.0, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideTop => { ++ (0.0, -(new_body.height() as f64), 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideBottom => { ++ (0.0, new_body.height() as f64, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::Grow => ( ++ new_body.width() as f64 / 2.0, ++ new_body.height() as f64 / 2.0, ++ -(new_body.width() as f64), ++ -(new_body.height() as f64), ++ ), ++ }; ++ child.body_animation.set(Some(PositionAnimation { ++ offset_x: dx, ++ offset_y: dy, ++ offset_w: dw, ++ offset_h: dh, ++ start_usec: now_usec, ++ duration_usec: (animation_duration as u64) * 1000, ++ mode_x: crate::animation::AnimationMode::Concurrent, ++ mode_y: crate::animation::AnimationMode::Concurrent, ++ reverse: false, ++ })); ++ continue; ++ } + }; + let new_body = child.body.get(); + let dx = old.x1() - new_body.x1(); +@@ -2158,24 +2246,75 @@ impl ContainingNode for ContainerNode { + self.activate_child2(child, preserve_focus); + } + } ++ } else if !preserve_focus { ++ if let Some(next) = node.next().or_else(|| node.prev()) { ++ let mut seats = SmallVec::<[_; 3]>::new(); ++ collect_kb_foci2(node.node.clone(), &mut seats); ++ for seat in seats { ++ next.node.clone().node_do_focus(&seat, Direction::Unspecified); ++ } ++ } + } ++ let child_body = node.body.get(); ++ let child_focused = node.border_color_is_focused.get(); + let node = { + let node = node; + node.focus_history.set(None); + node.to_ref() + }; ++ let animation_duration = self.state.theme.sizes.animation_duration.get(); ++ let spawn = self.state.theme.spawn_animation.get(); ++ if animation_duration > 0 ++ && spawn != crate::theme::SpawnAnimation::None ++ && !child_body.is_empty() ++ && !preserve_focus ++ { ++ let (dx, dy, dw, dh): (f64, f64, f64, f64) = match spawn { ++ crate::theme::SpawnAnimation::None => unreachable!(), ++ crate::theme::SpawnAnimation::SlideLeft => { ++ (-(child_body.width() as f64), 0.0, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideRight => { ++ (child_body.width() as f64, 0.0, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideTop => { ++ (0.0, -(child_body.height() as f64), 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::SlideBottom => { ++ (0.0, child_body.height() as f64, 0.0, 0.0) ++ } ++ crate::theme::SpawnAnimation::Grow => ( ++ child_body.width() as f64 / 2.0, ++ child_body.height() as f64 / 2.0, ++ -(child_body.width() as f64), ++ -(child_body.height() as f64), ++ ), ++ }; ++ let now_usec = Time::now_unchecked().usec(); ++ let ws_pos = self.workspace.get().position.get(); ++ let ws_child_body = child_body.move_( ++ self.abs_x1.get() - ws_pos.x1(), ++ self.abs_y1.get() - ws_pos.y1(), ++ ); ++ self.workspace.get().dying_children.borrow_mut().push(DyingChild { ++ body: ws_child_body, ++ animation: PositionAnimation { ++ offset_x: dx, ++ offset_y: dy, ++ offset_w: dw, ++ offset_h: dh, ++ start_usec: now_usec, ++ duration_usec: (animation_duration as u64) * 1000, ++ mode_x: crate::animation::AnimationMode::Concurrent, ++ mode_y: crate::animation::AnimationMode::Concurrent, ++ reverse: true, ++ }, ++ border_color_is_focused: child_focused, ++ }); ++ self.state.damage(self.workspace.get().position.get()); ++ } + let num_children = self.num_children.fetch_sub(1) - 1; + if num_children == 0 { +- let gap = self.state.theme.sizes.gap.get(); +- if gap > 0 { +- let bw = self.state.theme.sizes.border_width.get(); +- self.state.damage(Rect::new_sized_saturating( +- self.abs_x1.get() - bw, +- self.abs_y1.get() - bw, +- self.width.get() + 2 * bw, +- self.height.get() + 2 * bw, +- )); +- } + self.tl_destroy(); + return; + } +diff --git a/src/tree/float.rs b/src/tree/float.rs +index ccad4860..7d610898 100644 +--- a/src/tree/float.rs ++++ b/src/tree/float.rs +@@ -1,5 +1,6 @@ + use { + crate::{ ++ animation::PositionAnimation, + backend::ButtonState, + cursor::KnownCursor, + cursor_user::CursorUser, +@@ -56,6 +57,7 @@ pub struct FloatNode { + pub title_textures: RefCell>, + cursors: RefCell>, + pub attention_requested: Cell, ++ pub body_animation: Cell>, + } + + #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +@@ -136,6 +138,7 @@ impl FloatNode { + title_textures: Default::default(), + cursors: Default::default(), + attention_requested: Cell::new(false), ++ body_animation: Default::default(), + }); + floater.pull_child_properties(); + *floater.display_link.borrow_mut() = Some(state.root.stacked.add_last(floater.clone())); +diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs +index 8ec1e105..2e48b2fd 100644 +--- a/src/tree/toplevel.rs ++++ b/src/tree/toplevel.rs +@@ -994,8 +994,10 @@ pub fn toplevel_create_split(state: &Rc, tl: Rc, axis: + _ => return, + }; + if let Some(pn) = pn.node_into_containing_node() { +- let cn = ContainerNode::new(state, &ws, tl.clone(), axis); +- pn.cnode_replace_child(&*tl, cn); ++ let abs_body = tl.node_absolute_position(); ++ let cn = ContainerNode::new(state, &ws, tl.clone(), axis, None); ++ pn.cnode_replace_child(&*tl, cn.clone()); ++ cn.seed_child_body(tl.node_id(), abs_body); + } + } + +@@ -1012,12 +1014,49 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati + _ => return, + }; + if !floating { ++ // Float-to-tile: capture float position before removal ++ let float_pos = data.float.get().map(|f| f.position.get()); + parent.cnode_remove_child2(&*tl, true); +- state.map_tiled(tl); ++ state.map_tiled(tl.clone()); ++ // Seed the child's body from the float position so perform_layout ++ // computes a meaningful delta (slide from float pos to tiled pos) ++ if let Some(float_rect) = float_pos { ++ if let Some(cn) = data.parent.get().and_then(|p| p.node_into_container()) { ++ cn.seed_child_body(tl.node_id(), float_rect); ++ } ++ } + } else if let Some(ws) = data.workspace.get() { +- parent.cnode_remove_child2(&*tl, true); ++ // Tile-to-float: capture tiled position and size before removal ++ let tiled_pos = tl.node_absolute_position(); + let (width, height) = data.float_size(&ws); +- state.map_floating(tl, width, height, &ws, None); ++ let animation_duration = state.theme.sizes.animation_duration.get(); ++ let _ = data; ++ parent.cnode_remove_child2(&*tl, true); ++ state.map_floating(tl.clone(), width, height, &ws, None); ++ // Set animation on the new FloatNode ++ let data = tl.tl_data(); ++ if let Some(float) = data.float.get() { ++ let target = float.position.get(); ++ if !tiled_pos.is_empty() && !target.is_empty() && animation_duration > 0 { ++ let dx = (tiled_pos.x1() - target.x1()) as f64; ++ let dy = (tiled_pos.y1() - target.y1()) as f64; ++ let dw = (tiled_pos.width() - target.width()) as f64; ++ let dh = (tiled_pos.height() - target.height()) as f64; ++ let now_usec = crate::time::Time::now_unchecked().usec(); ++ float.body_animation.set(Some(crate::animation::PositionAnimation { ++ offset_x: dx, ++ offset_y: dy, ++ offset_w: dw, ++ offset_h: dh, ++ start_usec: now_usec, ++ duration_usec: (animation_duration as u64) * 1000, ++ mode_x: crate::animation::AnimationMode::Concurrent, ++ mode_y: crate::animation::AnimationMode::Concurrent, ++ reverse: false, ++ })); ++ state.damage(tiled_pos.union(target)); ++ } ++ } + } + } + +diff --git a/src/tree/workspace.rs b/src/tree/workspace.rs +index f60354a4..5378e71c 100644 +--- a/src/tree/workspace.rs ++++ b/src/tree/workspace.rs +@@ -23,7 +23,7 @@ use { + ContainingNode, Direction, FindTreeResult, FindTreeUsecase, FloatNode, FoundNode, Node, + NodeId, NodeLayerLink, NodeLocation, NodeVisitorBase, OutputNode, OutputNodeId, + PlaceholderNode, StackedNode, ToplevelNode, WorkspaceDisplayOrder, +- container::ContainerNode, walker::NodeVisitor, ++ container::{ContainerNode, DyingChild}, walker::NodeVisitor, + }, + utils::{ + clonecell::CloneCell, +@@ -68,6 +68,7 @@ pub struct WorkspaceNode { + pub attention_requests: ThresholdCounter, + pub render_highlight: NumCell, + pub ext_workspaces: CopyHashMap>, ++ pub dying_children: RefCell>, + pub opt: Rc>, + } + +@@ -96,6 +97,7 @@ impl WorkspaceNode { + attention_requests: Default::default(), + render_highlight: Default::default(), + ext_workspaces: Default::default(), ++ dying_children: Default::default(), + opt: Default::default(), + }); + slf.seat_state.disable_focus_history(); +@@ -104,6 +106,7 @@ impl WorkspaceNode { + + pub fn clear(&self) { + self.container.set(None); ++ self.dying_children.borrow_mut().clear(); + *self.output_link.borrow_mut() = None; + self.fullscreen.set(None); + self.jay_workspaces.clear(); +@@ -231,6 +234,9 @@ impl WorkspaceNode { + + pub fn set_visible(&self, visible: bool) { + self.visible.set(visible); ++ if !visible { ++ self.dying_children.borrow_mut().clear(); ++ } + for jw in self.jay_workspaces.lock().values() { + jw.send_visible(visible); + } +diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs +index cedcf78f..39b9fab4 100644 +--- a/toml-config/src/config.rs ++++ b/toml-config/src/config.rs +@@ -32,7 +32,7 @@ use { + keyboard::{Keymap, ModifiedKeySym, mods::Modifiers, syms::KeySym}, + logging::LogLevel, + status::MessageFormat, +- theme::{AnimationMode, BarPosition, Color}, ++ theme::{AnimationMode, BarPosition, Color, SpawnAnimation}, + video::{BlendSpace, ColorSpace, Eotf, Format, GfxApi, TearingMode, Transform, VrrMode}, + window::{ContentType, TileState, WindowType}, + workspace::WorkspaceDisplayOrder, +@@ -213,6 +213,7 @@ pub struct Theme { + pub gap: Option, + pub animation_duration: Option, + pub animation_mode: Option, ++ pub spawn_animation: Option, + } + + #[derive(Debug, Clone)] +diff --git a/toml-config/src/config/parsers/theme.rs b/toml-config/src/config/parsers/theme.rs +index ae9b7ccd..bcfa7cf4 100644 +--- a/toml-config/src/config/parsers/theme.rs ++++ b/toml-config/src/config/parsers/theme.rs +@@ -13,7 +13,7 @@ use { + }, + }, + indexmap::IndexMap, +- jay_config::theme::{AnimationMode, BarPosition}, ++ jay_config::theme::{AnimationMode, BarPosition, SpawnAnimation}, + thiserror::Error, + }; + +@@ -63,7 +63,7 @@ impl Parser for ThemeParser<'_> { + font, + title_font, + ), +- (bar_font, bar_position_val, bar_separator_width, gap, animation_duration, animation_mode_val), ++ (bar_font, bar_position_val, bar_separator_width, gap, animation_duration, animation_mode_val, spawn_animation_val), + ) = ext.extract(( + ( + opt(val("attention-requested-bg-color")), +@@ -96,6 +96,7 @@ impl Parser for ThemeParser<'_> { + recover(opt(s32("gap"))), + recover(opt(s32("animation-duration"))), + recover(opt(str("animation-mode"))), ++ recover(opt(str("spawn-animation"))), + ), + ))?; + macro_rules! color { +@@ -138,6 +139,23 @@ impl Parser for ThemeParser<'_> { + None + } + }); ++ let spawn_animation = ++ spawn_animation_val.and_then(|value| match value.value.to_lowercase().as_str() { ++ "none" => Some(SpawnAnimation::None), ++ "slide-left" => Some(SpawnAnimation::SlideLeft), ++ "slide-right" => Some(SpawnAnimation::SlideRight), ++ "slide-top" => Some(SpawnAnimation::SlideTop), ++ "slide-bottom" => Some(SpawnAnimation::SlideBottom), ++ "grow" => Some(SpawnAnimation::Grow), ++ _ => { ++ log::warn!( ++ "Unknown spawn animation '{}': {}", ++ value.value, ++ self.0.error3(value.span) ++ ); ++ None ++ } ++ }); + Ok(Theme { + attention_requested_bg_color: color!(attention_requested_bg_color), + bg_color: color!(bg_color), +@@ -165,6 +183,7 @@ impl Parser for ThemeParser<'_> { + gap: gap.despan(), + animation_duration: animation_duration.despan(), + animation_mode, ++ spawn_animation, + }) + } + } +diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs +index 34c69903..177e6581 100644 +--- a/toml-config/src/lib.rs ++++ b/toml-config/src/lib.rs +@@ -45,7 +45,7 @@ use { + tasks::{self, JoinHandle}, + theme::{ + reset_colors, reset_font, reset_sizes, set_animation_mode, set_bar_font, +- set_bar_position, set_font, set_title_font, ++ set_bar_position, set_font, set_spawn_animation, set_title_font, + }, + toggle_float_above_fullscreen, toggle_show_bar, toggle_show_titles, + video::{ +@@ -1623,6 +1623,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc +Date: Sat, 7 Mar 2026 19:03:12 +1100 +Subject: [PATCH 08/10] add directional focus navigation for floating windows + +Map left/down to previous and right/up to next in the workspace +stacking order, with wrapping. Does not cross to the tiled layer. +--- + src/ifs/wl_seat.rs | 32 ++++++++++++++++++++++++++++++++ + src/renderer.rs | 6 +++++- + src/tree/float.rs | 3 +++ + 3 files changed, 40 insertions(+), 1 deletion(-) + +diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs +index 31084948..4bb5542f 100644 +--- a/src/ifs/wl_seat.rs ++++ b/src/ifs/wl_seat.rs +@@ -796,6 +796,38 @@ impl WlSeatGlobal { + && let Some(c) = p.node_into_container() + { + c.move_focus_from_child(self, tl.deref(), direction); ++ } else if let Some(float) = data.float.get() ++ { ++ let ws = float.workspace.get(); ++ let floats: Vec<_> = ws ++ .stacked ++ .iter() ++ .filter_map(|node| (*node).clone().node_into_float()) ++ .filter(|f| f.child.get().is_some()) ++ .collect(); ++ if let Some(pos) = floats.iter().position(|f| f.id == float.id) { ++ let target = match direction { ++ Direction::Left | Direction::Down => { ++ if pos == 0 { ++ floats.last() ++ } else { ++ floats.get(pos - 1) ++ } ++ } ++ _ => { ++ if pos + 1 >= floats.len() { ++ floats.first() ++ } else { ++ floats.get(pos + 1) ++ } ++ } ++ }; ++ if let Some(f) = target ++ && f.id != float.id ++ { ++ f.clone().node_do_focus(self, Direction::Unspecified); ++ } ++ } + } + } + self.warp_cursor_to_focus(); +diff --git a/src/renderer.rs b/src/renderer.rs +index 93178d77..1d59f364 100644 +--- a/src/renderer.rs ++++ b/src/renderer.rs +@@ -824,7 +824,11 @@ impl Renderer<'_> { + let tpuh = theme.title_plus_underline_height(); + let tuh = theme.title_underline_height(); + let bw = theme.sizes.border_width.get(); +- let bc = theme.colors.border.get(); ++ let bc = if floating.active.get() { ++ theme.colors.focused_title_background.get() ++ } else { ++ theme.colors.border.get() ++ }; + let tc = if floating.active.get() { + theme.colors.focused_title_background.get() + } else if floating.attention_requested.get() { +diff --git a/src/tree/float.rs b/src/tree/float.rs +index 7d610898..858bdcc9 100644 +--- a/src/tree/float.rs ++++ b/src/tree/float.rs +@@ -486,6 +486,9 @@ impl FloatNode { + fn update_child_active(self: &Rc, active: bool) { + if self.active.replace(active) != active { + self.schedule_render_titles(); ++ if active { ++ self.restack(); ++ } + } + } + +-- +2.53.0 + diff --git a/graphical/desktop/patches/0009-add-configurable-animation-easing-curves.patch b/graphical/desktop/patches/0009-add-configurable-animation-easing-curves.patch new file mode 100644 index 0000000..c17ffb3 --- /dev/null +++ b/graphical/desktop/patches/0009-add-configurable-animation-easing-curves.patch @@ -0,0 +1,566 @@ +From f3aa1b1577a71792bc566989aad7b4319516495e Mon Sep 17 00:00:00 2001 +From: atagen +Date: Mon, 9 Mar 2026 01:04:12 +1100 +Subject: [PATCH 09/10] add configurable animation easing curves + +Adds an animation-curve setting that controls the easing function used +for all window animations. Supports presets (linear, ease-in, ease-out, +ease-in-out) and custom cubic-bezier curves with 4 control points. + +Co-Authored-By: Claude Opus 4.6 +--- + jay-config/src/_private/client.rs | 13 +++- + jay-config/src/_private/ipc.rs | 10 +++- + jay-config/src/theme.rs | 32 ++++++++++ + src/animation.rs | 80 +++++++++++++++++++++---- + src/config/handler.rs | 15 ++++- + src/theme.rs | 45 +++++++++++++- + src/tree/container.rs | 4 ++ + src/tree/toplevel.rs | 1 + + toml-config/src/config.rs | 3 +- + toml-config/src/config/parsers/theme.rs | 59 +++++++++++++++++- + toml-config/src/lib.rs | 7 ++- + 11 files changed, 248 insertions(+), 21 deletions(-) + +diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs +index 2a1ee047..a260813a 100644 +--- a/jay-config/src/_private/client.rs ++++ b/jay-config/src/_private/client.rs +@@ -27,7 +27,8 @@ use { + logging::LogLevel, + tasks::{JoinHandle, JoinSlot}, + theme::{ +- AnimationMode, BarPosition, Color, SpawnAnimation, colors::Colorable, sized::Resizable, ++ AnimationCurve, AnimationMode, BarPosition, Color, SpawnAnimation, ++ colors::Colorable, sized::Resizable, + }, + timer::Timer, + video::{ +@@ -1065,6 +1066,16 @@ impl ConfigClient { + animation + } + ++ pub fn set_animation_curve(&self, curve: AnimationCurve) { ++ self.send(&ClientMessage::SetAnimationCurve { curve }); ++ } ++ ++ pub fn get_animation_curve(&self) -> AnimationCurve { ++ let res = self.send_with_response(&ClientMessage::GetAnimationCurve); ++ get_response!(res, AnimationCurve::EaseOut, GetAnimationCurve { curve }); ++ curve ++ } ++ + pub fn set_middle_click_paste_enabled(&self, enabled: bool) { + self.send(&ClientMessage::SetMiddleClickPasteEnabled { enabled }); + } +diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs +index 9ed55666..282f37cf 100644 +--- a/jay-config/src/_private/ipc.rs ++++ b/jay-config/src/_private/ipc.rs +@@ -11,7 +11,8 @@ use { + keyboard::{Group, Keymap, mods::Modifiers, syms::KeySym}, + logging::LogLevel, + theme::{ +- AnimationMode, BarPosition, Color, SpawnAnimation, colors::Colorable, sized::Resizable, ++ AnimationCurve, AnimationMode, BarPosition, Color, SpawnAnimation, colors::Colorable, ++ sized::Resizable, + }, + timer::Timer, + video::{ +@@ -840,6 +841,10 @@ pub enum ClientMessage<'a> { + animation: SpawnAnimation, + }, + GetSpawnAnimation, ++ SetAnimationCurve { ++ curve: AnimationCurve, ++ }, ++ GetAnimationCurve, + ConnectorSetUseNativeGamut { + connector: Connector, + use_native_gamut: bool, +@@ -1117,6 +1122,9 @@ pub enum Response { + GetSpawnAnimation { + animation: SpawnAnimation, + }, ++ GetAnimationCurve { ++ curve: AnimationCurve, ++ }, + GetCursorFollowsFocus { + enabled: bool, + }, +diff --git a/jay-config/src/theme.rs b/jay-config/src/theme.rs +index 18cb2ff9..b1db8767 100644 +--- a/jay-config/src/theme.rs ++++ b/jay-config/src/theme.rs +@@ -252,6 +252,38 @@ pub fn get_spawn_animation() -> SpawnAnimation { + get!(SpawnAnimation::None).get_spawn_animation() + } + ++/// The easing curve used for animations. ++#[non_exhaustive] ++#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default)] ++pub enum AnimationCurve { ++ /// Linear interpolation (no easing). ++ Linear, ++ /// Cubic ease-in (slow start, fast end). ++ EaseIn, ++ /// Cubic ease-out (fast start, slow end). ++ #[default] ++ EaseOut, ++ /// Cubic ease-in-out (slow start and end). ++ EaseInOut, ++ /// Custom cubic bezier curve with control points (x1, y1, x2, y2). ++ /// ++ /// The curve goes from (0, 0) to (1, 1). The two control points define ++ /// the shape of the curve, similar to the CSS `cubic-bezier()` function. ++ CubicBezier(f64, f64, f64, f64), ++} ++ ++/// Sets the easing curve used for animations. ++/// ++/// Default: `EaseOut`. ++pub fn set_animation_curve(curve: AnimationCurve) { ++ get!().set_animation_curve(curve); ++} ++ ++/// Gets the current animation curve. ++pub fn get_animation_curve() -> AnimationCurve { ++ get!(AnimationCurve::EaseOut).get_animation_curve() ++} ++ + /// Elements of the compositor whose color can be changed. + pub mod colors { + use { +diff --git a/src/animation.rs b/src/animation.rs +index ed1d1f8d..09ca0f12 100644 +--- a/src/animation.rs ++++ b/src/animation.rs +@@ -1,3 +1,5 @@ ++use crate::theme::AnimationCurve; ++ + #[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] + pub enum AnimationMode { + #[default] +@@ -17,34 +19,88 @@ pub struct PositionAnimation { + pub mode_x: AnimationMode, + pub mode_y: AnimationMode, + pub reverse: bool, ++ pub curve: AnimationCurve, ++} ++ ++fn apply_curve(t: f64, curve: AnimationCurve) -> f64 { ++ match curve { ++ AnimationCurve::Linear => t, ++ AnimationCurve::EaseIn => t * t * t, ++ AnimationCurve::EaseOut => { ++ let t = 1.0 - t; ++ 1.0 - t * t * t ++ } ++ AnimationCurve::EaseInOut => { ++ if t < 0.5 { ++ 4.0 * t * t * t ++ } else { ++ let t = 1.0 - t; ++ 1.0 - 4.0 * t * t * t ++ } ++ } ++ AnimationCurve::CubicBezier(x1, y1, x2, y2) => cubic_bezier(t, x1, y1, x2, y2), ++ } ++} ++ ++/// Evaluates a cubic bezier easing curve at time `t`. ++/// ++/// The curve is defined by two control points (x1, y1) and (x2, y2), ++/// with implicit start (0, 0) and end (1, 1). ++fn cubic_bezier(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { ++ // Find the parameter u such that bezier_x(u) = t using Newton's method. ++ let mut u = t; ++ for _ in 0..8 { ++ let x = bezier_component(u, x1, x2) - t; ++ if x.abs() < 1e-7 { ++ break; ++ } ++ let dx = bezier_derivative(u, x1, x2); ++ if dx.abs() < 1e-7 { ++ break; ++ } ++ u -= x / dx; ++ } ++ u = u.clamp(0.0, 1.0); ++ bezier_component(u, y1, y2) ++} ++ ++/// Evaluates one component of a cubic bezier at parameter u. ++/// The bezier goes from 0 to 1 with control points c1 and c2. ++fn bezier_component(u: f64, c1: f64, c2: f64) -> f64 { ++ let u2 = u * u; ++ let u3 = u2 * u; ++ let inv = 1.0 - u; ++ let inv2 = inv * inv; ++ 3.0 * inv2 * u * c1 + 3.0 * inv * u2 * c2 + u3 + } + +-fn ease_out_cubic(t: f64) -> f64 { +- let t = 1.0 - t; +- 1.0 - t * t * t ++/// Derivative of one component of a cubic bezier. ++fn bezier_derivative(u: f64, c1: f64, c2: f64) -> f64 { ++ let inv = 1.0 - u; ++ 3.0 * inv * inv * c1 + 6.0 * inv * u * (c2 - c1) + 3.0 * u * u * (1.0 - c2) + } + +-fn sequential_factors(t: f64, mode: AnimationMode) -> (f64, f64) { ++fn sequential_factors(t: f64, mode: AnimationMode, curve: AnimationCurve) -> (f64, f64) { + match mode { + AnimationMode::Concurrent => { +- let r = 1.0 - ease_out_cubic(t); ++ let r = 1.0 - apply_curve(t, curve); + (r, r) + } + AnimationMode::MoveFirst => { + if t < 0.5 { +- let r = 1.0 - ease_out_cubic(t * 2.0); ++ let r = 1.0 - apply_curve(t * 2.0, curve); + (r, 1.0) + } else { +- let r = 1.0 - ease_out_cubic((t - 0.5) * 2.0); ++ let r = 1.0 - apply_curve((t - 0.5) * 2.0, curve); + (0.0, r) + } + } + AnimationMode::ResizeFirst => { + if t < 0.5 { +- let r = 1.0 - ease_out_cubic(t * 2.0); ++ let r = 1.0 - apply_curve(t * 2.0, curve); + (1.0, r) + } else { +- let r = 1.0 - ease_out_cubic((t - 0.5) * 2.0); ++ let r = 1.0 - apply_curve((t - 0.5) * 2.0, curve); + (r, 0.0) + } + } +@@ -62,8 +118,10 @@ impl PositionAnimation { + return None; + } + let t = elapsed as f64 / self.duration_usec as f64; +- let (move_remaining_x, size_remaining_x) = sequential_factors(t, self.mode_x); +- let (move_remaining_y, size_remaining_y) = sequential_factors(t, self.mode_y); ++ let (move_remaining_x, size_remaining_x) = ++ sequential_factors(t, self.mode_x, self.curve); ++ let (move_remaining_y, size_remaining_y) = ++ sequential_factors(t, self.mode_y, self.curve); + let (move_factor_x, size_factor_x, move_factor_y, size_factor_y) = if self.reverse { + ( + 1.0 - move_remaining_x, +diff --git a/src/config/handler.rs b/src/config/handler.rs +index 5a919daa..15decfb8 100644 +--- a/src/config/handler.rs ++++ b/src/config/handler.rs +@@ -68,7 +68,8 @@ use { + keyboard::{Group, Keymap, mods::Modifiers, syms::KeySym}, + logging::LogLevel, + theme::{ +- AnimationMode, BarPosition, SpawnAnimation, colors::Colorable, sized::Resizable, ++ AnimationCurve, AnimationMode, BarPosition, SpawnAnimation, colors::Colorable, ++ sized::Resizable, + }, + timer::Timer as JayTimer, + video::{ +@@ -1504,6 +1505,16 @@ impl ConfigProxyHandler { + }); + } + ++ fn handle_set_animation_curve(&self, curve: AnimationCurve) { ++ self.state.theme.animation_curve.set(curve.into()); ++ } ++ ++ fn handle_get_animation_curve(&self) { ++ self.respond(Response::GetAnimationCurve { ++ curve: self.state.theme.animation_curve.get().into(), ++ }); ++ } ++ + fn handle_set_show_float_pin_icon(&self, show: bool) { + self.state.show_pin_icon.set(show); + for stacked in self.state.root.stacked.iter() { +@@ -3394,6 +3405,8 @@ impl ConfigProxyHandler { + .handle_set_spawn_animation(animation) + .wrn("set_spawn_animation")?, + ClientMessage::GetSpawnAnimation => self.handle_get_spawn_animation(), ++ ClientMessage::SetAnimationCurve { curve } => self.handle_set_animation_curve(curve), ++ ClientMessage::GetAnimationCurve => self.handle_get_animation_curve(), + ClientMessage::SeatFocusHistory { seat, timeline } => self + .handle_seat_focus_history(seat, timeline) + .wrn("seat_focus_history")?, +diff --git a/src/theme.rs b/src/theme.rs +index f705c4fd..51d490ac 100644 +--- a/src/theme.rs ++++ b/src/theme.rs +@@ -7,8 +7,8 @@ use { + utils::clonecell::CloneCell, + }, + jay_config::theme::{ +- AnimationMode as ConfigAnimationMode, BarPosition as ConfigBarPosition, +- SpawnAnimation as ConfigSpawnAnimation, ++ AnimationCurve as ConfigAnimationCurve, AnimationMode as ConfigAnimationMode, ++ BarPosition as ConfigBarPosition, SpawnAnimation as ConfigSpawnAnimation, + }, + linearize::Linearize, + num_traits::Float, +@@ -620,6 +620,45 @@ impl Into for SpawnAnimation { + } + } + ++#[derive(Copy, Clone, Debug, Default, PartialEq)] ++pub enum AnimationCurve { ++ Linear, ++ EaseIn, ++ #[default] ++ EaseOut, ++ EaseInOut, ++ CubicBezier(f64, f64, f64, f64), ++} ++ ++impl From for AnimationCurve { ++ fn from(value: ConfigAnimationCurve) -> Self { ++ match value { ++ ConfigAnimationCurve::Linear => Self::Linear, ++ ConfigAnimationCurve::EaseIn => Self::EaseIn, ++ ConfigAnimationCurve::EaseOut => Self::EaseOut, ++ ConfigAnimationCurve::EaseInOut => Self::EaseInOut, ++ ConfigAnimationCurve::CubicBezier(x1, y1, x2, y2) => { ++ Self::CubicBezier(x1, y1, x2, y2) ++ } ++ _ => Self::EaseOut, ++ } ++ } ++} ++ ++impl Into for AnimationCurve { ++ fn into(self) -> ConfigAnimationCurve { ++ match self { ++ AnimationCurve::Linear => ConfigAnimationCurve::Linear, ++ AnimationCurve::EaseIn => ConfigAnimationCurve::EaseIn, ++ AnimationCurve::EaseOut => ConfigAnimationCurve::EaseOut, ++ AnimationCurve::EaseInOut => ConfigAnimationCurve::EaseInOut, ++ AnimationCurve::CubicBezier(x1, y1, x2, y2) => { ++ ConfigAnimationCurve::CubicBezier(x1, y1, x2, y2) ++ } ++ } ++ } ++} ++ + pub struct Theme { + pub colors: ThemeColors, + pub sizes: ThemeSizes, +@@ -631,6 +670,7 @@ pub struct Theme { + pub bar_position: Cell, + pub animation_mode: Cell, + pub spawn_animation: Cell, ++ pub animation_curve: Cell, + } + + impl Default for Theme { +@@ -647,6 +687,7 @@ impl Default for Theme { + bar_position: Default::default(), + animation_mode: Default::default(), + spawn_animation: Default::default(), ++ animation_curve: Default::default(), + } + } + } +diff --git a/src/tree/container.rs b/src/tree/container.rs +index 2d8d328d..a1595ea6 100644 +--- a/src/tree/container.rs ++++ b/src/tree/container.rs +@@ -497,6 +497,7 @@ impl ContainerNode { + mode_x: crate::animation::AnimationMode::Concurrent, + mode_y: crate::animation::AnimationMode::Concurrent, + reverse: false, ++ curve: self.state.theme.animation_curve.get(), + })); + } + } +@@ -584,6 +585,7 @@ impl ContainerNode { + mode_x: crate::animation::AnimationMode::Concurrent, + mode_y: crate::animation::AnimationMode::Concurrent, + reverse: false, ++ curve: self.state.theme.animation_curve.get(), + })); + continue; + } +@@ -656,6 +658,7 @@ impl ContainerNode { + mode_x, + mode_y, + reverse: false, ++ curve: self.state.theme.animation_curve.get(), + })); + } + } +@@ -2308,6 +2311,7 @@ impl ContainingNode for ContainerNode { + mode_x: crate::animation::AnimationMode::Concurrent, + mode_y: crate::animation::AnimationMode::Concurrent, + reverse: true, ++ curve: self.state.theme.animation_curve.get(), + }, + border_color_is_focused: child_focused, + }); +diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs +index 2e48b2fd..f8ab558c 100644 +--- a/src/tree/toplevel.rs ++++ b/src/tree/toplevel.rs +@@ -1053,6 +1053,7 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati + mode_x: crate::animation::AnimationMode::Concurrent, + mode_y: crate::animation::AnimationMode::Concurrent, + reverse: false, ++ curve: state.theme.animation_curve.get(), + })); + state.damage(tiled_pos.union(target)); + } +diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs +index 39b9fab4..b4626e62 100644 +--- a/toml-config/src/config.rs ++++ b/toml-config/src/config.rs +@@ -32,7 +32,7 @@ use { + keyboard::{Keymap, ModifiedKeySym, mods::Modifiers, syms::KeySym}, + logging::LogLevel, + status::MessageFormat, +- theme::{AnimationMode, BarPosition, Color, SpawnAnimation}, ++ theme::{AnimationCurve, AnimationMode, BarPosition, Color, SpawnAnimation}, + video::{BlendSpace, ColorSpace, Eotf, Format, GfxApi, TearingMode, Transform, VrrMode}, + window::{ContentType, TileState, WindowType}, + workspace::WorkspaceDisplayOrder, +@@ -214,6 +214,7 @@ pub struct Theme { + pub animation_duration: Option, + pub animation_mode: Option, + pub spawn_animation: Option, ++ pub animation_curve: Option, + } + + #[derive(Debug, Clone)] +diff --git a/toml-config/src/config/parsers/theme.rs b/toml-config/src/config/parsers/theme.rs +index bcfa7cf4..c1d92d23 100644 +--- a/toml-config/src/config/parsers/theme.rs ++++ b/toml-config/src/config/parsers/theme.rs +@@ -13,7 +13,7 @@ use { + }, + }, + indexmap::IndexMap, +- jay_config::theme::{AnimationMode, BarPosition, SpawnAnimation}, ++ jay_config::theme::{AnimationCurve, AnimationMode, BarPosition, SpawnAnimation}, + thiserror::Error, + }; + +@@ -63,7 +63,7 @@ impl Parser for ThemeParser<'_> { + font, + title_font, + ), +- (bar_font, bar_position_val, bar_separator_width, gap, animation_duration, animation_mode_val, spawn_animation_val), ++ (bar_font, bar_position_val, bar_separator_width, gap, animation_duration, animation_mode_val, spawn_animation_val, animation_curve_val), + ) = ext.extract(( + ( + opt(val("attention-requested-bg-color")), +@@ -97,6 +97,7 @@ impl Parser for ThemeParser<'_> { + recover(opt(s32("animation-duration"))), + recover(opt(str("animation-mode"))), + recover(opt(str("spawn-animation"))), ++ opt(val("animation-curve")), + ), + ))?; + macro_rules! color { +@@ -156,6 +157,7 @@ impl Parser for ThemeParser<'_> { + None + } + }); ++ let animation_curve = animation_curve_val.and_then(|v| parse_animation_curve(self.0, &v)); + Ok(Theme { + attention_requested_bg_color: color!(attention_requested_bg_color), + bg_color: color!(bg_color), +@@ -184,6 +186,59 @@ impl Parser for ThemeParser<'_> { + animation_duration: animation_duration.despan(), + animation_mode, + spawn_animation, ++ animation_curve, + }) + } + } ++ ++fn parse_animation_curve(ctx: &Context<'_>, v: &Spanned<&Value>) -> Option { ++ match v.value { ++ Value::String(s) => match s.to_lowercase().as_str() { ++ "linear" => Some(AnimationCurve::Linear), ++ "ease-in" => Some(AnimationCurve::EaseIn), ++ "ease-out" => Some(AnimationCurve::EaseOut), ++ "ease-in-out" => Some(AnimationCurve::EaseInOut), ++ _ => { ++ log::warn!( ++ "Unknown animation curve '{}': {}", ++ s, ++ ctx.error3(v.span) ++ ); ++ None ++ } ++ }, ++ Value::Array(arr) => { ++ if arr.len() != 4 { ++ log::warn!( ++ "Animation curve bezier array must have exactly 4 elements: {}", ++ ctx.error3(v.span) ++ ); ++ return None; ++ } ++ let mut points = [0.0f64; 4]; ++ for (i, elem) in arr.iter().enumerate() { ++ match &elem.value { ++ Value::Float(f) => points[i] = *f, ++ Value::Integer(n) => points[i] = *n as f64, ++ _ => { ++ log::warn!( ++ "Animation curve bezier element must be a number: {}", ++ ctx.error3(elem.span) ++ ); ++ return None; ++ } ++ } ++ } ++ Some(AnimationCurve::CubicBezier( ++ points[0], points[1], points[2], points[3], ++ )) ++ } ++ _ => { ++ log::warn!( ++ "Animation curve must be a string or array of 4 numbers: {}", ++ ctx.error3(v.span) ++ ); ++ None ++ } ++ } ++} +diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs +index 177e6581..1fe98ba9 100644 +--- a/toml-config/src/lib.rs ++++ b/toml-config/src/lib.rs +@@ -44,8 +44,8 @@ use { + switch_to_vt, + tasks::{self, JoinHandle}, + theme::{ +- reset_colors, reset_font, reset_sizes, set_animation_mode, set_bar_font, +- set_bar_position, set_font, set_spawn_animation, set_title_font, ++ reset_colors, reset_font, reset_sizes, set_animation_curve, set_animation_mode, ++ set_bar_font, set_bar_position, set_font, set_spawn_animation, set_title_font, + }, + toggle_float_above_fullscreen, toggle_show_bar, toggle_show_titles, + video::{ +@@ -1626,6 +1626,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc +Date: Mon, 9 Mar 2026 01:29:25 +1100 +Subject: [PATCH 10/10] add rounded corners for window frames + +Adds a configurable corner radius that clips all window frame elements +(borders, titles, content) to a rounded rectangle using SDF-based +anti-aliased clipping in the fragment shaders. Works for both tiled +and floating windows. Designed to pair with gaps for clean visuals. + +Configurable via theme.corner-radius (integer pixels, default 0). + +Co-Authored-By: Claude Opus 4.6 +--- + jay-config/src/theme.rs | 6 ++ + src/gfx_api.rs | 13 +++++ + src/gfx_apis/vulkan/renderer.rs | 42 ++++++++++++-- + src/gfx_apis/vulkan/shaders.rs | 16 +++++- + src/gfx_apis/vulkan/shaders/fill.common.glsl | 5 +- + src/gfx_apis/vulkan/shaders/fill.frag | 12 ++++ + .../vulkan/shaders/legacy/fill.common.glsl | 2 + + src/gfx_apis/vulkan/shaders/legacy/fill.frag | 12 ++++ + .../vulkan/shaders/legacy/tex.common.glsl | 2 + + src/gfx_apis/vulkan/shaders/legacy/tex.frag | 12 ++++ + src/gfx_apis/vulkan/shaders/tex.common.glsl | 2 + + src/gfx_apis/vulkan/shaders/tex.frag | 12 ++++ + src/gfx_apis/vulkan/shaders_bin/fill.frag.spv | Bin 1024 -> 2948 bytes + src/gfx_apis/vulkan/shaders_bin/fill.vert.spv | Bin 1732 -> 1776 bytes + .../vulkan/shaders_bin/legacy_fill.frag.spv | Bin 1016 -> 2984 bytes + .../vulkan/shaders_bin/legacy_fill.vert.spv | Bin 1576 -> 1672 bytes + .../vulkan/shaders_bin/legacy_tex.frag.spv | Bin 1852 -> 3804 bytes + .../vulkan/shaders_bin/legacy_tex.vert.spv | Bin 1924 -> 2020 bytes + src/gfx_apis/vulkan/shaders_bin/tex.frag.spv | Bin 15032 -> 16920 bytes + src/gfx_apis/vulkan/shaders_bin/tex.vert.spv | Bin 2572 -> 2668 bytes + src/gfx_apis/vulkan/shaders_hash.txt | 16 +++--- + src/renderer.rs | 52 ++++++++++++++---- + src/renderer/renderer_base.rs | 21 ++++++- + src/theme.rs | 1 + + toml-config/src/config.rs | 1 + + toml-config/src/config/parsers/theme.rs | 4 +- + toml-config/src/lib.rs | 1 + + 27 files changed, 204 insertions(+), 28 deletions(-) + +diff --git a/jay-config/src/theme.rs b/jay-config/src/theme.rs +index b1db8767..77ce0989 100644 +--- a/jay-config/src/theme.rs ++++ b/jay-config/src/theme.rs +@@ -463,5 +463,11 @@ pub mod sized { + /// + /// Default: 0 + const 06 => ANIMATION_DURATION, ++ /// The corner radius of window frames in pixels. ++ /// ++ /// Set to 0 for square corners. ++ /// ++ /// Default: 0 ++ const 07 => CORNER_RADIUS, + } + } +diff --git a/src/gfx_api.rs b/src/gfx_api.rs +index f178b753..a34ceabb 100644 +--- a/src/gfx_api.rs ++++ b/src/gfx_api.rs +@@ -86,6 +86,16 @@ impl GfxApi { + } + } + ++/// A rounded rectangle clip region in framebuffer pixel coordinates. ++#[derive(Debug, Copy, Clone, PartialEq)] ++pub struct RoundClip { ++ pub x1: f32, ++ pub y1: f32, ++ pub x2: f32, ++ pub y2: f32, ++ pub corner_radius: f32, ++} ++ + pub enum GfxApiOpt { + Sync, + FillRect(FillRect), +@@ -250,6 +260,7 @@ pub struct FillRect { + pub color: Color, + pub alpha: Option, + pub cd: Rc, ++ pub clip: Option, + } + + impl FillRect { +@@ -273,6 +284,7 @@ pub struct CopyTexture { + pub opaque: bool, + pub cd: Rc, + pub alpha_mode: AlphaMode, ++ pub clip: Option, + } + + #[derive(Clone, Debug, PartialEq)] +@@ -1097,6 +1109,7 @@ pub fn renderer_base<'a>( + transform, + fb_width: width as _, + fb_height: height as _, ++ round_clip: None, + } + } + +diff --git a/src/gfx_apis/vulkan/renderer.rs b/src/gfx_apis/vulkan/renderer.rs +index 911fa6a8..1f69acb0 100644 +--- a/src/gfx_apis/vulkan/renderer.rs ++++ b/src/gfx_apis/vulkan/renderer.rs +@@ -220,6 +220,8 @@ struct VulkanTexOp { + color_management_data_address: Option, + eotf_args_address: Option, + resource_descriptor_buffer_offset: DeviceAddress, ++ clip: [f32; 4], ++ corner_radius: f32, + } + + struct VulkanFillOp { +@@ -228,6 +230,8 @@ struct VulkanFillOp { + source_type: TexSourceType, + range_address: DeviceAddress, + instances: u32, ++ clip: [f32; 4], ++ corner_radius: f32, + } + + #[derive(Copy, Clone, Debug, Linearize, Eq, PartialEq)] +@@ -763,6 +767,8 @@ impl VulkanRenderer { + } + if let Some(VulkanOp::Fill(p)) = mops.last_mut() + && p.color == f.color ++ && p.clip == f.clip ++ && p.corner_radius.to_bits() == f.corner_radius.to_bits() + && idx > 0 + { + p.instances += f.instances; +@@ -821,7 +827,11 @@ impl VulkanRenderer { + .color_transforms + .apply_to_color(&fr.cd, target_cd, fr.color); + let color = color.to_array2(tf, fr.alpha); +- let source_type = match color[3] < 1.0 { ++ let (clip, corner_radius) = match &fr.clip { ++ Some(c) => ([c.x1, c.y1, c.x2, c.y2], c.corner_radius), ++ None => ([0.0; 4], 0.0), ++ }; ++ let source_type = match color[3] < 1.0 || corner_radius > 0.0 { + false => TexSourceType::Opaque, + true => TexSourceType::HasAlpha, + }; +@@ -831,6 +841,8 @@ impl VulkanRenderer { + source_type, + range_address: 0, + instances: 0, ++ clip, ++ corner_radius, + })); + } + } +@@ -892,6 +904,10 @@ impl VulkanRenderer { + self.device.uniform_buffer_offset_mask, + &mut memory.uniform_buffer_writer, + ); ++ let (clip, corner_radius) = match &ct.clip { ++ Some(c) => ([c.x1, c.y1, c.x2, c.y2], c.corner_radius), ++ None => ([0.0; 4], 0.0), ++ }; + ops.push(VulkanOp::Tex(VulkanTexOp { + tex: tex.clone(), + index, +@@ -909,6 +925,8 @@ impl VulkanRenderer { + color_management_data_address, + eotf_args_address, + resource_descriptor_buffer_offset: 0, ++ clip, ++ corner_radius, + })); + } + } +@@ -1311,8 +1329,9 @@ impl VulkanRenderer { + let push = FillPushConstants { + color: r.color, + vertices: r.range_address, +- _padding1: 0, +- _padding2: 0, ++ corner_radius: r.corner_radius, ++ _padding: 0, ++ clip: r.clip, + }; + unsafe { + dev.cmd_push_constants( +@@ -1329,6 +1348,11 @@ impl VulkanRenderer { + let push = LegacyFillPushConstants { + pos, + color: r.color, ++ clip: r.clip, ++ corner_radius: r.corner_radius, ++ _padding1: 0, ++ _padding2: 0, ++ _padding3: 0, + }; + unsafe { + dev.cmd_push_constants( +@@ -1361,6 +1385,8 @@ impl VulkanRenderer { + let push = TexPushConstants { + vertices: c.range_address, + alpha: c.alpha, ++ corner_radius: c.corner_radius, ++ clip: c.clip, + }; + unsafe { + db.cmd_set_descriptor_buffer_offsets( +@@ -1398,6 +1424,10 @@ impl VulkanRenderer { + pos, + tex_pos, + alpha: c.alpha, ++ corner_radius: c.corner_radius, ++ _padding1: 0, ++ _padding2: 0, ++ clip: c.clip, + }; + unsafe { + dev.cmd_push_constants( +@@ -1906,7 +1936,11 @@ impl VulkanRenderer { + for opt in opts.iter().rev() { + let (opaque, fb_rect) = match opt { + GfxApiOpt::Sync => continue, +- GfxApiOpt::FillRect(f) => (f.effective_color().is_opaque(), f.rect), ++ GfxApiOpt::FillRect(f) => { ++ let opaque = f.effective_color().is_opaque() ++ && f.clip.map_or(true, |c| c.corner_radius == 0.0); ++ (opaque, f.rect) ++ } + GfxApiOpt::CopyTexture(c) => { + let opaque = 'opaque: { + if let Some(a) = c.alpha +diff --git a/src/gfx_apis/vulkan/shaders.rs b/src/gfx_apis/vulkan/shaders.rs +index d6b8bec8..facf1977 100644 +--- a/src/gfx_apis/vulkan/shaders.rs ++++ b/src/gfx_apis/vulkan/shaders.rs +@@ -26,8 +26,9 @@ pub struct VulkanShader { + pub struct FillPushConstants { + pub color: [f32; 4], + pub vertices: DeviceAddress, +- pub _padding1: u32, +- pub _padding2: u32, ++ pub corner_radius: f32, ++ pub _padding: u32, ++ pub clip: [f32; 4], + } + + unsafe impl Packed for FillPushConstants {} +@@ -37,6 +38,11 @@ unsafe impl Packed for FillPushConstants {} + pub struct LegacyFillPushConstants { + pub pos: [[f32; 2]; 4], + pub color: [f32; 4], ++ pub clip: [f32; 4], ++ pub corner_radius: f32, ++ pub _padding1: u32, ++ pub _padding2: u32, ++ pub _padding3: u32, + } + + unsafe impl Packed for LegacyFillPushConstants {} +@@ -55,6 +61,8 @@ unsafe impl Packed for TexVertex {} + pub struct TexPushConstants { + pub vertices: DeviceAddress, + pub alpha: f32, ++ pub corner_radius: f32, ++ pub clip: [f32; 4], + } + + unsafe impl Packed for TexPushConstants {} +@@ -95,6 +103,10 @@ pub struct LegacyTexPushConstants { + pub pos: [[f32; 2]; 4], + pub tex_pos: [[f32; 2]; 4], + pub alpha: f32, ++ pub corner_radius: f32, ++ pub _padding1: u32, ++ pub _padding2: u32, ++ pub clip: [f32; 4], + } + + unsafe impl Packed for LegacyTexPushConstants {} +diff --git a/src/gfx_apis/vulkan/shaders/fill.common.glsl b/src/gfx_apis/vulkan/shaders/fill.common.glsl +index 0a5d0d7d..8f49055c 100644 +--- a/src/gfx_apis/vulkan/shaders/fill.common.glsl ++++ b/src/gfx_apis/vulkan/shaders/fill.common.glsl +@@ -7,6 +7,7 @@ layout(buffer_reference, buffer_reference_align = 8, std430) readonly buffer Ver + layout(push_constant, std430) uniform Data { + vec4 color; + Vertices vertices; +- uint padding1; +- uint padding2; ++ float corner_radius; ++ uint _pad; ++ vec4 clip; + } data; +diff --git a/src/gfx_apis/vulkan/shaders/fill.frag b/src/gfx_apis/vulkan/shaders/fill.frag +index d4718db3..ce1af678 100644 +--- a/src/gfx_apis/vulkan/shaders/fill.frag ++++ b/src/gfx_apis/vulkan/shaders/fill.frag +@@ -4,6 +4,18 @@ + + layout(location = 0) out vec4 out_color; + ++float rounded_box_sdf(vec2 p, vec2 center, vec2 half_size, float radius) { ++ vec2 d = abs(p - center) - half_size + radius; ++ return length(max(d, 0.0)) - radius; ++} ++ + void main() { + out_color = data.color; ++ if (data.corner_radius > 0.0) { ++ vec2 center = (data.clip.xy + data.clip.zw) * 0.5; ++ vec2 half_size = (data.clip.zw - data.clip.xy) * 0.5; ++ float dist = rounded_box_sdf(gl_FragCoord.xy, center, half_size, data.corner_radius); ++ if (dist > 0.5) discard; ++ out_color *= 1.0 - smoothstep(-0.5, 0.5, dist); ++ } + } +diff --git a/src/gfx_apis/vulkan/shaders/legacy/fill.common.glsl b/src/gfx_apis/vulkan/shaders/legacy/fill.common.glsl +index d49c75a8..9576f365 100644 +--- a/src/gfx_apis/vulkan/shaders/legacy/fill.common.glsl ++++ b/src/gfx_apis/vulkan/shaders/legacy/fill.common.glsl +@@ -1,4 +1,6 @@ + layout(push_constant, std430) uniform Data { + layout(offset = 0) vec2 pos[4]; + layout(offset = 32) vec4 color; ++ layout(offset = 48) vec4 clip; ++ layout(offset = 64) float corner_radius; + } data; +diff --git a/src/gfx_apis/vulkan/shaders/legacy/fill.frag b/src/gfx_apis/vulkan/shaders/legacy/fill.frag +index d8a3eaf3..1df44ac3 100644 +--- a/src/gfx_apis/vulkan/shaders/legacy/fill.frag ++++ b/src/gfx_apis/vulkan/shaders/legacy/fill.frag +@@ -5,6 +5,18 @@ + + layout(location = 0) out vec4 out_color; + ++float rounded_box_sdf(vec2 p, vec2 center, vec2 half_size, float radius) { ++ vec2 d = abs(p - center) - half_size + radius; ++ return length(max(d, 0.0)) - radius; ++} ++ + void main() { + out_color = data.color; ++ if (data.corner_radius > 0.0) { ++ vec2 center = (data.clip.xy + data.clip.zw) * 0.5; ++ vec2 half_size = (data.clip.zw - data.clip.xy) * 0.5; ++ float dist = rounded_box_sdf(gl_FragCoord.xy, center, half_size, data.corner_radius); ++ if (dist > 0.5) discard; ++ out_color *= 1.0 - smoothstep(-0.5, 0.5, dist); ++ } + } +diff --git a/src/gfx_apis/vulkan/shaders/legacy/tex.common.glsl b/src/gfx_apis/vulkan/shaders/legacy/tex.common.glsl +index 61f3ef3c..a486e45d 100644 +--- a/src/gfx_apis/vulkan/shaders/legacy/tex.common.glsl ++++ b/src/gfx_apis/vulkan/shaders/legacy/tex.common.glsl +@@ -2,4 +2,6 @@ layout(push_constant, std430) uniform Data { + layout(offset = 0) vec2 pos[4]; + layout(offset = 32) vec2 tex_pos[4]; + layout(offset = 64) float mul; ++ layout(offset = 68) float corner_radius; ++ layout(offset = 80) vec4 clip; + } data; +diff --git a/src/gfx_apis/vulkan/shaders/legacy/tex.frag b/src/gfx_apis/vulkan/shaders/legacy/tex.frag +index 2896bc87..73783dd7 100644 +--- a/src/gfx_apis/vulkan/shaders/legacy/tex.frag ++++ b/src/gfx_apis/vulkan/shaders/legacy/tex.frag +@@ -7,6 +7,11 @@ layout(set = 0, binding = 0) uniform sampler2D tex; + layout(location = 0) in vec2 tex_pos; + layout(location = 0) out vec4 out_color; + ++float rounded_box_sdf(vec2 p, vec2 center, vec2 half_size, float radius) { ++ vec2 d = abs(p - center) - half_size + radius; ++ return length(max(d, 0.0)) - radius; ++} ++ + void main() { + vec4 c = textureLod(tex, tex_pos, 0); + if (has_alpha_multiplier) { +@@ -17,4 +22,11 @@ void main() { + } + } + out_color = c; ++ if (data.corner_radius > 0.0) { ++ vec2 center = (data.clip.xy + data.clip.zw) * 0.5; ++ vec2 half_size = (data.clip.zw - data.clip.xy) * 0.5; ++ float dist = rounded_box_sdf(gl_FragCoord.xy, center, half_size, data.corner_radius); ++ if (dist > 0.5) discard; ++ out_color *= 1.0 - smoothstep(-0.5, 0.5, dist); ++ } + } +diff --git a/src/gfx_apis/vulkan/shaders/tex.common.glsl b/src/gfx_apis/vulkan/shaders/tex.common.glsl +index 8576231b..5dea05e2 100644 +--- a/src/gfx_apis/vulkan/shaders/tex.common.glsl ++++ b/src/gfx_apis/vulkan/shaders/tex.common.glsl +@@ -12,4 +12,6 @@ layout(buffer_reference, buffer_reference_align = 8, std430) readonly buffer Ver + layout(push_constant, std430) uniform Data { + Vertices vertices; + float mul; ++ float corner_radius; ++ vec4 clip; + } data; +diff --git a/src/gfx_apis/vulkan/shaders/tex.frag b/src/gfx_apis/vulkan/shaders/tex.frag +index e1ccacda..1bf7dff0 100644 +--- a/src/gfx_apis/vulkan/shaders/tex.frag ++++ b/src/gfx_apis/vulkan/shaders/tex.frag +@@ -14,6 +14,11 @@ layout(set = 0, binding = 0) uniform sampler sam; + layout(location = 0) in vec2 tex_pos; + layout(location = 0) out vec4 out_color; + ++float rounded_box_sdf(vec2 p, vec2 center, vec2 half_size, float radius) { ++ vec2 d = abs(p - center) - half_size + radius; ++ return length(max(d, 0.0)) - radius; ++} ++ + void main() { + vec4 c = textureLod(sampler2D(tex, sam), tex_pos, 0); + if (eotf != inv_eotf || has_matrix || alpha_mode != AM_PREMULTIPLIED_ELECTRICAL) { +@@ -42,4 +47,11 @@ void main() { + } + } + out_color = c; ++ if (data.corner_radius > 0.0) { ++ vec2 center = (data.clip.xy + data.clip.zw) * 0.5; ++ vec2 half_size = (data.clip.zw - data.clip.xy) * 0.5; ++ float dist = rounded_box_sdf(gl_FragCoord.xy, center, half_size, data.corner_radius); ++ if (dist > 0.5) discard; ++ out_color *= 1.0 - smoothstep(-0.5, 0.5, dist); ++ } + } +diff --git a/src/gfx_apis/vulkan/shaders_bin/fill.frag.spv b/src/gfx_apis/vulkan/shaders_bin/fill.frag.spv +index 5a16604393faa7b48aecf05273be7b0d32671fc2..2ccb26498f4a1857a0d345871c45210228246623 100644 +GIT binary patch +literal 2948 +zcmZQ(Qf6mhU}WHC;AO~WfB-=TCI&_Z1_luQScHLri-D6NI3O(E+aoBxAfvK4GdVFQ +zzPKd6C^0=XKB+V4vQ{t2IE8>e&(lpA_jI3ce&Cr^Gm4TUon}LC$ +zfPsMlWCjld14D9ZUP)>Zh|kKv%fP^pk(iSfUz}N$3RS}gGB+_Lv$PnbhM7T-fq@|f +zEXT?K3XA;GlKABOoctoN7z=|Y0|SFgVo4$>tk@V>!F&)KriP7y4J-zVud>vllFa1P +zVvsx=13Oq86pqRHMR}=3@kLN;8DQoxGcYj37bJqLhl_#YCpjk*PApF*rJ7l2bORM1nJ2vE&-Xt%D@Ir1L-;OZbgad&iVO8 +zU^jux3IT<0Vo_o)G(5u5_>pM*Xe7Qn0}D7z>P*{S* +z1Q-|?U}B(f1c^x?iGjioBqoC-1`0orm;%^776xqw1_m|;1_ll&UkA#E$TNe(5@faq +z10q~uc7Vbj2VF0tm7?{EKfy@_YU||5|Cy@Oh +zI}{jL!0rHvfiQ^A$-n?tqsYL{zy)<5$h|NOQlrhl0yY=qSCCskW~)Heg8U3JOBI}V +z85ls~)(k8RAU-HfgThIjftf*_fq~(v2m^yC0}F!&0|NudjUcn+plKDP52OaHA7n3xZwPWA*sUxKMo|4QcY)+h86e>fGT#g;4|2Z<0~3QK +z0|Nuh96PA{L41(B8^m0DkiH-WNcjjd7vwGw9~93pcYxG`GXz6}Jxo8SJYis9*l)(b +z3XXSB9Ds}jh4l{xCI(gp1_qE>pg08OM^Jdd_@Mj<K&$;4m^~U|;}+D=6%28CbyS!GwW<0VHn6zzoilAU;SvD9p^E>Oo-% +zQV;U41p@;ENPHIqD>xjj7#J8pVxaW*oq?Ib2IN@=28Le@EDRtyYX%0e7|8D+HppyH +zeAz?I28BP!Y)}|GFfcHH#QhoA!D-)-fq?-e2Erg?LE#S5?}SCaGXnzyNF1i$g@J(q +zBnHBud3?TDCYWkpJ?hFhJAaPI{0EGuAen4U{J3X=3>BYdn01}7U +z>CM2v01^XXn4K{FK3MeoGB7ZJ#9{jV7#J8pVjv9jb2HR^{tOHZAoD?LU~UdzU|;}= +z!`uu?Lm)9&n1({@ahUmG3=9k)F%X8C5ArK0ZGq%K`ao$Wf`NenFo4v7%mAg^Oa=yU-T}o4 +zh@Zv4zyJ~hsRj81RMvvTeljq@^8zTJ{AFNduwY3layZ0r>?~SAyIX08P7+3=9k) +zF%X93K`90X29REm94MSXZU%{g%mBGb8r;@pV1St+%fP?@DrZ2L2UcmV)7EALVO + +literal 1024 +zcmZQ(Qf6mhU}WHC;AN0xfB-=TCI&_Z1_luQScHLri-D6NI3O(E+aoBxAfvK4GdVFQ +zzPKd6C^0=XKB+V&!0mKKfVQSbI*uY|- +za4SnKD#=VvEe6TM#6f;4NK8q|%u6>!7H4K)V1SAnfy6=f3o9#xKyn}qvV#}uCy=|fq5AlsX;p>+;%AT=keDn3GXu!a +zpm305fcO{02e}PoF34_>KS6vU1_p54fYJ-dtsp)q?m+GZxlaV@K2ThM>;>^fL5>BB +zvoMH3^@H4O#=r^=A5b`elz{yEgMo>Gm4Si5k%1MgR-A!>0c1YN3|nZ}fx;dnZpXk3 +Qb_0~-S?SdN*2fg!#iF@*t1 +zj0L1VC$nI417ifEpa)bXNK~1Dfk9#ORVG_Tafs1e3=9n1V66-cJPZsBT$@!{<}(2R +DXa*7O + +delta 84 +zcmeysdxUpG7^4;&11|#u0}}%SLqTFnN@iZVAp-*g0~-TGoEaf*w0R0+1mh-VD@I|6 +V92Wxv12+Q$0}lvq4rH0n1OSjt4bK1o + +diff --git a/src/gfx_apis/vulkan/shaders_bin/legacy_fill.frag.spv b/src/gfx_apis/vulkan/shaders_bin/legacy_fill.frag.spv +index b1377f0a9e52111fdeb7bb355adeacd7c54a8d4c..eba9df7f7a95be375e79aeb45da80b35129470a7 100644 +GIT binary patch +literal 2984 +zcmZQ(Qf6mhU}WHC;AO~TfB-=TCI&_Z1_o{hHZbk(6YQf`T#}+^Vrl?V!N4vQ{t2IE8>e&(lpA_jI3ce&Cr^Gm4TUon}LC$fPsMlWCjld14D9ZUP)>Z +zh|kKv%fP^pk(iSfUz}N$3RS}gGB+_Lv$PnbhM7T-fq@|fEXT?K@^5}=NqlmCPJR(s +zjD^Kyd^Ta{!yo0uEPD8Uv+AkXu0E0Wu3@Jcthp2M`~GL41(EL2QuO +zAU??7AU?=k5Fg}c5FeD+KzxvYL3~yQ1_ogUCUD+T1m`jaFk6g)8SGb(yTut;7(jUt +ziIk1#MWfXr5bss*_X +zWR@y8zcVm^#H|@v7(jeb9s&79l!1jooq>S?aP`uePFff3`{TbN7`NEEYfdM22!XRToaS79Jk3~PI8~};K +z^gA*zFo48B7?cM=anQ)X3O3t`fq?;JK1fX;RLq5efdM2AN>`xp0L2eT3}&Yr7CYS; +z7#Kj}FgrcKWf7?CgQQiEoiP2LSoC`_Fff3`Vfwup7#KieAPn{5`%?l5VTzYGe4MtfdM22!Z7neeg&m1kQ_)KD9wa2Fff4p50V3= +zGY}sp7tX-I0I~-p2TF4wKFE)tG!qH+BXXLFVqjnZiG$RG(j*ht%w(vUJW%(5Dq~PO1LXry`2aE-qy`iRAUA;8CZKWzqz+^TDBXf8 +zPEc73iW3k&oq>S?BnDCo@&`x`B=(bm37!`~`Q$GHBZCD4BLk?c0GSP{w?JM1)dS`X +z%;5S0RG-1vpf(eX4Qele*r0q4YA1pGCBVSI04qxbp>+$$jiC4x0=F$e`3X`l2{SM- +zfXoB=3nVYXz`y`f0}%(cf*2S?p=Bzl3D9 +Id{Ddq0I0O>ng9R* + +literal 1016 +zcmZQ(Qf6mhU}WHC;AN0yfB-=TCI&_Z1_o{hHZbk(6YQf`T#}+^Vrl?V!NkiFG&O0 +z2Xdbj0|P^5URgY{m^9Rk+{BWi%nFd3Ss7#)7#P6z#OLOxq%ttLGq8aDCI>Qz0mA2H +zU|?WlU|`??^O?c^0EMv!)K4I#${>P)fdM84@+U}40j!4w>@ScQNEyfukbgjY5C-u< +z?gz0!=7RVj_k;ML*az`JZU^x}@eksI+zsNhGB7X*GcbYUToD}m3}Chx12fpoAp6A` +zSQtQQ1LOygegy^=uwOuCgT$?&d{CT%#6aq~pne9qN0fnuft!JW0b~}){n}7I4>T>x +zFt9Ly!U&`XBqqne0uCdP7|48(ef$gz;BW<{0g!zlJ}B-%c7n_ogqjcX6UclJUx)#e +zTR`@*FbG5SgUl0QU}6wsU|=w0U-5~t2kpTdt5>+Vx + +diff --git a/src/gfx_apis/vulkan/shaders_bin/legacy_fill.vert.spv b/src/gfx_apis/vulkan/shaders_bin/legacy_fill.vert.spv +index 48685e279bd423fe200d9b250c02eb14125b56de..61bab2ae42c6cb89572d48f2dbeb8a2f0426a708 100644 +GIT binary patch +delta 125 +zcmZ3%)4{tzf>D=^ft5j=fq{XEfq@}8C$oTofq{XIfgLQy3=+#P%1bSZFG@_wEG?ca +w%J^N?14)N60|SEr0|Nt045R}j=CGNQNsv*P4Qz}k0|SE?6!ULZWoBju0Civw8~^|S + +delta 33 +ocmeC+UBR0od5s; + +diff --git a/src/gfx_apis/vulkan/shaders_bin/legacy_tex.frag.spv b/src/gfx_apis/vulkan/shaders_bin/legacy_tex.frag.spv +index 9664f0444b53972c0abb7953a50d28a075bb07be..14748385b91b4601b9f4a5d96deae2b31b0bc28f 100644 +GIT binary patch +literal 3804 +zcmZQ(Qf6mhU}WHC;AQAzfB-=TCI&_Z1_o{hHZbk(6YQf`T#}+^Vrl?V!N*wB3=CkLo0ypglF(&fUU=Uzn2FtNCFfcGPFf%YQFfbfq1gYm@aQBIK +z_xE@Aag9$dD2OjEsmw`@&&kY7jZeueN=+`wEK3Ec<3LiEnU|bXnu4r=m4O9pKFn@b +z1}+AEklp#Ec`2zW@k#j=@x>`=8f9rl)-arAXwAUNz|6qSz`#(zz`y`9gNK2EAvra# +zB((^{XJz1JU|`5d%t?zc&a6s>s^J5fo0yVWS`1Rd%pl0Xz>or#V`fkT>4&nl7#J8z +zQY*mbg2JLCwIaSCznFoQfrG&iY;JLUVopIuVtj6CPDy4#PA14!keO@@CJYP=#YM^S +z8HrFeU^7`5tQi;>ToOwXLE+2B0O5n!AamImSi$n3ID^>*Qv(X`+|nEdHU@UE8c@t7 +z=NILr7R47qy}|&~4~oa+oJ>%VfZc8j@*mVrRt8pZoaUF7#3$$HK}14CwBSv;~>57>Rh@wtg5MVS?#FlS}xWnf@{#z%fiDg%Q% +z0}BHv4i!NLF(CL#NPJMd$|3PV@d^rm5C-W7#UqFfQUc;zGB7Z(F)%Q2K+Us4;WLBd +z1{7Bw(6|KYQw9+X3=A+aka-|61tc+$9*~#=k{BqCL1HdQVxYJMi3LFI2jx#>_k-dD +zlpjEO2jpH*Sc2>WnGfQF!V<&>nGNED!V$y=nG52B!Vtt~Wnf?sW?%y6b475DX8^Os +z7?{ES2f1CGfrSB79)Q9FWWE9e3piXrVjv9Sb22c%)hIHsGjJh=7bpxt7^FsLer5TtRG#D7b>OlH6!LkfsF>3}E +z1`r>VK0v}Cc^w8&2?}v9NFKxor4NvKA`DCnphDA-ff-6OFu>TLG$P8t!eGw8zyJyt +zka-~gfcO^BGF66wg#nayK;Z%s1Mxv=2ga9!=4+4|kT@tzK^Wv$klSn+7#Kj|1S&s3 +zZUgZ_`5Gh!vfB=7H^{9ZyFq+=21uNN^n=nBNG-@rP}sUb!w!@VK=MK0a-D$zWFN?U +z5FeDbK=Ls2LZND4{1~X;K>A~$?gQxu$%E_xmA4EG4E7-TLTK265R4B>%OE~19zbasCI?E#AUQJzR&crnr6CX>0GCrBK1f`Tfq?;(?jji2!SSKbz`y`f0}==2Gf*C}V}R6EAi3`h +z%nZgL&oMAC{9<4M*G)zY3}7*kUqNh;8Z#s{pfqdBz`)?jz`y`f17bTeurh$si6a98 +z11N2Q(htZ@ptx~jU|@iVvw+>?%)kJyEBhE&!F*5|1rh_PgSibP_LG5$0c0=84v<|S +z^FV399jf1-fr9~*7Cpe_8K{hgI1-dDKz@a}$rFp4ycif5K;j@bfXwg)my-+(AaR&J +zA1wNO85kHq;(-jHT8x3gkAZ;!q!(l}Ow1oD266{X3{>WV)PdX;0F9eK28e!;dXPNG +zY?%K+a>)J%g#(BWiYriffM{5Fgh0asq!*O$Kzx`V!k~TtNDfrSfcPLkg7R=R)Q`w% +zriOum0VEDm4=RH|d`kunaJ^g0zyNM9vl2Ou}JGcYiK>I;xMkQtzK+X3ybfZ_zi?_^+L0EvOrg8Tu>S0FJ^9)#ru +z5dD{dk->t2kpWa!fXoK9oj_gywN=a+n89rtP@4+I2K6yuY*7CK#0Hh?pne6&j{*z~ +z46wRH5ZYz|xe*kfLg4-wsCB4B-9^Os_ZtL@h`hqz2>{2?hoRkh=n)X;+egfdM22!mvCj#lXM-(hHIUg%ill +zATf{`AU8=fFo63=Ff(Ks7#Kk13<&c;%Lq^#6jUyN+FKy?AT>M;Y~Zvd4|PANZUCtR +gnG13osI3HY8_4}2KB%1zQVSCQ$H2q@;)CJ^0PgfTga7~l + +literal 1852 +zcmZQ(Qf6mhU}WHC;AOC5fB-=TCI&_Z1_o{hHZbk(6YQf`T#}+^Vrl?V!NaQBIK_xE@A +zag9$dD2OjEsmw`@&&kY7jZeueN=+`wEK3Ec<3LiEnU|bXnu4r=m4O9pKFnTL24)6M +z1_p*?1_lNYo0oxsp(M3}0i+J(rjpc(_=5am237_R1~CQ(hK$7G_{5xojKuid(wvga +zf}G6MB9Qx88Q2&k85kIfi<0BvYQSc)FeorEFt{X^B!bkjF+lhrHppBy23D{*mj1_p-A +zys~&?F&n5KauZ96GAlrSW@WHtU|@jyDL*BZfx(@Dg#i=}iXej+5PXmuKtdpSP}s|*ns>1!XSB2Sb*3dB_KXXEgJ&^0|!*SJOcv*g3k;N1CW0`py2`1rwk$(7#Lt; +zAoDJ48jad;Pj^mPKgX)wip95*xevEh%>M-fYK?*A0Yh-3@l*(fy6*ql7W+f3+hjh +znIJd8FpMwFz|6qSzyMYU($51`01~riU||69L2&^R2HD3CH4o$;ka__IP)=lE0EvUt +zgZQAh0hzDOzyfxwFi0~41A_0P{H-h+}v;-0Z*$Ik2 +zkQ~g-AifF%B)mcTRT&t-c@$(GC=9frVE~Flki0p#tY84g1;~65ACwM2@rGVcci69Xu1fb0RKWso00ac#@M0**&M +z1_p560r5fMBE-PJ0E&+Y26nLfL>L$tKx#nZpg07@jUBWM0?B=6U}lg2S;oM?@QZ;3 +zT;_-~Fo4BCc7xa;HPT3GKyfFy +z3=AM~5Fg}r4QTlGF|dN&sL8;<01^YKgSibP_LG5$0c0=84v<|i{W?(n{tO%ppt#Xx +zU|;}=fiTFKpm+uO73L;AEN;?gU|;}=gWLcz15{~%%mInR^ciB&X9P~q3=Dw`pc0sY +z!I*)80i+jXFHFn?Dh6@~Ow1H2269&bG;YioAo@Y-LGmE8Vg3inA^RT`4j?`#u0Y`d +aqG91-2@MaBUQjv&@&7U~GFUJ$G5`R7vV+qA + +diff --git a/src/gfx_apis/vulkan/shaders_bin/legacy_tex.vert.spv b/src/gfx_apis/vulkan/shaders_bin/legacy_tex.vert.spv +index edf86be6cb7970c8b0cdb14e9c7b1849d474572b..f313f703acc159f8821e91b42d4cf3f77b928d79 100644 +GIT binary patch +delta 897 +zcmZqSf5JZ@f>B{&GsI?&F${>P)fdMQw*^o)t7N!a$;{cOkU|;|_03_yu +zBnGk=Bo@HHz~Iin!l1A@l1ZGgUYvo2L7ahsfuDha0i;9@>IYE<1_m*xI7kkpMuCBa +zL6U)ifs=s&>`!YbALIv+m^K3o0~-SagEZ8kAoVg(^#TkG3?RNd)OjGjH3N$k14uvt +zDhM+`k%55$6wolf76StVC^TSv1CaX3iY(fU;*)(@q8Y^}uViUX1X(4^zyJ9+1U) +zlWW<;!GUN9)dBJzD5zn!7%?y~faE|rK(>JRFk6hFdO%)7)?>oJzyOj1=>gedGWjc; +RKco0$S9Wzq>B+h5;s7SeKs*2d + +delta 800 +zcmaFD-@-p3f>C~A{FP%)T(1qKEq29UpD0-6jA +z3?SdY`1(){;tVVdW>CkmOm1Y+W;C0;mL;0eY%(Wnb0WxU83qP$2!Wgg!nO=7V5?;r +z7#Kiub_~o6LJSNHAig658v{s>B2APAUTj8kdu`rzhxD#2kB6O>HygQ +z(go9@%D}(?k^|`g*#hFjY*B;i0jURJm>zWo1_qEENDs&s5dS9w69XRu1H%snCI%h` +z1_qcJS_}+PaPNTv31)~k0|SE=0|Nud5RkkgSnfPft`Vyfq@}4za%X_ +zsl?F2!c3zq&Da{`Csqb#1}+8$hGYf?237_R20jJ`hRnROcwB0E;cD3#1Yu?tmlzpX +zn8X(pLW~8ODF9c;!61TcCKk2AaJ6g<;z(wi7$D3OgR5a@kU}#LS)C-3I$4-mIr-^^ +z22jJm{$ys5fve+SP(U^hi&}ZOT6P9yBr}Z-QNvLQu8xC24b4n+wW@HnYz&$(GmA@1 +zj4Tj-)_|*FXV5`54^5pmTpbsKKFqA-{M>^4(!7-Tg8cH-qIjct6Ntf3|Lei^a5ESo +zn@vEUAzU9DgDC?8Lt;TeP9-?CK#T{)y$M_mJA(yW4J=h5)S1K8aWObCFfbH>+?$#b +zpOjw_U!0PrQI=+84Z~@M)(jxC?HCvs3c!9~VXz13Pt7YyEdr%oRt5(K28N8poV57j +z%&JtV8b^@1i7A<-#UM4z3|=7j7=qZW4BiY346gYlX^utd#h|>v#sComu@j5ZL0)Bm +ziGj+P#G-UiP{GAOc|Nfy-58`Erj8k8eo;Co2pCuy*cg04Zp;M-P<$fP9*{qRLF$Y^ +z?qFsJ2eFMI>}U|%1i}V6DiP||5(Wka&%83Yb3yij#6Wfpc?pBZ(x?4eFsBUFv +zD1*2a7EvhftpvFX>fRa<8|vPA5F6^=X(0Ut5Vy}}U|`5A%>~EVA_fMAl+-*hdmEDY +zUbr|5Llz^Xd;<9gRDM-6GB6Y*7A59F3vR!2V@sP-B9X$;=FzObiSqsTJVx +z(`8~{C{6^0D+>dtJ}ODAh%d-5W?*GtWiVp`rT^mi+{BWi%nDFEurgRMF)$?N6l5gE +z=jNxRg2b2^?3frBiqez7>C+KpUQu#Sr|H)7#P47FfcH%F|dL8AUn!Zi%K$+Q$bmSfsFy84wT+=OLG|57}&w;KxsWW +zzbG%YD82}q^}+50nF&hs$vK&zB7_0tt{x@^hOpEkQ1F7y1MxxjfWj1%$3T3L`N-ki +z2i1dc2O9%O45Sa)eIWBEfZ`Ms2;gvK1-BpaOH1ODL176NV_{eY3M-^?Y%4fyP|LA> +zAagQ{LD?1@Z=g0vdQQAsQDVAtetr=s*?|3X5G9;h7>=OvkD>8TAo1N9n89TtDEvH- +z%0f_`2l5+C3=|$9F;M#mCI-?2660WCV1S8%!WtyT!@$7c&cFgL3l$j{z-=WMA5>?8 +z%mdjC@*KzOpxBq#xuKkUWTwtRIvwk>x>rkUUI1D6h(aIMA>FNL-PDg@Ka+!Uy>iq}~c#oHw!}mR6odEn0yEWq~8SNM?lrU_%Yyq62hHn +z3=9mjW=cBffJMk%aFvvzdv4xp;k%55$BnHDUb(a|!7!C)f!Nc_m0|Ns{42EIq +zt}!q$Jh9+|hr@LS1_qEA48zpjVqjpX|Gf)t?`;MK29OvGi!d-T++ko~0OiHI3=Cj4 +zDDT4bKZ5uVZqH)|1_qEA48zntV_;xdV5rR%h3@bG%Yz`y_! +zgJGDu_n23iVPs(7x~>d&H!mXt14s;pVd{iH +zg$aWK+&p1M1_qEA48zo^FfuTh+xc6=+^5ROzyK11VVF8SMh1qoG&Q(+`iu+=ATbz* +zsWW6`U~pXC4UZ=yMg|6u7!1SIS%Tf?08jr`j0_AQF&Kuavt?vpcvuw(S7*n_zyK11 +zVVF7xMg|7|H%H*{?#Rf%01|^?m^wE`28M~v^Wf>#osoe7BnHDUbzY1N4C~wz;p)5@ +z85lrfFbq@Y%gDfxx4a1MUq40$29OvG!_);aGB7+_rwTVOn2~`2BnHDUbrB%(9e(ii +z7RkuK01|^?n7SBHIB>PY?Tuw*U;v51Fic%CBLjnso-aIpQy3W-Kw>Zqs(&&WLG=tI +z0UI%}Fo5`={vk*NW==aJ1H;)fJn;DMU}RtbiNP>T-E>9<24iDmxVjmP3=AMK7>1b- +zYVRSNf18nkL4Vf^c>LXAWMBY^!7$AH*P!sey$!DJ4I={sNDPKS<%|lnjRI<~f$A?+ +zCPEenLuI2!k`0H +z2jYX&f%u@dC`g?&12cm@6T}RVnTAY|IvdpX0I3D>LG4tKy(SE7;Ih$_iGg7?0|U5! +z4hkbf24=80sQn64W6s3D0II)0YC+<#Fa@<~LH2^e3KWNyP`APKSwYPPsRPNu#I2#` +zfy@P|f!PUa6T{RvF)=W#VqgIGZ;|ynGchnMXJB9esRgNl+3m&z8mEP{KR|8?hWZ_5 +zUI>zTAUT+LD3W;~H4+Rg49QFk44}9J#itwtGq^nt5(9;4Difq_2Wpdp!W6^@wYx!H +z2g#>HUkz0ck_Y(% +zq;E4QkQf;3LGt^d@dJ_v=?C#aZF5lCgsHy>QNQ1effd|N2gL^{y@Blb!N9}->Pvz2 +zg7O<^JO$Lw2Jt~{anKkDG9NVFfy@VuSHSq7b~mVxj?4%3(P4ZW1_lOD{~X2#sR#AL +zk@=v0I5Hp94~OwV`a%705Z{)81)RsVm>9t0J|I3QFY7TeFo5!L7y~Of4;wHsFo4v6 +z#6fC6acs=Q03H*HU|6!4@RJz`*c}frSB- +z?`)VDz+#~A1G5=ez-HMqF@VRU{24gF{Tl}+1_qEA2!p~L6fYp7K<0zO79dcKw=>CL2O3`RtAv&-J#}#@+rtap!DFu#J~U&hpF+zqQ;AffdM4Gg@F}Z +zE_pLCFo48Beq9M3IbvY&VPaqaiG$j_ATvScgTmUEi2*zo769#o_%T7$g4Ba>2Lov4 +zgn_}IiGcy6793v9YC-Za|AE9nVFmLGhz9vL3CX{p +zdLxmEfdM22@-K)D@(ZZE%wS>wk0*lqpdj~v%FayC7#S$7Li^xZQ1=CbN5>c#vY8kd +zKw_Y@2@}hKihn2bl-+KS&3aTGwXEoGLP`wNb=Ncvk29P>X_<+m=sR4<>^wdK2fa+eDo;s)= +zP23xS0|Q75ghAm7 +z3MY_rVQ!j<#Z9xA7#Kj}AUA;0-)trZ29P-*ahSe2SoF}T1yC`NyI}ciArk`wNIytDNFHQ1%>N)cWdDQ00mO&J3y6k=#}a6Gfb@de +z79c*%4@;qb0F6U|)Pv$<8508oNPHKxu2>FgXM*Y@aNn4jVI|0s(DVS3TfxKt76Zit +zhz&9u)NWY=H5)Xh1u`2Hmus0A7(n9w4D1Y`x_BKE0|Q75gh62h3KN+A^;q<8U}9hZ +ziNo}7WMW_diGeW4S)eveBLgeg>`hDz3?TDCYC!P_vTq9$0|Q7L)P@3u2dKRQ5`)>f +z4U3)InHU&A;xIdRFflNI#6TEkCrtlNEc$mbF))C{VfuG7F))C{Kp5udW~iI@FflNI +z%m=A~xp^-W0|Q7L=4Mcv3M2*#(?ig;7BKS@IG +z*V9Z4;CXA1{h;<8$UKl5kXlfC5F`(>7nJtSFhSagu(WrUiGcwm2J$_jU;v4M(jJKYhk+4X=77XtW?q7t$pdxIWoVljG;s}T8-vUSsR6|S$PHJRAY*?Z +zbs#f9>GmpgO$#VaK>TY=3=AMKkXn#GK=}$J2Fin=`~h+gi2lpK$Y8<1$N*}4gUS)m +zm^mm;K>Y!824?WMK4@JDNIhsC0MzaV)kmQDcaVA*A2bh+%m>YdBlAJ?;UGT9y&&3! +z0kp=4fdSSIaR<%eBVm}h2iiC%D2zb*J>lcj3=AN%K>9&yL4F0f7bFi#51=vbKn4cz +zoE5Cy0Sdz)1_lO@I7mInFCacleJE5tavcx`8i!_JU;wEH`4z;6sgH!JM^0~13=9k) +zagcgYSb+F2^|4U(uyP0Fo;U^u29P*NJt(X|d{9_{H;;f%JmbTY&U~wx4zWBf34GZ`2dK<lhdqK;j_vps)b(Vd@*8 +z>XFl8BLf2iNF1ad6jmTUD6Bx~sEL7r0pvy)znOu70ptb{9~71?(6EHXaVrA@14s;{ +z4x|QTMjHbI1IQh)aBXK`U;v51Ff1*0FfcHH^n>JKX|Wp`)}XWqk_XKP!OZOe%^5Ht +z%9LIP1_qEA48zi59|Hpe$Q+P7%$@zl}AhSUFL25y92XY@s9%k2cs9mtIoCVbj3QLe2XwD3z599}s98BLF1_lO@7%cqe +zGcYiK#6WXrF!wD$b2rS6g-|;{Yv4foKj>$ng(a`v^(`$n_#RwQMgUkZy2dM>x2gp4jd6-?>p?1Oix(liokMFao@QWR0ExrG=^O(C14s-sUkX!m9;ycBuS*OJ3?MPkTq#W7RR#tIkT_^w6z0|& +zNNxqmgXThEX5NIFxsQRB0W`mPkAZ;!BnFxX1&xP+($swh1_qEg$Q+QnK>h@YA^Q^) +z*XaHPt*r(5Q<{N+0cPJ51_lO@ILy6I85kHqbEY71n3@+*H8A(TMshz$4m2kU)BlEn +zfdMp^3Q_~hzi$~B7(n8naDkcm4r(UMpC1_*7(im6c~h7_KQS;cfW$%iLGA+i6C{T0 +zPtY1+bbo@@27~+wS{DMd?1?f|Vj2Dt;YW(Veu{|pQaAaPiJ2Q6d=mG>|V6K4d?BO;Xr +zu=Xz#BV^78R1Sd50_g{-1?3}|EkT@(JMHv|wKw=+g2p3A +z4rYfk)DBoYsxdM!fW;UY7+~?J&d9(35(nvr#Un@zIs8Ct;nBkn)CK^BpB7U1X)!V| +zfW%?pq>UDSuz1sf`W2)Wqz9x96haN44`!kAaPh6+cPpSfW$%T7GQpLf|?KVD@Y!+RsrNMkT|lxKzj?&{RP@b +z0P+`T0}ad_uAsFT;5Im9{=@_1FGdCi(Aox=8c(PiSeo)-WMBY^fz~y^-0#oGzyJ~l +ztzm$&4RLd}Gw!4O6U29OwNZ38R~hB7iRfW$%OfZPT0CrAw0pWsFQ +zXl-`TmJd*oCa-e<)%)beY3=E)k +z6CgFP@+py#fdM2A3Ky7}Nl-Ik{!C?LU;v4M)>6RynFd-<&&a?4(hqVM$e$oFWPgJ8 +zZ=lB~XyXFNpJqsHQ_xy`Q24^yrl7U>$b8URd}KaoEj}_Iv=$$k4_b?l%m=N-2k}AU +zU=^T&QU(Tw-we{=H3#L;@XBU{%&X@xLgw*v!F#P381g{tjTjgh@fO8gWLs^uYt;g@+c_mKz!JoPAwy7uNngbNE~E7h!4^a@()aZ1CoAF +zScCX5{f$ufgTz7lL41&YP&mN!H$(Nq%KH{*7=rrHAUT-2Ry1{OP<5brbdVe@&2~b? +zLFEHT4QQ4lmHat}xjrmhc7T|ZPE$UPuA +zn0qEb#X;@?sR5M%F!xMkWMBZ94^jhi4~P$PPXTCs5_GO`GMYV8pyq+x1CoQeXDXVy +zX;5___kiSJ?wJV{2e}8N2DHWq=AK!M3=AOiL25wm0r5fZ0i{ovJ#(S0gPYAC$L2e3<@Kj11uYZ6I-weh?p|AC&)K +z`qx19!_wC+dZ;>3e1PO&V^bSIYu*?b7?8)xHZn3WfW%?#g-whM +z3?MNWhQ;M(sF|R+1ep&~2aC%sj0_AQw}I4v`~u>G`~oUNU~b$Fbt5P)LFF2V4-1PO +zpfzQnz9}>;L41&YP?`51Z9E;cZw|d|2JM#vskcNbn?ZY&KxHSaYzFO7Lgs_^C?WGf +zdz6s*pgl^+e9#^xWIkw*5{M5fo6jJX&7ielr=elBA6hmafR@b%p=C2@KP+hM|1h*{ +zJ_0S9k3!4lW6-ksIJ9g&0WI53Ld)h;(6Sk14=jB7m>3v9;Q$JM5E~R8Ap2qRXF=;n +z86asD#0JTO+y#@r0F?*D4=C(Fd|3Qk1oZ_#eQ;>}fcPN&ApgMhUqR9j3TqG_rvECa +zp8{IH4Al?fgY<*K0jB>tR6i`O-++c8D6NC!VCrt7sk;SL2TJQ8IaqqV3l#^YSCAUe +zIz?D|y~oJF05Tt>1{7W(KFB?w_<-5-0L`9s-`-(y0Ap1dlkbY49gXsq?tOMmYSbAlI +z&M|`G10)Ai#{|unFm=pOb)fhF$-&BI7SP%gw6d8Mw0@BZ(GOx{VqgG?!7wZ?*`a2F +z;u2&&NF6LLIY4rtb+t^8ybt1o`~oUNU~c4wx)BtYpmGhwhlK?XXzd(mJ`AcK#0TjI +zm3hczGsxedy~?1n8MHSCG^P()?+4nK3}b`#CBxXDeMulTXzmlV7a3%xGqn5$=>zTg +z1eLEKH-hG_g~9XIknuGTUj($yjsY_61mcS_F))DCfW$#z4H6Rr`5k&z0Z1NZmN*jw +zcuzE_F9A{~!NkA-QVS9XsR8*#l8J!<ii?cH@FfcPPGk~0Oh!Lcgox$BF-ZdfwYEyhsYFcVh +zYF=_G0}BHOOs+UNF(B=7F;@9yIopIlH7UtCg|lNz6s +znU@-$l3A3RT#{Lq3Q`9)2dXYJFFB_)1z7ov97(|fG#G+Ohu9l5K9LY=*1B7{Ea5d}p{dh`tK(wOhnba}pIeY$nwJt^kYAo!6mJx70x=lse?7P!ZU!S{vkB-k +zgzIBtFlAt1NGvGGsRXAMi1DDfH-W2RXRv^)fu$;hI&-)>W(G%)IzvzzWMyz-U|?{~ +zFG+JON-qYbQ8osM7>J!%ln!zN156B*?-PsCL0*81f%0NvQMxfmJxm=l$o!&okkc4g +z8Q2(HKz8SX9Uh+uwFl&OFOWJTkUN+e{6K7D2s;SGHi59SKy0X6vl$o|JoC!n&IQ>A +z5(C+d>{gH%x?4eF=xzmxp}LiwAs6CSSOB89w*cf8sCz+yl?Zij8Au%J-d>RY0*Kou +zF)%RXmF6;l{5q3?fgvR|56oVRB)$bM&ccw$2q`x}{sEOMMT`s#1&Kw8AUA>dWoZ1# +zX#DAD{Ml&y`DpyLD126it&E8B2VGpVJ5`Hz)+kB3QHCSP`yx+S`lB6U(CSDz{()Q#K4e|SR9|5SW=W( +z0SbFo2011MhQyqLjKuid{FGFX7&C(k69Yq0dJ;HIs)NicN{$EV1FHk)RTc&_6!)+* +zcrh_BIOpf&7x^aUC8nq5rskEnB$gzC(?3WK6ix_xSr~jk?$3=+0jUASSvZP$91Iyy +zw}S1A&n?X<$t=jpOf3SL15%&I#J~WyfPsO5je(5;#0SZhr52TBCZ~du9RnK!3s@YK +z#&b(^7(n`pm>3wsQj0*r1(pZ#LHa;p1j;8MK1e-sxRpTlAk1ZB0EvP0A=?Kszl@22 +z0qiMoII)83<^0l;_+(JnFfcHOuVKq^B(WjM$@m>9?nATdx~4-*6F0f})i +zFfhQxK=~3R#sfB+1zd(GGBAMaes=~4A5;f`%mdjC%F7^gK>9&!kewj?AQ~nHQVSA; +z*$q+$(gPBM*$omyw;PoIKz4(~L3V@q2)iNq7GyTaERZ}XAA$HV^`LYN(hqVANFKyT +z)(=YC$nqdQNFJshl-}h)9B5d8(mTj}kUv1?fzmpN&kB_XrEL%&qy-c_KK0FoDuM0|Sg-h{Ol!0i|Jdb3kG+b3knikU7OreGcI8 +zU*ie(6k0i?;v-8#6kK&ZBAHt3o|f*)0`r>W`pEG?g#Nfc?;xzkbNNa4Gatnp`lmpVCFY6Fff3`U>K&ZpMim4%~^jNn7RoJ +z3=AMK7>22v0``BHJxtwH1_lO@7!1SI%>t>rFwGIBZZ^oDNEoJW4oF?YRJgji3=9k) +zF&KuaTf)G=kp18UJe-y?Fff3`U>K%uJ;*!-Ww?JgFfcHH#9$cao~;ZF44^av!!Z4O +zLHak$b%Lqe$H2e<5`$rwy2BuK3=MF5jxaDVfW%-JrtUZc1A}bT6I+;hCm0wQKw>Zq +zQ+EoaKQIj*uBRCo7(ik$3{!WOfq~(P1s^;d&M`1BfW%-JrtTsG14I4qU2uCZF)%QI +z#9&y2fr;TV0|Nsn|6O5V0JA~)52pVn#D8#mZZR-0fW%-JrtU5S1H&TMKDfK@F)%QI +z#9$bv?g0Y>Lt5H1cz8WzU|;}=!7xnSGf?07_%3)le9pkY01|^?n7TJ$_bI^B&07Wr +z29OvG!_>V4t7Cxs<2?fd14s;pVe#^dfq?;3rob>v|6c|MhKgAsaQ*)n7#KieFbq@2 +z%*epNbzK?mZWcxc29OvG!_;wt3KIqexOrTR3=AMK7>22nW@KP6xAV7#xle|XfdM22 +z!!UKKjG%B;gPW(u$iM&+gJGCD4MqkA$K~Ddc+zBKU;v51Fif2R*nJN0^l!+>zyK11 +zVVF7-Mh1q5Re^AIri=^>ATbz*sWWF}VDNu)1Rn1eAb%lYm^xcV28M~v^Wf>#j*)=@ +zBnHDUb&iY-4C~wz;p&_~{zAeqbuNqy40+3o;Qn=GWMBY^!7xmnCnE#HvvsO)^Sl@t +z7(ik$3{&UN$iNV^!w;U`0zm#k!Z3BgAa`@M!|e@WWMBY^!7xl+6e9zJjGiw%exn%~ +z7(ik$461(;7(w+6Bmo;SurPr5pgs;r1ZGYhBLl;t85tNrVlWId{}Cv>Z*POEd(6nd01|^? +zP&va1ZC8NWPN4dWiwRP%gTy5nI2m}L^57&0as(8^_#i#JpvsMb0jw5O=Yja3_8>^9 +z6ay23023(8SQrGM>Og#uIuIY!E(EEQW?*IzVS<BnApoZzf3F57b@;g(-*+Y7>LJ4wCnU%7fIy+?EOzgZUHGMpkEFX2@k?V0bFR +zz#s?h$AS2uwhl~RJ`-r%k%2*mfrSCoCI-bLNDRaWwTWSTkbY467o-LxF3Z5o0BZY! +z#6a!`wQWIsko!P(fb0Z`f%v5$zkG`>f?dhsv!Ats65DBAbtlloI&9O +z@;`_VYA1v0Tr&n%a2pvEZlH7l68pix!~p7#fYgHW38-HWYSV)Fpms2*FOJLy^}CVz +zpnfxmZ_B^}&MW*(4B+tq5FeB$gqau^K=~nzffbw=M41>EKx#nZAT^*cl4N24j}=5P +zu!G}Oiiv>%qz)twiZ@VQX~zH=a{$SGXJBSf21zh5F#KX*VF0C9B_;;27|1^$HppyH +z7^pHafX6EQ892c0UNt5L29OvCgTerm*FZ*r%m?`uBo8uQ8zcxdAC#uGm>3v9Vj%ND +zY)1xG22lH77ivBzeS_=-#e*Ib0|Q7LrbZu&8UrQ<29Wp`23Bx>He_O80EvP8x)MC1 +zz`$U{#J~U&2elnRW`fKIg|#sg19*%i0NR!}VS=aysR!W>2GHm>1A{3O0|Q7cNFL@t +zkl0TKCb(Zfeu9~64mB5~78K7Qz9rNw3nm8e*d|CEBo7KJkQgYeY>~nWlwWO_7#Kie +zps)h5Vc}qhB^>OT7#Kj}Ff|TX)HpIRFo48iVdccczyK0M4l8FS1_qEgD6Bwc!u;d{ +z^%E?tTtVYRNEjAYZlLiTCI$wOJj{O}F;G~+`~spu{`EleFQ~kAXJTLgiGlnJVuSnw +zDrfwd7{KF9puQZ)J)knjpNWA1Bo5+(;xYj0zCiG37Xw2e69WTC43suuVnI+bP+EnF +z1w+L^P6>eKn-C_5evo>QJSZ$CGO&X4R45Yz1ITWW8dzQkV`5+ciGwi6K9Kn!^I-l5 +z$%Fh4^BahUxjO>tZjf3~835wL(n=%~19*%ZBo2}Xr4^7EDD0v^7C`eAsE&wYVqgG? +zfx-^N28A6cU&UZa2eC{H3?Ols8{(jD=!2%wcqRr0kQhiE%wCWf$PX|(Kr~E$0#rZD +z&P1r4p!y6J&PhxR3?Ox&@Bx_#QUel$=}Cs_0o7SBJtRf +zn4WZ~9#HUr^nlcW#6WHbm35%-0nwl^%mOKbh9Rh~%4A|-0EvOrfY=~4IY??i;hfFH +zz)-`)zyMMMVuRcQsyix}7#KkH31~b46fU6htCER<0VEFMgUZkIJYn{Fo5)e?1hQ7Ld8JtfQhw1#X#ApIbX_gMk4&J`5`l +zL21nsJif=k08$U~3y2R>?*mnjoHl(K7#Kj}AoU=>g7`4?0Z{dzu`rPTK;{QBfcyqA +z9~2fKK1_WGR6S^{45mJmfq?-e4l*AURv +z7#Kj}AoZZI0`Wm%1xiPi3=9k)H^TT;3=9k)H-Pw{u&joLB`l6>7#J8pVjy)OH6Sx; +z85kHq?tq1B9RmXcNDPKyX|bMxfdQl+Bo9lAP0+9grA3fDXuKC@ZZiV|1IWEFb6Xe~ +z7(ikO3~8UXGB7ZJ%mK;6+}Xy!zyJ~l`45y9L44%22wGo(o)$s#A)vG<1WqrY6(tM| +zp!r=;IDpb3s2>BG<3;9!=6OMUkefktCj)5x3aG4rjE{GL&4Q#mP&*Ummu?(suLl|) +zptJ`v3#1>U78G|N_krYLcJ)E+f`#Qos9sQ5g5*GB;vjt>KY-+5`X)0#`~}O)(-;^S +zKw_Y=ahUt2qq!Sq#|)?)p!E|VeIRupJ3!$Al7rbX4{Ap%w7i(lz`y_+D+h_g>|MaX +zzyKO&2g!lV0EHz;3}(hc1_lO@xiJ1B1_lO@8dzItG{DaobpvOOGtqdssMZtcD#6M{69u%*z_y^73gZMBvtz=+e0O^B;8)z&8 +z6wfdW69zEUpGSag8T}S1I-=4 +z!g~`mY(Z*Z`EN4=0|O|IL2@ATL4E^?!OY)=X8v|0^FeaR=I?;UCrAy<{GAL844`=m +zkQ~T-P*{P)VCL^)U|;~*3FGf(U|;~LfyL(@1_lO@8jv{5kNc6_0FnpIZ-C+xB#s=P +zptVit@d;Yj1d314+#k%&LktWIAaPhY9c5r(0EvO-Lttu-LDj(gb&`RB0VD>R3xVl7 +z!@$4*5(mwDz}$Ks$*mxH&|C-1%nML6_c5?CfaWi*GB7ZJ#6a^Lpnf_iO{lKyw~2{f`+K7(jC+AT_Z3`-Fjk0VECz7nqq(p=QGT`GSFg0VD>R7lHZnB?AKk +zNF1ae19AsQ9N8V9Ic9WsfYz*m+yR;&hq>b$0|Ns{9G2g|gUb-i +za{mX8a{njPJ)m*`WEMz2NG&KIfy@QT!|eJEwTlNjZu1AK7ZffaIaq%B&%nR{k^{{F +z!NfodGeP2@@PMUnM$nir$djPC0O4(K5NDMjrKkRFgaP#A&uAUT-(RiNg<^r=Jjf#Mq^ +z2bw>G<#R1Y1_qEkXs!^H20(mdKY-SGqWb|9Par?&BKbj=5j4MmXv68@@Pj_oEg(08 +z^nlcX`~Wf&BnR_@5!5`GJB=9`7(nxsAaPh6n}O!L7#SEq^O7*XT0+eS`4uD&nu`Sa +z3nY&0FVMPFbbo=?po07bT6hg}hYceG14ta^4tr1*-ATeZrf)}DPFo4(Bz{WB_3syn?1T9E~`7;m{zToyhq)rH8WMBZze}cqeYC@rE +zVD1k?az988)DMCAH=L1y0W>cPQUfcWA{ZGMK;ocqfteWzH52B~7)AyLkQivL6z0!Z +zMg|6uI7mOpT_Asg#E|_7TKkJ0pPlH}b6trFx6uz*wDQLYYG9R>F6qyfN +zFN(|utrtb+gVu{8^FixHL443SU_Pi(&A`C$n?V}9ZZ!`YUP+*O0kj4dw0;1zwiUci +zpMfD2R8E7|P=M+P1_p+7M#x&P3`WR$uT0Q70#M%*RHlR0%7Dsy1_p*4Mo8a1mk~0? +z2(kwjz8gVv_6!URpfCfmLE!q!U5*KDyV)~d0!0;Lr@Y29krN*#@XMsC)pa0nMeu(rhCm0|Ur>kQ$JCKzxvU(vbYqjAl;@)I5-T +zKyonmw4$kNgQ^3$2P6k`Pdii`ld?7n(iY +zQ1d|U0m;GC^`NQig{lL&2P6k`&jhGA$UPu6pfv4!Ju=KhF8lIr|0Lj7BEk#qe45|(kA0Ro{*wk{+nid8I2IR4_6^slF +zAaPi`Y9%8B14s;pVR5+%Y9=TyLFR+h!Qyf?BLf4-Z6Gxuzkv84zktdRm>btY-3W?H +zP`L);!@^=cXw3+yZwie|5Fex;RObCh8&3!Axj-+QL3=Gg>MfDVX3#o%P}vD9n?dX7 +zk@=u?^vHbBI(lS2XdOK=AGD4hnGae=58{K$=EF#3GiW^wXs;AVXdAR_-VQCBcRW5(k+N;)C>q`~%Z}8c9DW +ztU-L3{xggW;JsHMagcrxAEX}?4lw=ap!#8H{X8_ifzmoi4yNt`n!1Znb)d8kl7pq! +zD^PJzdIhNgtwVyP*Q<;S3?TDCYC!G*@j>nZ#Rtru>uC1efSLz#4@eHC?k1YLTTpc% +z_kiSJ?zs&W2e}8N2DyE6hmnB+WIjj@$UPuF$UUHR0kh{GnmzZS=7HP;l7p#xfTr#t +zR2|4YAUT+Oo?RRKzvwy +zzXbJh7#J8p;voGXK1e?(UBmRhLDCP(dmuhc|6AxfHjp?-KZp;~56TZP{U4C@gYqhf +z57YmVkpa9%4I~cI58{LLgYpqf{}&|vpu7#@!}Nb;WB~6;1Brw5gZLo*p!^5Z{~f9y +zmR^5A!xI!AAUT-2pJ?iSLDhlc10)A4n}0Jx<|UBJ=0A)K3?Ok>+5DH0fdM22!?3vg +z2Q?EEmmu>&>R@sCA5>O=%2g0%U}6CC8JHMAWeCiTOwh6e6qlfK4aA3q1v6+(3TS+T +R2~y93_@J-=m3gqT832(+pa=i} + +diff --git a/src/gfx_apis/vulkan/shaders_bin/tex.vert.spv b/src/gfx_apis/vulkan/shaders_bin/tex.vert.spv +index 74f97643913146d0caf39139b7f2b8916e8487f7..7d49c6c7c5d1b34bdc45ae0e59d3aa19ca2506ee 100644 +GIT binary patch +delta 1447 +zcmeAXc_T8Rfl+Q^2f}AyVBlgX$S-C9X%e2ikx|%I6siniH5&s+3}g(llR!p_GcYiuK%LIYz|J5y +znUU!-FUSKRhk1Y;GWj8suqj9ceT1{N?I;j2{ +z^vi9Y$SlmLD$c;dzz;Qwg@J)V4jLM^ln)YvTF9UU)eqr|Gcf2vqY)y&z+k|@z`#8DC95_g|71}%V@BD@o^0lf +z{F58mg6lyJlxARH0Qm~!a9e07$S^Q4fW$$3kYnY+A<4kt$iT(`a-<>y0|Q76NDoNP +zmVphNnw1zB7(jBMC885rsrco`VLAqG+i@|79` +z0|Q7gOd-g}8Vn2!s!(-y49pB5K1dx*k0w+PC}cr~gY;-a^?*VLqzA+Yse|d!0qd!U +zBzTx6J*Xy72!b?$_#lNaP5NL>3=E(Q0n=j$)dLD;kRA{pqz7sa!$Cebhw6d( +z9K;8ygXyt=>H$SjJ1_lO~#F9h?1_lN;1~vu|AH*(8Eh@=OPAvwp*%(;B;*1Oo +z47sH_46F<+3_=VH3}LB7C8-r4eGooKZ9#r90~-S?gV5xMjKa1eP*n(%*cd=!AeG2A +zfJ_u)U|>jrx`dU1ok4c8A=71Ekb9UI7#KVlSQ+>y3o;9vfX<4gIx!)j|I#Ixl{sbKg{JIn?d3rn`Jk@ +zWEN(Wm4gNmAJjM*sH4~!7#KJwE3yjLgH(VV0CFnGd9n=53?R!vVsZ=+i$Qz^1{Ma8 +zDNjWhzzzWMMM0)8Fn}Y$nt_D@#0LczCj$e6C<6}FmxYRfWA@l7#J8pAqnA&Gcf2gFfcGPFffQSurOFd!--|GC!02-_2g1EV@8?DE7{B$ +zttUTZ3$B-BU|^79U|<0G666zGXsAd-#X)?K29O*mq#PMo89*LY0fiPX0|Nud5RgKUFI5>B7(j|) +z3PB;D&cMK+0##?nz{~*RgVe$FXh8LVLK47ytLKmjT0ICNR>L5KJK1dx%4=DW^LVXT%9LVQJ3=9k)k89R5 +zFff1=f_!ewz`y`fjO=q01_lO@Vo(Ty3R@_Ip?YA3gM4lV)dTZ6h!0W+(_;?R +z14=XXFke_eHNkui;)4{zG+9D5fzlOBj}=r8%;z9JNF6NAI599VID#=7I3YVjeGT#q +sNY0jlW%7DXOGfL-FFDm2B_<1U3Cn`gnjcsXNH0X6KUAOQWJfM-07NiZ2mk;8 + +diff --git a/src/gfx_apis/vulkan/shaders_hash.txt b/src/gfx_apis/vulkan/shaders_hash.txt +index ab855a2d..9fad083a 100644 +--- a/src/gfx_apis/vulkan/shaders_hash.txt ++++ b/src/gfx_apis/vulkan/shaders_hash.txt +@@ -1,19 +1,19 @@ + 302a9f250bdc4f8e0e71a9f77c9a8a7aa55fd003bc91c2422a700c4abd83f54e src/gfx_apis/vulkan/shaders/alpha_modes.glsl + b6a0df1e231fab533499329636b7a580384784418baee06c147af5fcc384cf5c src/gfx_apis/vulkan/shaders/eotfs.glsl +-8a38df18851cd13884499820f26939fb7319f45d913d867f254d8118d59fb117 src/gfx_apis/vulkan/shaders/fill.common.glsl +-21c488d12aa5ad2f109ec44cb856dfe837e02ea9025b5ed64439d742c17cbf30 src/gfx_apis/vulkan/shaders/fill.frag ++14d2b60b73a687994bad37963885f325e2930714f23fb0e76dec023c994f81cc src/gfx_apis/vulkan/shaders/fill.common.glsl ++fe792632ea7ceee7b2cf05b64c94c12ec45b11446672bface2f27c98e3b2b4a5 src/gfx_apis/vulkan/shaders/fill.frag + 4fb481d8d73afdfb0d8f077eb8665d86f06c8a32a91e44ed369ef5dff554646d src/gfx_apis/vulkan/shaders/fill.vert + f93524fd077bc9984702b1e0f92232f80bfe28a0a92439dc164c1ea41fd16d64 src/gfx_apis/vulkan/shaders/frag_spec_const.glsl +-c315a064b48dd5bdb607a6b79c30d31b6e59ffec69e93d50ab875abf97c41bbf src/gfx_apis/vulkan/shaders/legacy/fill.common.glsl +-590d061b97446fc501158609eaf098b71bc7b328c008b586ff36613ce690d618 src/gfx_apis/vulkan/shaders/legacy/fill.frag ++220734896731e76a514201f6be40ffe1feee8da393b017882389aa5250bbccbe src/gfx_apis/vulkan/shaders/legacy/fill.common.glsl ++58e7c0a6a700b2c68a965849b930c1dc4e595e90bfb7df1a2d1cd2a9d19ddbf2 src/gfx_apis/vulkan/shaders/legacy/fill.frag + ad22a79e1a88a12daa40c0a2b953084c129a408297c8ca544d60e0b6001470b9 src/gfx_apis/vulkan/shaders/legacy/fill.vert +-e0a8769dd7938dd02e66db9e9048ed6bef8f8c42671f2e2c7a7976a6d498f685 src/gfx_apis/vulkan/shaders/legacy/tex.common.glsl +-0e7c72ea11671065842c8b4ad4131a7df33b427dc0ea76bf5a896546f6636cb0 src/gfx_apis/vulkan/shaders/legacy/tex.frag ++aae35457901f8504ba50e73258c9902eaeea9e55f9be72b497f8e5f8c1d62580 src/gfx_apis/vulkan/shaders/legacy/tex.common.glsl ++038456023e755ba9b20ddd937a4ec0dc9c561f872976848365a35be3342004b0 src/gfx_apis/vulkan/shaders/legacy/tex.frag + 4402f7ccdbb9fb52fb6cda3aab13cf89e2980c79b541f8be0463efd64a5f98ed src/gfx_apis/vulkan/shaders/legacy/tex.vert + 3ba5d05c2b95099e5424b3ade5d1c31d431f5730b1d0b51a9fb5f8afc4ea14b4 src/gfx_apis/vulkan/shaders/out.common.glsl + 5069f619c7d722815a022e2d84720a2d8290af49a3ed49ea0cd26b52115cc39a src/gfx_apis/vulkan/shaders/out.frag + 0adc7e12328c15fb3e7e6c8b8701a182223c2f15337e14131f41dd247e697809 src/gfx_apis/vulkan/shaders/out.vert +-e22d4d3318a350def8ef19c7b27dc6a308a84c2fe9d7c02b81107f72073cd481 src/gfx_apis/vulkan/shaders/tex.common.glsl +-1f196cee646a934072beb3e5648a5042c035953d9a0c26b0a22e330c2f8bb994 src/gfx_apis/vulkan/shaders/tex.frag ++6885f937160dd379a2b8c56dd7f4b17d89d685b84bedccd14639de1935a15e4a src/gfx_apis/vulkan/shaders/tex.common.glsl ++578fb3dd46d71308d4c6dacfb2ce823b2eeddbac23165e67cf83b64dd81f5de6 src/gfx_apis/vulkan/shaders/tex.frag + 423cf327c9fcc4070dbf75321c1224a1589b6cf3d2f1ea5e8bd0362e1a9f3aa1 src/gfx_apis/vulkan/shaders/tex.vert + b982f7101c22931a33b32dce3408387f3392c0f0ad0ca5852da265b0d12856bb src/gfx_apis/vulkan/shaders/tex_set.glsl +diff --git a/src/renderer.rs b/src/renderer.rs +index 1d59f364..2ade06a4 100644 +--- a/src/renderer.rs ++++ b/src/renderer.rs +@@ -295,7 +295,17 @@ impl Renderer<'_> { + } + + pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) { ++ let cr = self.state.theme.sizes.corner_radius.get(); + if container.mono_child.is_some() { ++ if cr > 0 { ++ let outer = Rect::new_sized_saturating( ++ x, ++ y, ++ container.width.get(), ++ container.height.get(), ++ ); ++ self.base.set_round_clip(outer, cr); ++ } + let srgb_srgb = self.state.color_manager.srgb_gamma22(); + let srgb = &srgb_srgb.linear; + let rd = container.render_data.borrow_mut(); +@@ -408,6 +418,7 @@ impl Renderer<'_> { + .node + .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); + } ++ self.base.clear_round_clip(); + } else { + let srgb_srgb = self.state.color_manager.srgb_gamma22(); + let srgb = &srgb_srgb.linear; +@@ -454,8 +465,31 @@ impl Renderer<'_> { + } else { + (0, 0, 0, 0) + }; +- // Render title background and underline with animation offset. ++ // Compute the animated body rect early so we can set the round clip. ++ let anim_w = (body.width() + anim_dw).max(1); ++ let anim_h = (body.height() + anim_dh).max(1); ++ let anim_body = Rect::new_sized_saturating( ++ body.x1() + anim_dx, ++ body.y1() + anim_dy, ++ anim_w, ++ anim_h, ++ ); + let title_rect = child.title_rect.get(); ++ if cr > 0 { ++ let top_y = if tpuh > 0 { ++ title_rect.y1() + anim_dy ++ } else { ++ anim_body.y1() ++ }; ++ let outer = Rect::new_saturating( ++ anim_body.x1() - bw + x, ++ top_y - bw + y, ++ anim_body.x2() + bw + x, ++ anim_body.y2() + bw + y, ++ ); ++ self.base.set_round_clip(outer, cr); ++ } ++ // Render title background and underline with animation offset. + if tpuh > 0 { + let anim_title = title_rect.move_(anim_dx, anim_dy); + let title_bg = if child.active.get() { +@@ -519,15 +553,6 @@ impl Renderer<'_> { + } + } + } +- // Compute the animated body rect. +- let anim_w = (body.width() + anim_dw).max(1); +- let anim_h = (body.height() + anim_dh).max(1); +- let anim_body = Rect::new_sized_saturating( +- body.x1() + anim_dx, +- body.y1() + anim_dy, +- anim_w, +- anim_h, +- ); + // Draw per-child border frames at the animated position/size. + if gap > 0 && !child.node.node_is_container() { + let c = if child.border_color_is_focused.get() { +@@ -595,6 +620,7 @@ impl Renderer<'_> { + y + content.y1() + anim_dy, + Some(&body), + ); ++ self.base.clear_round_clip(); + } + if has_active_animation { + let abs_x = container.abs_x1.get(); +@@ -837,6 +863,11 @@ impl Renderer<'_> { + theme.colors.unfocused_title_background.get() + }; + let uc = theme.colors.separator.get(); ++ let cr = theme.sizes.corner_radius.get(); ++ if cr > 0 { ++ let outer = Rect::new_sized_saturating(x, y, pos.width(), pos.height()); ++ self.base.set_round_clip(outer, cr); ++ } + let borders = [ + Rect::new_sized_saturating(x, y, pos.width(), bw), + Rect::new_sized_saturating(x, y + bw, bw, pos.height() - bw), +@@ -926,6 +957,7 @@ impl Renderer<'_> { + ); + let scissor_body = self.base.scale_rect(body); + child.node_render(self, body.x1(), body.y1(), Some(&scissor_body)); ++ self.base.clear_round_clip(); + } + + pub fn render_layer_surface(&mut self, surface: &ZwlrLayerSurfaceV1, x: i32, y: i32) { +diff --git a/src/renderer/renderer_base.rs b/src/renderer/renderer_base.rs +index 7963f79b..e018fa7d 100644 +--- a/src/renderer/renderer_base.rs ++++ b/src/renderer/renderer_base.rs +@@ -3,7 +3,7 @@ use { + cmm::cmm_description::{ColorDescription, LinearColorDescription}, + gfx_api::{ + AcquireSync, AlphaMode, BufferResv, CopyTexture, FillRect, FramebufferRect, GfxApiOpt, +- GfxTexture, ReleaseSync, SampleRect, ++ GfxTexture, ReleaseSync, RoundClip, SampleRect, + }, + rect::Rect, + scale::Scale, +@@ -21,6 +21,7 @@ pub struct RendererBase<'a> { + pub transform: Transform, + pub fb_width: f32, + pub fb_height: f32, ++ pub round_clip: Option, + } + + impl RendererBase<'_> { +@@ -64,6 +65,21 @@ impl RendererBase<'_> { + rect + } + ++ pub fn set_round_clip(&mut self, logical_rect: Rect, corner_radius: i32) { ++ let scaled = self.scale_rect(logical_rect); ++ self.round_clip = Some(RoundClip { ++ x1: scaled.x1() as f32, ++ y1: scaled.y1() as f32, ++ x2: scaled.x2() as f32, ++ y2: scaled.y2() as f32, ++ corner_radius: (corner_radius as f64 * self.scalef) as f32, ++ }); ++ } ++ ++ pub fn clear_round_clip(&mut self) { ++ self.round_clip = None; ++ } ++ + pub fn fill_scaled_boxes( + &mut self, + boxes: &[Rect], +@@ -121,6 +137,7 @@ impl RendererBase<'_> { + color: *color, + alpha, + cd: cd.clone(), ++ clip: self.round_clip, + })); + } + } +@@ -161,6 +178,7 @@ impl RendererBase<'_> { + color: *color, + alpha: None, + cd: cd.clone(), ++ clip: self.round_clip, + })); + } + } +@@ -229,6 +247,7 @@ impl RendererBase<'_> { + opaque, + cd: cd.clone(), + alpha_mode, ++ clip: self.round_clip, + })); + } + +diff --git a/src/theme.rs b/src/theme.rs +index 51d490ac..e3c85d1c 100644 +--- a/src/theme.rs ++++ b/src/theme.rs +@@ -517,6 +517,7 @@ sizes! { + bar_separator_width = (0, 1000, 1), + gap = (0, 1000, 0), + animation_duration = (0, 5000, 0), ++ corner_radius = (0, 100, 0), + } + + pub const DEFAULT_FONT: &str = "monospace 8"; +diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs +index b4626e62..98bb72ef 100644 +--- a/toml-config/src/config.rs ++++ b/toml-config/src/config.rs +@@ -215,6 +215,7 @@ pub struct Theme { + pub animation_mode: Option, + pub spawn_animation: Option, + pub animation_curve: Option, ++ pub corner_radius: Option, + } + + #[derive(Debug, Clone)] +diff --git a/toml-config/src/config/parsers/theme.rs b/toml-config/src/config/parsers/theme.rs +index c1d92d23..87d267a0 100644 +--- a/toml-config/src/config/parsers/theme.rs ++++ b/toml-config/src/config/parsers/theme.rs +@@ -63,7 +63,7 @@ impl Parser for ThemeParser<'_> { + font, + title_font, + ), +- (bar_font, bar_position_val, bar_separator_width, gap, animation_duration, animation_mode_val, spawn_animation_val, animation_curve_val), ++ (bar_font, bar_position_val, bar_separator_width, gap, animation_duration, animation_mode_val, spawn_animation_val, animation_curve_val, corner_radius), + ) = ext.extract(( + ( + opt(val("attention-requested-bg-color")), +@@ -98,6 +98,7 @@ impl Parser for ThemeParser<'_> { + recover(opt(str("animation-mode"))), + recover(opt(str("spawn-animation"))), + opt(val("animation-curve")), ++ recover(opt(s32("corner-radius"))), + ), + ))?; + macro_rules! color { +@@ -187,6 +188,7 @@ impl Parser for ThemeParser<'_> { + animation_mode, + spawn_animation, + animation_curve, ++ corner_radius: corner_radius.despan(), + }) + } + } +diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs +index 1fe98ba9..0e4a1a32 100644 +--- a/toml-config/src/lib.rs ++++ b/toml-config/src/lib.rs +@@ -986,6 +986,7 @@ impl State { + size!(BAR_SEPARATOR_WIDTH, bar_separator_width); + size!(GAP, gap); + size!(ANIMATION_DURATION, animation_duration); ++ size!(CORNER_RADIUS, corner_radius); + macro_rules! font { + ($fun:ident, $field:ident) => { + if let Some(font) = &theme.$field { +-- +2.53.0 + diff --git a/graphical/desktop/patches/FEATURES.md b/graphical/desktop/patches/FEATURES.md new file mode 100644 index 0000000..ce0f5fd --- /dev/null +++ b/graphical/desktop/patches/FEATURES.md @@ -0,0 +1,217 @@ +# Patch Series Feature Documentation + +This document describes each patch in the series, its user-facing impact, and +all configuration options. + +--- + +## 01. Configurable Gap Between Tiled Windows + +Adds a configurable pixel gap between tiled windows. When set to a value +greater than 0, windows are visually separated by empty space instead of +touching at their borders. + +**Config option:** + +| Key | Type | Default | Range | +|-----|------|---------|-------| +| `theme.gap` | integer | `0` | 0–1000 | + +**Example:** + +```toml +[theme] +gap = 10 +``` + +--- + +## 02. Position and Size Animations for Tiled Windows + +Adds smooth animations when tiled windows change position or size (e.g. when +a window is opened, closed, or resized). Window transitions interpolate from +old to new geometry over a configurable duration. + +**Config option:** + +| Key | Type | Default | Range | +|-----|------|---------|-------| +| `theme.animation-duration` | integer (ms) | `0` | 0–5000 | + +Set to `0` to disable animations. + +**Example:** + +```toml +[theme] +animation-duration = 200 +``` + +--- + +## 03. Window Border Frames When Gaps Are Enabled + +When gaps are enabled (`gap > 0`), each tiled window gets an individual border +frame drawn around it, rather than sharing borders with adjacent windows. This +gives each window a distinct visual boundary within the gap space. + +**Config option:** + +Uses the existing `theme.border-width` setting. Borders are drawn per-window +when `gap > 0`. + +| Key | Type | Default | +|-----|------|---------| +| `theme.border-width` | integer | `4` | + +--- + +## 04. Sequential Animation Mode for Tiled Windows + +Adds a sequential animation mode where position and size changes animate in +two phases rather than simultaneously. When a window shrinks, it resizes first +then moves; when it grows, it moves first then resizes. This prevents windows +from overlapping during animations. + +**Config option:** + +| Key | Type | Default | Values | +|-----|------|---------|--------| +| `theme.animation-mode` | string | `"concurrent"` | `"concurrent"`, `"sequential"` | + +**Example:** + +```toml +[theme] +animation-mode = "sequential" +animation-duration = 200 +``` + +--- + +## 05. Cursor Follows Focus + +When enabled, the mouse cursor warps to the center of the focused window on +keyboard-driven focus changes. This is useful with multi-monitor setups or +large screens where the cursor may be far from the focused window. + +**Config option:** + +| Key | Type | Default | +|-----|------|---------| +| `cursor-follows-focus` | boolean | `false` | + +**Example:** + +```toml +cursor-follows-focus = true +``` + +--- + +## 06. Toggle Focus Between Floating and Tiled Layers + +Adds an action to toggle keyboard focus between the floating and tiled window +layers. Useful for quickly switching attention between a floating terminal and +tiled editor, for example. + +**Config action:** + +| Action | Description | +|--------|-------------| +| `toggle-float-focus` | Toggles focus between floating and tiled windows | + +**Example:** + +```toml +[shortcuts] +alt-space = "toggle-float-focus" +``` + +--- + +## 07. Open/Close Animations for Tiled Windows + +Adds spawn and despawn animations for tiled windows. When a window is opened +or closed, it can slide in from a screen edge or grow/shrink from center. + +**Config option:** + +| Key | Type | Default | Values | +|-----|------|---------|--------| +| `theme.spawn-animation` | string | `"none"` | `"none"`, `"slide-left"`, `"slide-right"`, `"slide-top"`, `"slide-bottom"`, `"grow"` | + +Requires `animation-duration > 0` to be visible. + +**Example:** + +```toml +[theme] +spawn-animation = "grow" +animation-duration = 200 +``` + +--- + +## 08. Directional Focus Navigation for Floating Windows + +Extends the existing directional focus commands (`focus-left`, `focus-right`, +`focus-up`, `focus-down`) to work with floating windows. When focus is on a +floating window, left/down moves to the previous float in stacking order and +right/up moves to the next. Navigation wraps around at the ends. + +**No new config options.** Uses existing directional focus key bindings. + +--- + +## 09. Configurable Animation Easing Curves + +Adds configurable easing curves for all animations. Choose from preset curves +or define a custom cubic bezier curve (same syntax as CSS `cubic-bezier()`). + +**Config option:** + +| Key | Type | Default | Values | +|-----|------|---------|--------| +| `theme.animation-curve` | string or array | `"ease-out"` | `"linear"`, `"ease-in"`, `"ease-out"`, `"ease-in-out"`, `[x1, y1, x2, y2]` | + +For custom bezier curves, specify an array of 4 floats representing the two +control points, matching CSS `cubic-bezier(x1, y1, x2, y2)`. + +**Examples:** + +```toml +[theme] +animation-curve = "ease-in-out" +``` + +```toml +[theme] +# CSS "ease" equivalent +animation-curve = [0.25, 0.1, 0.25, 1.0] +``` + +--- + +## 10. Rounded Corners for Window Frames + +Adds rounded corners to window frames. All frame elements (borders, title bar, +content) are clipped to a rounded rectangle using anti-aliased SDF clipping in +the fragment shaders. Works for both tiled and floating windows. Best paired +with gaps for clean visuals. + +**Config option:** + +| Key | Type | Default | Range | +|-----|------|---------|-------| +| `theme.corner-radius` | integer (px) | `0` | 0–100 | + +Set to `0` for square corners. + +**Example:** + +```toml +[theme] +gap = 10 +corner-radius = 8 +``` diff --git a/graphical/desktop/quickshell/shell.qml b/graphical/desktop/quickshell/shell.qml index 3410a0f..0140974 100644 --- a/graphical/desktop/quickshell/shell.qml +++ b/graphical/desktop/quickshell/shell.qml @@ -88,155 +88,161 @@ ShellRoot { } // centre main - Variants { - model: Quickshell.screens.filter(screen => screen.name != "DP-1") - delegate: PanelWindow { - id: windowTitle - property var modelData - anchors { - top: true - } + // Variants { + // model: Quickshell.screens.filter(screen => screen.name != "DP-1") + // delegate: PanelWindow { + // id: windowTitle + // property var modelData + // anchors { + // top: true + // left: true + // right: true + // } + // margins { + // left: 300 + // right: 300 + // } - TextMetrics { - id: textInfo - font { - family: "MS W98 UI" - pointSize: 12 - } - elideWidth: 550 - elide: Qt.ElideMiddle - text: Title.currentWindow - } + // TextMetrics { + // id: textInfo + // font { + // family: "MS W98 UI" + // pointSize: 12 + // } + // elideWidth: 550 + // elide: Qt.ElideMiddle + // text: Title.currentWindow + // } - implicitHeight: textInfo.height + 6 - implicitWidth: (textInfo.width > textInfo.elideWidth ? textInfo.elideWidth : textInfo.width) + 24 - Behavior on implicitWidth { - NumberAnimation { - duration: 150 - easing.type: Easing.InOutQuad - } - } - color: "transparent" + // implicitHeight: textInfo.height + 6 + // implicitWidth: (textInfo.width > textInfo.elideWidth ? textInfo.elideWidth : textInfo.width) + 24 + // Behavior on implicitWidth { + // NumberAnimation { + // duration: 150 + // easing.type: Easing.InOutQuad + // } + // } + // color: "transparent" - Rectangle { - anchors { - fill: parent - } - color: Colours.c.black - bottomLeftRadius: 10 - bottomRightRadius: 10 + // Rectangle { + // anchors { + // fill: parent + // } + // color: Colours.c.black + // bottomLeftRadius: 10 + // bottomRightRadius: 10 - Rectangle { - anchors { - fill: parent - leftMargin: 12 - rightMargin: 12 - topMargin: 4 - } - color: "transparent" - Text { - font { - family: "MS W98 UI" - pointSize: 12 - } - color: Colours.c.yellow_b - text: textInfo.elidedText - } - } - } + // Rectangle { + // anchors { + // fill: parent + // leftMargin: 12 + // rightMargin: 12 + // topMargin: 4 + // } + // color: "transparent" + // Text { + // font { + // family: "MS W98 UI" + // pointSize: 12 + // } + // color: Colours.c.yellow_b + // text: textInfo.elidedText + // } + // } + // } - exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Top - screen: modelData - } - } + // exclusionMode: ExclusionMode.Ignore + // WlrLayershell.layer: WlrLayer.Top + // screen: modelData + // } + // } - // bottom middle main - Variants { - model: Quickshell.screens - delegate: PanelWindow { - id: tags - property var modelData - anchors { - bottom: true - } + // // bottom middle main + // Variants { + // model: Quickshell.screens + // delegate: PanelWindow { + // id: tags + // property var modelData + // anchors { + // bottom: true + // } - implicitHeight: 22 - implicitWidth: (Tags.keys.length !== undefined) ? Tags.keys.length * 13 + 24 : 37; - color: "transparent" + // implicitHeight: 22 + // implicitWidth: (Tags.keys.length !== undefined) ? Tags.keys.length * 13 + 24 : 37; + // color: "transparent" - Rectangle { - anchors { - fill: parent - } - color: Colours.c.black - topLeftRadius: 10 - topRightRadius: 10 + // Rectangle { + // anchors { + // fill: parent + // } + // color: Colours.c.black + // topLeftRadius: 10 + // topRightRadius: 10 - Rectangle { - anchors { - fill: parent - leftMargin: 12 - rightMargin: 12 - topMargin: 3 - } - Row { - anchors { - fill: parent - } - spacing: 1 - Repeater { - model: Tags.keys - delegate: Column { - id: baseCol - required property var modelData - spacing: 3 - Rectangle { - property var tag: Tags.tags[baseCol.modelData] - width: 12 - height: width - radius: width - color: tag.urgent ? Colours.c.red_b : (tag.enabled) ? Colours.c.yellow_b : Colours.c.black - Behavior on color { - ColorAnimation { - duration: 300 - } - } - border { - width: 1 - color: tag.urgent ? Colours.c.red_b : Colours.c.yellow_b - Behavior on color { - ColorAnimation { - duration: 300 - } - } - } - } - Rectangle { - property var tag: Tags.tags[baseCol.modelData] - anchors.horizontalCenter: parent.horizontalCenter - width: 1 - height: 1 - color: tag.urgent ? Colours.c.red_b : (tag.occupied) ? Colours.c.yellow_b : Colours.c.black - Behavior on color { - ColorAnimation { - duration: 300 - } - } - } - } - } - } - color: "transparent" - } // inner container + // Rectangle { + // anchors { + // fill: parent + // leftMargin: 12 + // rightMargin: 12 + // topMargin: 3 + // } + // Row { + // anchors { + // fill: parent + // } + // spacing: 1 + // Repeater { + // model: Tags.keys + // delegate: Column { + // id: baseCol + // required property var modelData + // spacing: 3 + // Rectangle { + // property var tag: Tags.tags[baseCol.modelData] + // width: 12 + // height: width + // radius: width + // color: tag.urgent ? Colours.c.red_b : (tag.enabled) ? Colours.c.yellow_b : Colours.c.black + // Behavior on color { + // ColorAnimation { + // duration: 300 + // } + // } + // border { + // width: 1 + // color: tag.urgent ? Colours.c.red_b : Colours.c.yellow_b + // Behavior on color { + // ColorAnimation { + // duration: 300 + // } + // } + // } + // } + // Rectangle { + // property var tag: Tags.tags[baseCol.modelData] + // anchors.horizontalCenter: parent.horizontalCenter + // width: 1 + // height: 1 + // color: tag.urgent ? Colours.c.red_b : (tag.occupied) ? Colours.c.yellow_b : Colours.c.black + // Behavior on color { + // ColorAnimation { + // duration: 300 + // } + // } + // } + // } + // } + // } + // color: "transparent" + // } // inner container - }// outer visible rect + // }// outer visible rect - exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Top - screen: modelData - }//invisible rect - } + // exclusionMode: ExclusionMode.Ignore + // WlrLayershell.layer: WlrLayer.Top + // screen: modelData + // }//invisible rect + // } // pops up on current monitor Launcher.Launcher { diff --git a/graphical/desktop/shell.nix b/graphical/desktop/shell.nix index 7b3941d..2aa8888 100644 --- a/graphical/desktop/shell.nix +++ b/graphical/desktop/shell.nix @@ -22,7 +22,7 @@ in inputs.stash.nixosModules.default ]; services.stasis = { - enable = true; + enable = false; extraPathPackages = [ (config.programs.niri.package) ]; extraConfig = '' default: @@ -44,32 +44,35 @@ in }; quick.services = { - noti = "${getExe pkgs.swaynotificationcenter}"; - shell = "${getExe pkgs.quickshell}"; - pwManager = "${getExe config.apps.passwordManager}"; + noti.cmd = "${getExe pkgs.swaynotificationcenter}"; + shell = { + cmd = "${getExe pkgs.quickshell}"; + restart = true; + }; + # pwManager.cmd = "${getExe config.apps.passwordManager}"; # music = "${getExe config.apps.streamPlayer}"; }; - user.systemd.services.music = { - environment.PATH = lib.mkForce "/run/current-system/sw/bin:/run/current-system/sw/sbin:/etc/profiles/per-user/${mainUser}/bin:/etc/profiles/per-user/${mainUser}/sbin"; - unitConfig = { - Description = "airdrome"; - Requires = [ - "graphical-session.target" - ]; - After = [ - "graphical-session.target" - "niri.target" - ]; - PartOf = [ "graphical-session.target" ]; - }; - serviceConfig = { - ExecStart = "${getExe config.apps.streamPlayer}"; - Type = "forking"; - }; - wantedBy = [ "graphical-session.target" ]; + # user.systemd.services.music = { + # environment.PATH = lib.mkForce "/run/current-system/sw/bin:/run/current-system/sw/sbin:/etc/profiles/per-user/${mainUser}/bin:/etc/profiles/per-user/${mainUser}/sbin"; + # unitConfig = { + # Description = "airdrome"; + # Requires = [ + # "graphical-session.target" + # ]; + # After = [ + # "graphical-session.target" + # "niri.target" + # ]; + # PartOf = [ "graphical-session.target" ]; + # }; + # serviceConfig = { + # ExecStart = "${getExe config.apps.streamPlayer}"; + # Type = "forking"; + # }; + # wantedBy = [ "graphical-session.target" ]; - }; + # }; environment.files."/home/${mainUser}/.config/quickshell" = { source = "/home/${mainUser}/.nix/graphical/desktop/quickshell"; diff --git a/graphical/desktop/wm.nix b/graphical/desktop/wm.nix index 30a9231..12013cf 100644 --- a/graphical/desktop/wm.nix +++ b/graphical/desktop/wm.nix @@ -1,5 +1,4 @@ { - mainUser, inputs, getFlakePkg', lib, @@ -8,14 +7,19 @@ ... }: let - niri = (getFlakePkg' inputs.niri "niri-unstable"); - xwayland-satellite = (getFlakePkg' inputs.niri "xwayland-satellite-unstable"); inherit (config) rice; + niri = getFlakePkg' inputs.niri "niri-unstable"; + xwayland-satellite = getFlakePkg' inputs.niri "xwayland-satellite-unstable"; + niri-session-direct = pkgs.writeShellScript "niri-session-direct" '' + systemctl --user import-environment + dbus-update-activation-environment --all + systemctl --user start niri-session-bridge.service & + exec ${lib.getExe niri} --session + ''; in { imports = [ - inputs.niri.nixosModules.niri inputs.niri-tag.nixosModules.niri-tag inputs.niri-s76.nixosModules.default ]; @@ -51,34 +55,19 @@ in baseConfig; }; - user.packages = [ + environment.systemPackages = [ niri xwayland-satellite ]; - services.greetd = { - enable = true; - restart = false; - settings = - let - session = { - command = - let - niri-session-direct = pkgs.writeShellScript "niri-session-direct" '' - systemctl --user import-environment - dbus-update-activation-environment --all - exec ${lib.getExe niri} --session - ''; - in - "${niri-session-direct}"; - user = "${mainUser}"; - }; - in - { - default_session = session; - initial_session = session; - }; - }; + environment.etc."greetd/wayland-sessions/niri.desktop".text = '' + [Desktop Entry] + Name=Niri + Comment=A scrollable-tiling Wayland compositor + Exec=${niri-session-direct} + Type=Application + DesktopNames=niri + ''; programs.niri = { enable = true; @@ -97,8 +86,26 @@ in services.niri-s76-bridge.enable = true; - # niri runs directly from greetd (session-1.scope), not as a user service + # niri runs directly from greetd (not as a user service), + # so that it stays inside the logind session scope for proper polkit/dbus access. systemd.user.services.niri.wantedBy = lib.mkForce [ ]; systemd.user.services.niri.enable = lib.mkForce false; + # bridge service to activate graphical-session.target for the direct-launched niri. + # waits for niri IPC readiness before pulling in the target. + systemd.user.services.niri-session-bridge = { + unitConfig = { + Description = "Activate graphical-session.target for direct-launched niri"; + BindsTo = [ "graphical-session.target" ]; + Before = [ "graphical-session.target" ]; + Wants = [ "graphical-session-pre.target" ]; + After = [ "graphical-session-pre.target" ]; + }; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${lib.getExe niri} msg version"; + }; + }; + } diff --git a/graphical/dev.nix b/graphical/dev.nix index d2e7101..d5ab93c 100644 --- a/graphical/dev.nix +++ b/graphical/dev.nix @@ -1,15 +1,10 @@ { - inputs, - localPkgs, - getFlakePkg', + pkgs, ... }: scope "user" { programs = { - direnv = { - enable = true; - integrations.fish.enable = true; - }; + direnv.enable = true; git = { enable = true; @@ -34,4 +29,6 @@ scope "user" { }; }; + packages = [ pkgs.jujutsu ]; + } diff --git a/graphical/integrations.nix b/graphical/integrations.nix index 49b634a..c49a283 100644 --- a/graphical/integrations.nix +++ b/graphical/integrations.nix @@ -1,28 +1,12 @@ { - pkgs, lib, ... }: { xdg.autostart.enable = lib.mkForce false; - xdg.portal = { - enable = true; - config = { - common = { - default = [ - "gnome" - ]; - }; - }; - extraPortals = [ - pkgs.xdg-desktop-portal-gnome - ]; - }; environment.pathsToLink = [ "/share/xdg-desktop-portal" "/share/applications" ]; - - security.polkit.enable = true; } diff --git a/graphical/kernel.nix b/graphical/kernel.nix index 8e631b6..04d787c 100644 --- a/graphical/kernel.nix +++ b/graphical/kernel.nix @@ -10,7 +10,7 @@ bunker.kernel = { enable = true; cpuArch = "MZEN3"; - version = "6.18"; + version = "6.19"; hardened = false; lto = "none"; }; diff --git a/graphical/quick-services.nix b/graphical/quick-services.nix index d696f36..89862e3 100644 --- a/graphical/quick-services.nix +++ b/graphical/quick-services.nix @@ -4,24 +4,44 @@ mainUser, ... }: +let + inherit (lib) mkOption types mkIf; + inherit (types) + attrsOf + str + bool + submodule + ; +in scope "options.quick" { - services = - with lib; - mkOption { - type = with types; attrsOf str; - default = { }; - }; - oneShots = - with lib; - mkOption { - type = with types; attrsOf str; - default = { }; - }; + services = mkOption { + type = attrsOf ( + submodule ( + { ... }: + { + options = { + cmd = mkOption { + type = str; + }; + restart = mkOption { + type = bool; + default = false; + }; + }; + } + ) + ); + default = { }; + }; + oneShots = mkOption { + type = attrsOf str; + default = { }; + }; } // scope "config.user.systemd" { enable = true; services = - builtins.mapAttrs (name: cmd: { + builtins.mapAttrs (name: opts: { environment.PATH = lib.mkForce "/run/current-system/sw/bin:/run/current-system/sw/sbin:/etc/profiles/per-user/${mainUser}/bin:/etc/profiles/per-user/${mainUser}/sbin"; unitConfig = { Description = "${name}"; @@ -35,8 +55,8 @@ scope "options.quick" { PartOf = [ "graphical-session.target" ]; }; serviceConfig = { - ExecStart = cmd; - Restart = "always"; + ExecStart = opts.cmd; + Restart = mkIf (opts.restart) "always"; }; wantedBy = [ "graphical-session.target" ]; }) config.quick.services diff --git a/graphical/rice.nix b/graphical/rice.nix index 6ed8bce..56cb013 100644 --- a/graphical/rice.nix +++ b/graphical/rice.nix @@ -7,6 +7,7 @@ ... }: { + environment.sessionVariables.XCURSOR_THEME = config.rice.cursor.name; user.packages = let diff --git a/graphical/startup.nix b/graphical/startup.nix index ba196d9..257eb89 100644 --- a/graphical/startup.nix +++ b/graphical/startup.nix @@ -1,26 +1,27 @@ -{ - pkgs, - lib, - ... -}: -scope "user.systemd.services.startup-sound" -<| { - unitConfig = { - Description = "startup sound"; - Requires = [ - "graphical-session.target" - ]; - After = [ - "graphical-session.target" - "niri.target" - "sound.target" - "shell.service" - ]; - PartOf = [ "graphical-session.target" ]; - }; - serviceConfig = { - ExecStart = "${lib.getExe' pkgs.alsa-utils "aplay"} " + ../assets/startup.wav; - Type = "oneshot"; - }; - wantedBy = [ "graphical-session.target" ]; -} +_: { } +# { +# pkgs, +# lib, +# ... +# }: +# scope "user.systemd.services.startup-sound" +# <| { +# unitConfig = { +# Description = "startup sound"; +# Requires = [ +# "graphical-session.target" +# ]; +# After = [ +# "graphical-session.target" +# "niri.target" +# "sound.target" +# "shell.service" +# ]; +# PartOf = [ "graphical-session.target" ]; +# }; +# serviceConfig = { +# ExecStart = "${lib.getExe' pkgs.alsa-utils "aplay"} " + ../assets/startup.wav; +# Type = "oneshot"; +# }; +# wantedBy = [ "graphical-session.target" ]; +# } diff --git a/graphical/terminal.nix b/graphical/terminal.nix index dccdd61..49e2c1c 100644 --- a/graphical/terminal.nix +++ b/graphical/terminal.nix @@ -1,5 +1,6 @@ { pkgs, + lib, config, ... }: @@ -18,6 +19,12 @@ font-bold = font; font-italic = font; }; + bell = { + system = true; + urgent = true; + notify = true; + visual = true; + }; colors = let pal = config.rice.palette.shortHex; diff --git a/hosts/quiver/hw.nix b/hosts/quiver/hw.nix index 20e7c01..38e57be 100644 --- a/hosts/quiver/hw.nix +++ b/hosts/quiver/hw.nix @@ -13,7 +13,7 @@ nixpkgs.config.cudaSupport = true; hardware.nvidia = { - package = config.boot.kernelPackages.nvidiaPackages.beta; + package = config.boot.kernelPackages.nvidiaPackages.production; modesetting.enable = true; powerManagement.enable = true; open = true; diff --git a/hosts/quiver/input.nix b/hosts/quiver/input.nix index ee8e1eb..9157535 100644 --- a/hosts/quiver/input.nix +++ b/hosts/quiver/input.nix @@ -16,6 +16,6 @@ in }; quick.services = { - ckb-next = "${lib.getExe ckb-next} -c -b"; + ckb-next.cmd = "${lib.getExe ckb-next} -c -b"; }; }