commit 17ac4a4b3b58402f703d82342e6aeab011511a09 Author: James Magahern Date: Sat Jul 18 11:21:00 2026 -0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66cc710 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4a8364 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# Repository Guidelines + +## Purpose and scope + +`hypr-switcher` provides macOS-style application switching and hiding on +Hyprland without compositor patches. Prefer stock Hyprland IPC operations over +changes to the Hyprland fork. A compositor change should only be considered +when the required operation cannot be expressed through the command or event +sockets. + +## Project structure + +- `src/main.rs`: daemon lifecycle, local command socket, switch sessions, GTK + layer-shell HUD, and keyboard handling. +- `src/ipc.rs`: Hyprland command/event sockets and window operations. +- `src/model.rs`: client data, application grouping, address normalization, and + focus history. +- `src/desktop.rs`: XDG desktop-file metadata and icon lookup. +- `../hyprland.conf`: source configuration integration. The live config may be + deployed from another checkout; verify it before editing or reloading. + +Add focused modules under `src/` rather than allowing `main.rs` to absorb +unrelated parsing or model logic. + +## Behavioral invariants + +- The process must remain a daemon. A one-shot switcher cannot retain a useful + MRU history between invocations. +- Group applications exactly as `Client::app_key` does: current class, then + initial class, then title, then address. +- Freeze normal MRU updates while a HUD session is active. Preview focus events + must not mutate the ordering captured at the start of that session. +- Keep application and same-application window cycle lists stable for the + duration of a cycle. Re-sorting after each preview causes two-window + oscillation instead of traversal. +- A selected application raises all of its windows, with the MRU representative + raised and focused last. +- The HUD owns an exclusive layer-shell keyboard grab. Release and hide it + before final focus, retaining the short delay in `Switcher::finish`; removing + that delay produces raised but unfocused windows. +- Hidden applications live in `ipc::HIDDEN_WORKSPACE`, are appended after + visible applications, and are not unhidden merely by previewing them. Restore + them only when the selection is committed. +- Hidden state must remain discoverable from Hyprland client metadata after a + daemon restart; do not make it depend solely on in-memory state. + +## Build and validation + +Before handing off changes, run: + +```sh +cargo fmt --check +cargo test +cargo clippy --workspace --all-targets -- -D warnings +cargo build --release +``` + +Install a tested build with: + +```sh +cargo install --path . --root ~/.local --force +``` + +After installation, restart the existing daemon. For configuration changes, +validate and reload the actual active Hyprland config, then inspect live binds +and `hyprctl configerrors`. + +For runtime testing, use a disposable window/class rather than moving or +hiding the user's real applications. Confirm layer presence through +`hyprctl layers`, focus through `hyprctl activewindow`, and hidden placement +through `hyprctl clients`. + +## Style and tests + +Use Rust 2021 idioms and standard `rustfmt`. Keep IPC errors contextual and log +recoverable runtime failures without crashing the daemon. Add unit tests beside +pure parsing, grouping, and ordering logic. UI and compositor behavior require +proportional runtime checks in a live Hyprland session. + +Preserve unrelated dirty changes in both configuration checkouts. Use +`readlink -f ~/.config/hypr/hyprland.conf` before assuming the workspace copy is +the active one. + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..38dcd15 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1080 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e1e1b1516be3d7ca089dfa6a1e688e268c74aef50c0c25fe8c46b1ba8ed1cc" +dependencies = [ + "bitflags 2.13.1", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3057dc117db2d664a9b45f1956568701914e80cf9f2c8cef0a755af4c1c8105" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hypr-switcher" +version = "0.1.0" +dependencies = [ + "anyhow", + "env_logger", + "glib", + "gtk4", + "gtk4-layer-shell", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..40ecee1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hypr-switcher" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +env_logger = "0.11" +glib = "0.20" +gtk4 = "0.9" +gtk4-layer-shell = "0.4" +log = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb326af --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# hypr-switcher + +A macOS-style application switcher for Hyprland. It runs as a small GTK +layer-shell daemon, tracks focus through Hyprland's event socket, and exposes +commands for application and same-application window switching. + +The application HUD groups windows by Hyprland `class`, orders applications and +their windows by most-recent focus, and resolves names and icons from installed +desktop files. Selecting an application raises all of its windows and focuses +its most-recent window. + +This implementation uses only standard Hyprland IPC. It does not depend on the +custom `cycleapp` or `cycleappwindow` dispatchers. + +## Behavior + +- `Super+Tab` / `Super+Shift+Tab`: show the application HUD and move selection. +- Release `Super` or press `Enter`: accept the selection. +- `Escape`: cancel and restore the original application. +- `Super+grave` / `Super+Shift+grave`: cycle windows in the current application. +- `Super+H`: hide every window in the current application. + +Hidden windows are moved silently to `special:hypr-switcher-hidden`. Their +applications remain visible, dimmed, at the end of the HUD. Selecting a hidden +application restores all of its windows to the current workspace and focuses +its most-recent window. The special workspace also lets the daemon rediscover +hidden applications after a restart. + +The HUD displays applications from the current workspace plus hidden +applications. Normal application switching does not pull visible windows from +other workspaces. + +## Requirements + +- Hyprland with its command and event IPC sockets enabled (the default) +- GTK 4 +- gtk4-layer-shell +- A working icon theme and desktop files under the standard XDG data paths +- Rust and Cargo to build from source + +## Build and install + +Run this from the directory containing this README: + +```sh +cargo test +cargo clippy --workspace --all-targets -- -D warnings +cargo install --path . --root ~/.local --force +``` + +The binary is installed as `~/.local/bin/hypr-switcher`. The accompanying +`../hyprland.conf` starts the daemon and defines the bindings: + +```ini +$switcher = ~/.local/bin/hypr-switcher + +exec-once = $switcher daemon +layerrule = match:namespace ^(hypr-switcher)$, no_anim 1 + +bind = SUPER, Tab, exec, $switcher show +bind = SUPER SHIFT, Tab, exec, $switcher show-previous +bind = SUPER, grave, exec, $switcher window-next +bind = SUPER SHIFT, grave, exec, $switcher window-previous +bind = SUPER, H, exec, $switcher hide-current +``` + +After rebuilding, restart the daemon so the running process uses the new +binary. Reload Hyprland after changing bindings. + +## Commands + +```sh +hypr-switcher daemon +hypr-switcher show +hypr-switcher show-previous +hypr-switcher window-next +hypr-switcher window-previous +hypr-switcher hide-current +hypr-switcher cancel +``` + +Set `RUST_LOG=hypr_switcher=debug` on the daemon command for diagnostics. + +## How it works + +- `src/ipc.rs` talks directly to Hyprland's Unix sockets, listens for focus and + window events, raises/focuses windows, and moves hidden apps. +- `src/model.rs` owns address normalization, window metadata, application + grouping, and MRU ordering. +- `src/desktop.rs` resolves application names and icons from XDG desktop files. +- `src/main.rs` owns the daemon command socket, switch sessions, GTK HUD, and + keyboard handling. + +The daemon freezes MRU updates while the HUD is previewing applications so its +own focus requests do not reorder the active switch session. Final focus is +sent shortly after the layer-shell HUD closes, allowing its exclusive keyboard +grab to be released first. + +## Troubleshooting + +- If commands report that the daemon is unavailable, start + `hypr-switcher daemon` or begin a new Hyprland session so `exec-once` runs. +- If the HUD works from the command line but not from `Super+Tab`, inspect live + bindings with `hyprctl binds` and verify which config is active with + `readlink -f ~/.config/hypr/hyprland.conf`. +- If an icon is missing, compare the Hyprland window `class` from + `hyprctl clients` with the desktop file name or its `StartupWMClass` value. diff --git a/src/desktop.rs b/src/desktop.rs new file mode 100644 index 0000000..a65383b --- /dev/null +++ b/src/desktop.rs @@ -0,0 +1,148 @@ +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Debug)] +pub struct AppMetadata { + pub name: String, + pub icon: String, +} + +#[derive(Default)] +pub struct DesktopRegistry { + by_id: HashMap, + by_startup_class: HashMap, +} + +impl DesktopRegistry { + pub fn load() -> Self { + let mut registry = Self::default(); + for directory in application_directories() { + registry.load_directory(&directory); + } + registry + } + + pub fn lookup(&self, app_key: &str) -> AppMetadata { + let key = app_key.to_ascii_lowercase(); + self.by_id + .get(&key) + .or_else(|| self.by_startup_class.get(&key)) + .cloned() + .unwrap_or_else(|| AppMetadata { + name: humanize(app_key), + icon: app_key.to_string(), + }) + } + + fn load_directory(&mut self, directory: &Path) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("desktop") { + continue; + } + let Some(record) = parse_desktop_file(&path) else { + continue; + }; + let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + let metadata = AppMetadata { + name: record.name, + icon: record.icon, + }; + self.by_id + .entry(stem.to_ascii_lowercase()) + .or_insert_with(|| metadata.clone()); + if let Some(startup_class) = record.startup_class { + self.by_startup_class + .entry(startup_class.to_ascii_lowercase()) + .or_insert(metadata); + } + } + } +} + +struct DesktopRecord { + name: String, + icon: String, + startup_class: Option, +} + +fn parse_desktop_file(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + let mut in_desktop_entry = false; + let mut name = None; + let mut icon = None; + let mut startup_class = None; + + for line in contents.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_desktop_entry = line == "[Desktop Entry]"; + continue; + } + if !in_desktop_entry || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + match key { + "Name" if name.is_none() => name = Some(value.to_string()), + "Icon" if icon.is_none() => icon = Some(value.to_string()), + "StartupWMClass" if startup_class.is_none() => startup_class = Some(value.to_string()), + _ => {} + } + } + + Some(DesktopRecord { + name: name?, + icon: icon.unwrap_or_else(|| "application-x-executable".into()), + startup_class, + }) +} + +fn application_directories() -> Vec { + let mut directories = Vec::new(); + if let Ok(data_home) = env::var("XDG_DATA_HOME") { + directories.push(PathBuf::from(data_home).join("applications")); + } else if let Ok(home) = env::var("HOME") { + directories.push(PathBuf::from(home).join(".local/share/applications")); + } + + let data_dirs = + env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into()); + directories.extend( + data_dirs + .split(':') + .filter(|directory| !directory.is_empty()) + .map(|directory| PathBuf::from(directory).join("applications")), + ); + directories +} + +fn humanize(app_key: &str) -> String { + app_key + .rsplit(['.', '/']) + .next() + .unwrap_or(app_key) + .replace(['-', '_'], " ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_humanize_reverse_dns_app_id() { + assert_eq!(humanize("org.gnome.Nautilus"), "Nautilus"); + assert_eq!(humanize("some-app"), "some app"); + } +} diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..7c21aef --- /dev/null +++ b/src/ipc.rs @@ -0,0 +1,161 @@ +use std::{ + env, + io::{BufRead, BufReader, Read, Write}, + os::unix::net::UnixStream, + path::PathBuf, + thread, +}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::model::{normalize_address, AppGroup, Client, WorkspaceRef}; + +pub const HIDDEN_WORKSPACE: &str = "special:hypr-switcher-hidden"; + +#[derive(Clone, Debug)] +pub enum HyprEvent { + ActiveWindow(String), + CloseWindow(String), + Refresh, +} + +#[derive(Debug, Deserialize)] +pub struct ActiveWindow { + pub address: String, + pub workspace: WorkspaceRef, +} + +pub fn runtime_dir() -> Result { + let runtime = env::var("XDG_RUNTIME_DIR").context("XDG_RUNTIME_DIR is not set")?; + let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE") + .context("HYPRLAND_INSTANCE_SIGNATURE is not set; is Hyprland running?")?; + Ok(PathBuf::from(runtime).join("hypr").join(signature)) +} + +pub fn command_socket_path() -> Result { + Ok(runtime_dir()?.join("hypr-switcher.sock")) +} + +fn request(command: &str) -> Result { + log::trace!("Hyprland IPC request: {command}"); + let mut stream = UnixStream::connect(runtime_dir()?.join(".socket.sock")) + .context("connecting to Hyprland's command socket")?; + stream.write_all(command.as_bytes())?; + stream.shutdown(std::net::Shutdown::Write)?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) +} + +pub fn clients() -> Result> { + let response = request("j/clients")?; + serde_json::from_str(&response).context("decoding Hyprland clients response") +} + +pub fn active_window() -> Result { + let response = request("j/activewindow")?; + serde_json::from_str(&response).context("decoding Hyprland active window response") +} + +pub fn active_workspace() -> Result { + let response = request("j/activeworkspace")?; + serde_json::from_str(&response).context("decoding Hyprland active workspace response") +} + +fn dispatch(dispatcher: &str, argument: &str) -> Result<()> { + let response = request(&format!("dispatch {dispatcher} {argument}"))?; + if response.trim() == "ok" { + Ok(()) + } else { + anyhow::bail!("Hyprland rejected {dispatcher}: {}", response.trim()) + } +} + +pub fn activate_app(group: &AppGroup) -> Result<()> { + let representative = group + .representative() + .context("selected application has no windows")?; + + // Raise least-recent windows first so the representative remains on top. + for window in group.windows.iter().rev() { + let address = normalize_address(&window.address); + dispatch("alterzorder", &format!("top,address:{address}"))?; + } + + let address = normalize_address(&representative.address); + dispatch("focuswindow", &format!("address:{address}")) +} + +pub fn activate_window(window: &Client) -> Result<()> { + let address = normalize_address(&window.address); + dispatch("alterzorder", &format!("top,address:{address}"))?; + dispatch("focuswindow", &format!("address:{address}")) +} + +pub fn move_app_to_workspace(group: &AppGroup, workspace: &str) -> Result<()> { + for window in &group.windows { + let address = normalize_address(&window.address); + dispatch( + "movetoworkspacesilent", + &format!("{workspace},address:{address}"), + )?; + } + Ok(()) +} + +pub fn spawn_event_listener(sender: std::sync::mpsc::Sender) -> Result<()> { + let event_socket = runtime_dir()?.join(".socket2.sock"); + thread::Builder::new() + .name("hypr-events".into()) + .spawn(move || loop { + match UnixStream::connect(&event_socket) { + Ok(stream) => { + let reader = BufReader::new(stream); + for line in reader.lines() { + let Ok(line) = line else { break }; + if let Some(event) = parse_event(&line) { + if sender.send(event).is_err() { + return; + } + } + } + } + Err(error) => log::warn!("Hyprland event socket unavailable: {error}"), + } + thread::sleep(std::time::Duration::from_millis(500)); + })?; + Ok(()) +} + +fn parse_event(line: &str) -> Option { + let (name, data) = line.trim_end().split_once(">>")?; + match name { + "activewindowv2" => Some(HyprEvent::ActiveWindow(normalize_address(data))), + "closewindow" => Some(HyprEvent::CloseWindow(normalize_address(data))), + "openwindow" | "movewindowv2" | "workspacev2" | "windowtitlev2" => Some(HyprEvent::Refresh), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_active_window_event() { + assert!(matches!( + parse_event("activewindowv2>>ABCD\n"), + Some(HyprEvent::ActiveWindow(address)) if address == "0xabcd" + )); + } + + #[test] + fn test_parse_close_window_event() { + assert!(matches!( + parse_event("closewindow>>0x123\n"), + Some(HyprEvent::CloseWindow(address)) if address == "0x123" + )); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..65f67ec --- /dev/null +++ b/src/main.rs @@ -0,0 +1,659 @@ +mod desktop; +mod ipc; +mod model; + +use std::{ + cell::RefCell, + env, fs, + io::Write, + os::unix::net::{UnixListener, UnixStream}, + path::Path, + rc::Rc, + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use desktop::DesktopRegistry; +use glib::{ControlFlow, Propagation}; +use gtk::{gdk, pango}; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4_layer_shell::{self as layer, LayerShell}; +use ipc::HyprEvent; +use model::{normalize_address, AppGroup, FocusHistory}; + +#[derive(Clone, Copy, Debug)] +enum Direction { + Next, + Previous, +} + +#[derive(Clone, Debug)] +enum DaemonMessage { + Show(Direction), + CycleWindow(Direction), + HideCurrent, + Cancel, + Hypr(HyprEvent), +} + +struct Session { + groups: Vec, + selected: usize, + original: usize, + destination_workspace: String, +} + +struct RuntimeState { + history: FocusHistory, + session: Option, + window_cycle: Option, + ignore_focus_until: Option, +} + +struct WindowCycleSession { + app_key: String, + addresses: Vec, + index: usize, + updated_at: Instant, +} + +struct Switcher { + window: gtk::ApplicationWindow, + row: gtk::Box, + registry: DesktopRegistry, + state: RefCell, +} + +impl Switcher { + fn new(application: >k::Application) -> Result> { + let clients = ipc::clients().unwrap_or_default(); + let mut history = FocusHistory::default(); + history.seed(&clients); + + let window = gtk::ApplicationWindow::new(application); + window.set_decorated(false); + window.set_resizable(false); + window.init_layer_shell(); + window.set_namespace("hypr-switcher"); + window.set_layer(layer::Layer::Overlay); + window.set_keyboard_mode(layer::KeyboardMode::None); + + let frame = gtk::Frame::new(None); + frame.add_css_class("switcher-frame"); + + let row = gtk::Box::new(gtk::Orientation::Horizontal, 10); + row.set_margin_top(14); + row.set_margin_bottom(14); + row.set_margin_start(14); + row.set_margin_end(14); + + let scroller = gtk::ScrolledWindow::new(); + scroller.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Never); + scroller.set_propagate_natural_width(true); + scroller.set_max_content_width(980); + scroller.set_child(Some(&row)); + frame.set_child(Some(&scroller)); + window.set_child(Some(&frame)); + + install_css(); + + Ok(Rc::new(Self { + window, + row, + registry: DesktopRegistry::load(), + state: RefCell::new(RuntimeState { + history, + session: None, + window_cycle: None, + ignore_focus_until: None, + }), + })) + } + + fn install_key_controller(self: &Rc) { + let controller = gtk::EventControllerKey::new(); + + let switcher = Rc::clone(self); + controller.connect_key_pressed(move |_controller, key, _keycode, modifiers| match key { + gdk::Key::Tab | gdk::Key::ISO_Left_Tab => { + let backwards = key == gdk::Key::ISO_Left_Tab + || modifiers.contains(gdk::ModifierType::SHIFT_MASK); + switcher.cycle(if backwards { + Direction::Previous + } else { + Direction::Next + }); + Propagation::Stop + } + gdk::Key::Escape => { + switcher.finish(false); + Propagation::Stop + } + gdk::Key::Return | gdk::Key::KP_Enter => { + switcher.finish(true); + Propagation::Stop + } + _ => Propagation::Proceed, + }); + + let switcher = Rc::clone(self); + controller.connect_key_released(move |_controller, key, _keycode, _modifiers| { + if matches!( + key, + gdk::Key::Super_L | gdk::Key::Super_R | gdk::Key::Meta_L | gdk::Key::Meta_R + ) { + switcher.finish(true); + } + }); + + self.window.add_controller(controller); + } + + fn handle_message(&self, message: DaemonMessage) { + match message { + DaemonMessage::Show(direction) => self.show(direction), + DaemonMessage::CycleWindow(direction) => self.cycle_window(direction), + DaemonMessage::HideCurrent => self.hide_current_app(), + DaemonMessage::Cancel => self.finish(false), + DaemonMessage::Hypr(event) => self.handle_hypr_event(event), + } + } + + fn handle_hypr_event(&self, event: HyprEvent) { + let mut state = self.state.borrow_mut(); + match event { + HyprEvent::ActiveWindow(address) => { + let ignoring = state.session.is_some() + || state + .ignore_focus_until + .is_some_and(|deadline| deadline > Instant::now()); + if !ignoring { + state.ignore_focus_until = None; + state.history.touch(&address); + } + } + HyprEvent::CloseWindow(address) => state.history.remove(&address), + HyprEvent::Refresh => {} + } + } + + fn show(&self, direction: Direction) { + if self.state.borrow().session.is_some() { + self.cycle(direction); + return; + } + + let Ok(clients) = ipc::clients() else { + log::error!("cannot show switcher: failed to read Hyprland clients"); + return; + }; + + let active = ipc::active_window().ok(); + let workspace = active + .as_ref() + .map(|window| window.workspace.clone()) + .or_else(|| ipc::active_workspace().ok()); + let Some(workspace) = workspace else { + log::error!("cannot show switcher: Hyprland has no active workspace"); + return; + }; + + let active_address = active + .as_ref() + .map(|window| normalize_address(&window.address)) + .unwrap_or_default(); + let mut state = self.state.borrow_mut(); + if !active_address.is_empty() { + state.history.touch(&active_address); + } + let mut groups = state.history.groups_for_workspace(&clients, workspace.id); + let visible_count = groups.len(); + + if let Some(hidden_workspace_id) = clients + .iter() + .find(|client| client.workspace.name == ipc::HIDDEN_WORKSPACE) + .map(|client| client.workspace.id) + { + let mut hidden_groups = state + .history + .groups_for_workspace(&clients, hidden_workspace_id); + hidden_groups.retain(|hidden| !groups.iter().any(|group| group.key == hidden.key)); + for group in &mut hidden_groups { + group.hidden = true; + } + groups.extend(hidden_groups); + } + + if groups.is_empty() || (groups.len() == 1 && visible_count == 1) { + log::debug!("not showing switcher: no other visible or hidden applications"); + return; + } + + let original = groups + .iter() + .position(|group| { + group + .windows + .iter() + .any(|window| normalize_address(&window.address) == active_address) + }) + .unwrap_or(0); + let selected = match (active_address.is_empty(), direction) { + (true, Direction::Next) => 0, + (true, Direction::Previous) => groups.len() - 1, + (false, Direction::Next) => (original + 1) % groups.len(), + (false, Direction::Previous) => (original + groups.len() - 1) % groups.len(), + }; + state.session = Some(Session { + groups, + selected, + original, + destination_workspace: workspace.name, + }); + drop(state); + + self.render(); + self.window + .set_keyboard_mode(layer::KeyboardMode::Exclusive); + self.window.present(); + self.activate_selected(); + } + + fn cycle(&self, direction: Direction) { + let mut state = self.state.borrow_mut(); + let Some(session) = state.session.as_mut() else { + return; + }; + let length = session.groups.len(); + session.selected = match direction { + Direction::Next => (session.selected + 1) % length, + Direction::Previous => (session.selected + length - 1) % length, + }; + drop(state); + self.render(); + self.activate_selected(); + } + + fn activate_selected(&self) { + let selected = self + .state + .borrow() + .session + .as_ref() + .map(|session| session.groups[session.selected].clone()); + if let Some(group) = selected { + if group.hidden { + return; + } + if let Err(error) = ipc::activate_app(&group) { + log::error!("failed to activate {}: {error:#}", group.key); + } + } + } + + fn hide_current_app(&self) { + let Ok(active) = ipc::active_window() else { + return; + }; + let Ok(clients) = ipc::clients() else { + return; + }; + let active_address = normalize_address(&active.address); + let Some(active_client) = clients + .iter() + .find(|client| normalize_address(&client.address) == active_address) + else { + return; + }; + let app_key = active_client.app_key(); + let windows = clients + .iter() + .filter(|client| { + client.mapped + && client.app_key() == app_key + && client.workspace.name != ipc::HIDDEN_WORKSPACE + }) + .cloned() + .collect::>(); + if windows.is_empty() { + return; + } + + let group = AppGroup { + key: app_key.clone(), + windows, + hidden: false, + }; + let next_app = { + let state = self.state.borrow(); + state + .history + .groups_for_workspace(&clients, active.workspace.id) + .into_iter() + .find(|candidate| candidate.key != app_key) + }; + + if let Err(error) = ipc::move_app_to_workspace(&group, ipc::HIDDEN_WORKSPACE) { + log::error!("failed to hide {app_key}: {error:#}"); + return; + } + if let Some(next_app) = next_app { + if let Err(error) = ipc::activate_app(&next_app) { + log::error!("failed to focus after hiding {app_key}: {error:#}"); + } + } + } + + fn cycle_window(&self, direction: Direction) { + let Ok(active) = ipc::active_window() else { + return; + }; + let Ok(clients) = ipc::clients() else { + return; + }; + let active_address = normalize_address(&active.address); + + let (target_address, app_key) = { + let mut state = self.state.borrow_mut(); + state.history.touch(&active_address); + let groups = state + .history + .groups_for_workspace(&clients, active.workspace.id); + let Some(group) = groups.iter().find(|group| { + group + .windows + .iter() + .any(|window| normalize_address(&window.address) == active_address) + }) else { + return; + }; + if group.windows.len() < 2 { + return; + } + + let fresh_addresses: Vec = group + .windows + .iter() + .map(|window| normalize_address(&window.address)) + .collect(); + let can_continue = state.window_cycle.as_ref().is_some_and(|cycle| { + cycle.app_key == group.key + && cycle.updated_at.elapsed() < Duration::from_millis(1200) + && cycle.addresses.len() == fresh_addresses.len() + && cycle.addresses.contains(&active_address) + && cycle.addresses[cycle.index] == active_address + }); + + let cycle = if can_continue { + state.window_cycle.as_mut().expect("checked above") + } else { + let index = fresh_addresses + .iter() + .position(|address| address == &active_address) + .unwrap_or(0); + state.window_cycle = Some(WindowCycleSession { + app_key: group.key.clone(), + addresses: fresh_addresses, + index, + updated_at: Instant::now(), + }); + state.window_cycle.as_mut().expect("just inserted") + }; + + cycle.index = match direction { + Direction::Next => (cycle.index + 1) % cycle.addresses.len(), + Direction::Previous => { + (cycle.index + cycle.addresses.len() - 1) % cycle.addresses.len() + } + }; + cycle.updated_at = Instant::now(); + (cycle.addresses[cycle.index].clone(), cycle.app_key.clone()) + }; + + if let Some(target) = clients + .iter() + .find(|client| normalize_address(&client.address) == target_address) + { + if let Err(error) = ipc::activate_window(target) { + log::error!("failed to cycle a window in {app_key}: {error:#}"); + } + } + } + + fn finish(&self, commit: bool) { + let (target, destination_workspace) = { + let mut state = self.state.borrow_mut(); + let Some(session) = state.session.take() else { + return; + }; + let target_index = if commit { + session.selected + } else { + session.original + }; + let target = session.groups[target_index].clone(); + if let Some(window) = target.representative() { + state.history.touch(&window.address); + } + state.ignore_focus_until = Some(Instant::now() + Duration::from_millis(200)); + (target, session.destination_workspace) + }; + + self.window.set_keyboard_mode(layer::KeyboardMode::None); + self.window.hide(); + // The layer surface releases its exclusive keyboard focus + // asynchronously. Wait for that round-trip before focusing the + // selected toplevel, otherwise Hyprland raises it without granting + // keyboard focus. + glib::timeout_add_local_once(Duration::from_millis(30), move || { + if target.hidden { + if let Err(error) = ipc::move_app_to_workspace(&target, &destination_workspace) { + log::error!("failed to unhide {}: {error:#}", target.key); + return; + } + } + if let Err(error) = ipc::activate_app(&target) { + log::error!("failed to finalize {}: {error:#}", target.key); + } + }); + } + + fn render(&self) { + while let Some(child) = self.row.first_child() { + self.row.remove(&child); + } + + let state = self.state.borrow(); + let Some(session) = state.session.as_ref() else { + return; + }; + + for (index, group) in session.groups.iter().enumerate() { + let metadata = self.registry.lookup(&group.key); + let item = gtk::Box::new(gtk::Orientation::Vertical, 7); + item.add_css_class("switcher-item"); + if group.hidden { + item.add_css_class("hidden"); + } + if index == session.selected { + item.add_css_class("selected"); + } + + let image = if Path::new(&metadata.icon).is_absolute() { + gtk::Image::from_file(&metadata.icon) + } else { + gtk::Image::from_icon_name(&metadata.icon) + }; + image.set_pixel_size(64); + image.set_size_request(76, 76); + + let title = gtk::Label::new(Some(&metadata.name)); + title.add_css_class("switcher-title"); + title.set_ellipsize(pango::EllipsizeMode::End); + title.set_max_width_chars(14); + title.set_tooltip_text(Some(&metadata.name)); + + item.append(&image); + item.append(&title); + self.row.append(&item); + } + } +} + +fn install_css() { + let provider = gtk::CssProvider::new(); + provider.load_from_data( + " + window { background: transparent; } + .switcher-frame { + background: rgba(31, 31, 34, 0.94); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 18px; + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.48); + } + .switcher-item { + min-width: 96px; + padding: 9px 8px 7px 8px; + border: 2px solid transparent; + border-radius: 13px; + } + .switcher-item.selected { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.72); + } + .switcher-item.hidden { opacity: 0.52; } + .switcher-item.hidden.selected { opacity: 0.82; } + .switcher-title { + color: rgba(255, 255, 255, 0.94); + font-size: 12px; + font-weight: 500; + } + ", + ); + if let Some(display) = gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} + +fn run_daemon() -> Result<()> { + let (sender, receiver) = mpsc::channel::(); + let receiver = Rc::new(RefCell::new(Some(receiver))); + ipc::spawn_event_listener({ + let sender = sender.clone(); + let (hypr_sender, hypr_receiver) = mpsc::channel(); + thread::spawn(move || { + while let Ok(event) = hypr_receiver.recv() { + if sender.send(DaemonMessage::Hypr(event)).is_err() { + break; + } + } + }); + hypr_sender + })?; + spawn_command_server(sender)?; + + let application = gtk::Application::builder() + .application_id("net.buzzert.HyprSwitcher") + .build(); + application.connect_activate(move |application| { + let Some(receiver) = receiver.borrow_mut().take() else { + return; + }; + let switcher = match Switcher::new(application) { + Ok(switcher) => switcher, + Err(error) => { + log::error!("failed to initialize switcher: {error:#}"); + application.quit(); + return; + } + }; + switcher.install_key_controller(); + + let switcher_for_messages = Rc::clone(&switcher); + glib::timeout_add_local(Duration::from_millis(10), move || { + while let Ok(message) = receiver.try_recv() { + switcher_for_messages.handle_message(message); + } + ControlFlow::Continue + }); + + // Keep a hidden application window alive for the daemon's lifetime. + switcher.window.set_visible(false); + }); + application.run_with_args(&["hypr-switcher"]); + Ok(()) +} + +fn spawn_command_server(sender: mpsc::Sender) -> Result<()> { + let path = ipc::command_socket_path()?; + if path.exists() { + if UnixStream::connect(&path).is_ok() { + anyhow::bail!("another hypr-switcher daemon is already running"); + } + fs::remove_file(&path).context("removing stale switcher socket")?; + } + + let listener = UnixListener::bind(&path).context("binding switcher command socket")?; + thread::Builder::new() + .name("switcher-commands".into()) + .spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut command = String::new(); + if std::io::Read::read_to_string(&mut stream, &mut command).is_err() { + continue; + } + let message = match command.trim() { + "show" => Some(DaemonMessage::Show(Direction::Next)), + "show-previous" => Some(DaemonMessage::Show(Direction::Previous)), + "window-next" => Some(DaemonMessage::CycleWindow(Direction::Next)), + "window-previous" => Some(DaemonMessage::CycleWindow(Direction::Previous)), + "hide-current" => Some(DaemonMessage::HideCurrent), + "cancel" => Some(DaemonMessage::Cancel), + _ => None, + }; + if let Some(message) = message { + if sender.send(message).is_err() { + break; + } + } + } + })?; + Ok(()) +} + +fn send_command(command: &str) -> Result<()> { + let path = ipc::command_socket_path()?; + let mut stream = UnixStream::connect(&path).with_context(|| { + format!( + "connecting to {}; start `hypr-switcher daemon` first", + path.display() + ) + })?; + stream.write_all(command.as_bytes())?; + Ok(()) +} + +fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + + match env::args().nth(1).as_deref() { + Some("daemon") => run_daemon(), + Some("show-previous") => send_command("show-previous"), + Some("window-next") => send_command("window-next"), + Some("window-previous") => send_command("window-previous"), + Some("hide-current") => send_command("hide-current"), + Some("cancel") => send_command("cancel"), + Some("show") | None => send_command("show"), + Some(command) => anyhow::bail!("unknown command: {command}"), + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..08986fc --- /dev/null +++ b/src/model.rs @@ -0,0 +1,201 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct WorkspaceRef { + pub id: i32, + #[serde(default)] + pub name: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Client { + pub address: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub class: String, + #[serde(default, rename = "initialClass")] + pub initial_class: String, + pub workspace: WorkspaceRef, + #[serde(default)] + pub mapped: bool, + #[serde(default)] + pub hidden: bool, + #[serde(default, rename = "focusHistoryID")] + pub focus_history_id: i64, +} + +impl Client { + pub fn app_key(&self) -> String { + if !self.class.is_empty() { + self.class.clone() + } else if !self.initial_class.is_empty() { + self.initial_class.clone() + } else if !self.title.is_empty() { + self.title.clone() + } else { + self.address.clone() + } + } +} + +#[derive(Clone, Debug)] +pub struct AppGroup { + pub key: String, + pub windows: Vec, + pub hidden: bool, +} + +impl AppGroup { + pub fn representative(&self) -> Option<&Client> { + self.windows.first() + } +} + +#[derive(Default, Debug)] +pub struct FocusHistory { + addresses: VecDeque, +} + +impl FocusHistory { + pub fn seed(&mut self, clients: &[Client]) { + let mut sorted = clients.to_vec(); + sorted.sort_by_key(|client| client.focus_history_id); + for client in sorted.into_iter().rev() { + self.touch(&client.address); + } + } + + pub fn touch(&mut self, address: &str) { + let address = normalize_address(address); + if address.is_empty() { + return; + } + + self.addresses.retain(|candidate| candidate != &address); + self.addresses.push_front(address); + self.addresses.truncate(256); + } + + pub fn remove(&mut self, address: &str) { + let address = normalize_address(address); + self.addresses.retain(|candidate| candidate != &address); + } + + pub fn rank(&self, address: &str) -> usize { + let address = normalize_address(address); + self.addresses + .iter() + .position(|candidate| candidate == &address) + .unwrap_or(usize::MAX) + } + + pub fn groups_for_workspace(&self, clients: &[Client], workspace_id: i32) -> Vec { + let mut eligible: Vec = clients + .iter() + .filter(|client| client.mapped && !client.hidden && client.workspace.id == workspace_id) + .cloned() + .collect(); + + eligible.sort_by_key(|client| { + let rank = self.rank(&client.address); + if rank == usize::MAX { + (usize::MAX, client.focus_history_id.max(0) as usize) + } else { + (rank, 0) + } + }); + + let mut by_app: HashMap> = HashMap::new(); + let mut app_order = Vec::new(); + let mut seen_apps = HashSet::new(); + + for client in eligible { + let key = client.app_key(); + if seen_apps.insert(key.clone()) { + app_order.push(key.clone()); + } + by_app.entry(key).or_default().push(client); + } + + app_order + .into_iter() + .filter_map(|key| { + by_app.remove(&key).map(|windows| AppGroup { + key, + windows, + hidden: false, + }) + }) + .collect() + } +} + +pub fn normalize_address(address: &str) -> String { + let address = address.trim(); + if address.is_empty() { + String::new() + } else if address.starts_with("0x") { + address.to_ascii_lowercase() + } else { + format!("0x{}", address.to_ascii_lowercase()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn client(address: &str, class: &str, focus_history_id: i64) -> Client { + Client { + address: address.into(), + title: String::new(), + class: class.into(), + initial_class: String::new(), + workspace: WorkspaceRef { + id: 1, + name: "1".into(), + }, + mapped: true, + hidden: false, + focus_history_id, + } + } + + #[test] + fn test_normalize_address() { + assert_eq!(normalize_address("ABC"), "0xabc"); + assert_eq!(normalize_address("0xAbC"), "0xabc"); + assert_eq!(normalize_address(""), ""); + } + + #[test] + fn test_groups_follow_window_mru_and_deduplicate_apps() { + let clients = vec![ + client("0x1", "browser", 2), + client("0x2", "editor", 1), + client("0x3", "browser", 0), + ]; + let mut history = FocusHistory::default(); + history.seed(&clients); + + let groups = history.groups_for_workspace(&clients, 1); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].key, "browser"); + assert_eq!(groups[0].windows[0].address, "0x3"); + assert_eq!(groups[1].key, "editor"); + } + + #[test] + fn test_touch_promotes_an_app_through_its_window() { + let clients = vec![client("0x1", "browser", 0), client("0x2", "editor", 1)]; + let mut history = FocusHistory::default(); + history.seed(&clients); + history.touch("0x2"); + + let groups = history.groups_for_workspace(&clients, 1); + assert_eq!(groups[0].key, "editor"); + } +}