jay, many thing

This commit is contained in:
atagen 2026-03-09 13:58:20 +11:00
parent d4d04d7d6d
commit 6e62eccfba
32 changed files with 5580 additions and 333 deletions

View file

@ -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="
];
}

View file

@ -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;
}

View file

@ -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 [

154
flake.lock generated
View file

@ -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": {

View file

@ -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";
};
}

View file

@ -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"}";
}

View file

@ -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 (

74
graphical/desktop/jay.nix Normal file
View file

@ -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
'';
};
};
}

View file

@ -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; }
}

View file

@ -0,0 +1,196 @@
From c46dbd6689b1a69e23068334e784b53d1bccebb0 Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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<Self>) {
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<String>,
pub bar_position: Option<BarPosition>,
pub bar_separator_width: Option<i32>,
+ pub gap: Option<i32>,
}
#[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

View file

@ -0,0 +1,576 @@
From bb20a9359b5f520aa3d52803ce4e7fc565c74a93 Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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<bool>,
compute_render_positions_scheduled: Cell<bool>,
render_titles_scheduled: Cell<bool>,
- num_children: NumCell<usize>,
+ pub num_children: NumCell<usize>,
pub children: LinkedList<ContainerChild>,
focus_history: LinkedList<NodeRef<ContainerChild>>,
child_nodes: RefCell<AHashMap<NodeId, LinkedNode<ContainerChild>>>,
@@ -158,6 +160,7 @@ pub struct ContainerChild {
pub body: Cell<Rect>,
pub content: Cell<Rect>,
factor: Cell<f64>,
+ pub body_animation: Cell<Option<PositionAnimation>>,
}
#[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<Rect> {
+ 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<Self>, node: Rc<dyn ToplevelNode>, 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<BarPosition>,
pub bar_separator_width: Option<i32>,
pub gap: Option<i32>,
+ pub animation_duration: Option<i32>,
}
#[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

View file

@ -0,0 +1,201 @@
From 595b88b6a1d321086f33e213f86e80055e26bbe9 Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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<Rect>,
factor: Cell<f64>,
pub body_animation: Cell<Option<PositionAnimation>>,
+ pub border_rects: RefCell<SmallVec<[Rect; 4]>>,
+ pub border_color_is_focused: Cell<bool>,
}
#[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

View file

@ -0,0 +1,545 @@
From 4555defec92f59b019f116b3fae0d914f171eea2 Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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<ConfigBarPosition> for BarPosition {
}
}
+impl TryFrom<ConfigAnimationMode> for AnimationMode {
+ type Error = ();
+
+ fn try_from(value: ConfigAnimationMode) -> Result<Self, Self::Error> {
+ let v = match value {
+ ConfigAnimationMode::Concurrent => Self::Concurrent,
+ ConfigAnimationMode::Sequential => Self::Sequential,
+ _ => return Err(()),
+ };
+ Ok(v)
+ }
+}
+
+impl Into<ConfigAnimationMode> for AnimationMode {
+ fn into(self) -> ConfigAnimationMode {
+ match self {
+ AnimationMode::Concurrent => ConfigAnimationMode::Concurrent,
+ AnimationMode::Sequential => ConfigAnimationMode::Sequential,
+ }
+ }
+}
+
impl Into<ConfigBarPosition> for BarPosition {
fn into(self) -> ConfigBarPosition {
match self {
@@ -547,6 +571,13 @@ impl Into<ConfigBarPosition> 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<String>,
pub show_titles: Cell<bool>,
pub bar_position: Cell<BarPosition>,
+ pub animation_mode: Cell<AnimationMode>,
}
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<i32>,
pub gap: Option<i32>,
pub animation_duration: Option<i32>,
+ pub animation_mode: Option<AnimationMode>,
}
#[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<Persistent
if let Some(v) = config.theme.bar_position {
set_bar_position(v);
}
+ if let Some(v) = config.theme.animation_mode {
+ set_animation_mode(v);
+ }
if let Some(v) = config.focus_history {
if let Some(v) = v.only_visible {
persistent.seat.focus_history_set_only_visible(v);
--
2.53.0

View file

@ -0,0 +1,276 @@
From 986b7638b94c45b5cd4526a4f2cc66f8091508af Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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<Option<Rc<dyn InputMethodKeyboardGrab>>>,
forward: Cell<bool>,
focus_follows_mouse: Cell<bool>,
+ cursor_follows_focus: Cell<bool>,
fallback_output_mode: Cell<FallbackOutputMode>,
swipe_bindings: PerClientBindings<ZwpPointerGestureSwipeV1>,
pinch_bindings: PerClientBindings<ZwpPointerGesturePinchV1>,
@@ -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<Self>, 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<Duration>,
pub explicit_sync_enabled: Option<bool>,
pub focus_follows_mouse: bool,
+ pub cursor_follows_focus: bool,
pub window_management_key: Option<ModifiedKeySym>,
pub vrr: Option<Vrr>,
pub tearing: Option<Tearing>,
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<Persistent
true => 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

View file

@ -0,0 +1,199 @@
From 05e9f39292784d9aa6295517c1aa3ca20fad0e46 Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
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

View file

@ -0,0 +1,975 @@
From 424a39fb0a9f3d84a483fc99f7edf274536627d8 Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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<ConfigSpawnAnimation> for SpawnAnimation {
+ type Error = ();
+
+ fn try_from(value: ConfigSpawnAnimation) -> Result<Self, Self::Error> {
+ 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<ConfigSpawnAnimation> 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<bool>,
pub bar_position: Cell<BarPosition>,
pub animation_mode: Cell<AnimationMode>,
+ pub spawn_animation: Cell<SpawnAnimation>,
}
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<bool>,
}
+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<WorkspaceNode>,
child: Rc<dyn ToplevelNode>,
split: ContainerSplit,
+ initial_child_body: Option<Rect>,
) -> Rc<Self> {
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<SmallMapMut<Scale, TextTexture, 2>>,
cursors: RefCell<AHashMap<CursorType, CursorState>>,
pub attention_requested: Cell<bool>,
+ pub body_animation: Cell<Option<PositionAnimation>>,
}
#[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<State>, tl: Rc<dyn ToplevelNode>, 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<State>, tl: Rc<dyn ToplevelNode>, 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<u32>,
pub ext_workspaces: CopyHashMap<WorkspaceManagerId, Rc<ExtWorkspaceHandleV1>>,
+ pub dying_children: RefCell<Vec<DyingChild>>,
pub opt: Rc<Opt<WorkspaceNode>>,
}
@@ -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<i32>,
pub animation_duration: Option<i32>,
pub animation_mode: Option<AnimationMode>,
+ pub spawn_animation: Option<SpawnAnimation>,
}
#[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<Persistent
if let Some(v) = config.theme.animation_mode {
set_animation_mode(v);
}
+ if let Some(v) = config.theme.spawn_animation {
+ set_spawn_animation(v);
+ }
if let Some(v) = config.focus_history {
if let Some(v) = v.only_visible {
persistent.seat.focus_history_set_only_visible(v);
--
2.53.0

View file

@ -0,0 +1,90 @@
From 0197b7939940df165d0123e49a274572dbedf827 Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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<Self>, active: bool) {
if self.active.replace(active) != active {
self.schedule_render_titles();
+ if active {
+ self.restack();
+ }
}
}
--
2.53.0

View file

@ -0,0 +1,566 @@
From f3aa1b1577a71792bc566989aad7b4319516495e Mon Sep 17 00:00:00 2001
From: atagen <boss@atagen.co>
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 <noreply@anthropic.com>
---
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<ConfigSpawnAnimation> for SpawnAnimation {
}
}
+#[derive(Copy, Clone, Debug, Default, PartialEq)]
+pub enum AnimationCurve {
+ Linear,
+ EaseIn,
+ #[default]
+ EaseOut,
+ EaseInOut,
+ CubicBezier(f64, f64, f64, f64),
+}
+
+impl From<ConfigAnimationCurve> 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<ConfigAnimationCurve> 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<BarPosition>,
pub animation_mode: Cell<AnimationMode>,
pub spawn_animation: Cell<SpawnAnimation>,
+ pub animation_curve: Cell<AnimationCurve>,
}
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<State>, tl: Rc<dyn ToplevelNode>, 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<i32>,
pub animation_mode: Option<AnimationMode>,
pub spawn_animation: Option<SpawnAnimation>,
+ pub animation_curve: Option<AnimationCurve>,
}
#[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<AnimationCurve> {
+ 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<Persistent
if let Some(v) = config.theme.spawn_animation {
set_spawn_animation(v);
}
+ if let Some(v) = config.theme.animation_curve {
+ set_animation_curve(v);
+ }
if let Some(v) = config.focus_history {
if let Some(v) = v.only_visible {
persistent.seat.focus_history_set_only_visible(v);
--
2.53.0

File diff suppressed because it is too large Load diff

View file

@ -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` | 01000 |
**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` | 05000 |
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` | 0100 |
Set to `0` for square corners.
**Example:**
```toml
[theme]
gap = 10
corner-radius = 8
```

View file

@ -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 {

View file

@ -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";

View file

@ -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";
};
};
}

View file

@ -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 ];
}

View file

@ -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;
}

View file

@ -10,7 +10,7 @@
bunker.kernel = {
enable = true;
cpuArch = "MZEN3";
version = "6.18";
version = "6.19";
hardened = false;
lto = "none";
};

View file

@ -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

View file

@ -7,6 +7,7 @@
...
}:
{
environment.sessionVariables.XCURSOR_THEME = config.rice.cursor.name;
user.packages =
let

View file

@ -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" ];
# }

View file

@ -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;

View file

@ -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;

View file

@ -16,6 +16,6 @@ in
};
quick.services = {
ckb-next = "${lib.getExe ckb-next} -c -b";
ckb-next.cmd = "${lib.getExe ckb-next} -c -b";
};
}