From d8bdb68b2af7ec020567ce1ba58f940f2dd8277a Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 13 Oct 2026 23:15:44 +0100 Subject: [PATCH] Add native shell launcher with themed search and browsing --- Cargo.lock | 554 +++++++++++++- compositor/src/backends/direct.rs | 3 +- compositor/src/config.rs | 10 +- compositor/src/config/bindings.rs | 7 + compositor/src/handlers/window_capture.rs | 2 +- compositor/src/input.rs | 3 + compositor/src/ipc.rs | 8 + compositor/src/session_lock.rs | 1 + compositor/src/shell_control.rs | 103 ++- compositor/src/state/navigation.rs | 3 - .../src/state/window_lifecycle_tests.rs | 39 + crates/ferese-theme/assets/icons/apps.svg | 1 + crates/ferese-theme/assets/icons/folder.svg | 1 + crates/ferese-theme/assets/icons/search.svg | 1 + crates/ferese-theme/assets/icons/window.svg | 1 + crates/ferese-theme/src/icons.rs | 4 + docs/configuration/bindings.md | 2 +- docs/shortcuts.md | 12 + feresectl/src/cli.rs | 13 + feresectl/tests/cli.rs | 15 + packaging/arch/PKGBUILD.in | 2 +- packaging/arch/ferese-git/.SRCINFO | 1 + packaging/arch/ferese-git/PKGBUILD | 2 +- packaging/fedora/ferese.spec.in | 2 +- protocols/ferese-shell-v1.xml | 35 +- scripts/tests/test_shell_motion_isolated.py | 17 + shell/ferese-shell/Cargo.toml | 2 + shell/ferese-shell/src/config.rs | 9 + shell/ferese-shell/src/control.rs | 63 +- shell/ferese-shell/src/launcher/apps.rs | 161 +++++ shell/ferese-shell/src/launcher/files.rs | 160 +++++ shell/ferese-shell/src/launcher/icons.rs | 40 ++ shell/ferese-shell/src/launcher/launch.rs | 135 ++++ shell/ferese-shell/src/launcher/mod.rs | 675 ++++++++++++++++++ shell/ferese-shell/src/launcher/sources.rs | 297 ++++++++ shell/ferese-shell/src/launcher/tests.rs | 180 +++++ shell/ferese-shell/src/launcher/thumbnails.rs | 83 +++ shell/ferese-shell/src/launcher/view.rs | 531 ++++++++++++++ shell/ferese-shell/src/launcher/work.rs | 15 + shell/ferese-shell/src/main.rs | 31 +- shell/ferese-shell/src/motion.rs | 194 +++-- shell/ferese-shell/src/surfaces.rs | 47 +- shell/ferese-shell/src/system_modal.rs | 1 + 43 files changed, 3351 insertions(+), 115 deletions(-) create mode 100644 crates/ferese-theme/assets/icons/apps.svg create mode 100644 crates/ferese-theme/assets/icons/folder.svg create mode 100644 crates/ferese-theme/assets/icons/search.svg create mode 100644 crates/ferese-theme/assets/icons/window.svg create mode 100644 shell/ferese-shell/src/launcher/apps.rs create mode 100644 shell/ferese-shell/src/launcher/files.rs create mode 100644 shell/ferese-shell/src/launcher/icons.rs create mode 100644 shell/ferese-shell/src/launcher/launch.rs create mode 100644 shell/ferese-shell/src/launcher/mod.rs create mode 100644 shell/ferese-shell/src/launcher/sources.rs create mode 100644 shell/ferese-shell/src/launcher/tests.rs create mode 100644 shell/ferese-shell/src/launcher/thumbnails.rs create mode 100644 shell/ferese-shell/src/launcher/view.rs create mode 100644 shell/ferese-shell/src/launcher/work.rs diff --git a/Cargo.lock b/Cargo.lock index c178a25..7c6ee1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -568,6 +568,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -617,6 +626,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block" version = "0.1.6" @@ -742,6 +764,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -1032,6 +1060,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cookie-factory" version = "0.3.3" @@ -1129,11 +1163,11 @@ dependencies = [ "atomicwrites", "cosmic-config-derive", "cosmic-settings-daemon", - "dirs", + "dirs 6.0.0", "futures-util", "iced_futures", "known-folders", - "notify", + "notify 8.2.0", "ron", "serde", "tokio", @@ -1220,7 +1254,7 @@ dependencies = [ "configparser", "cosmic-config", "csscolorparser", - "dirs", + "dirs 6.0.0", "hex_color", "palette", "ron", @@ -1256,6 +1290,43 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1312,7 +1383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168dbdf4dced8f57fa4246ac9080598452b49d70bb2d0e46422142f113140dd8" dependencies = [ "num-traits", - "phf", + "phf 0.13.1", "serde", "uncased", ] @@ -1404,6 +1475,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_setters" version = "0.1.9" @@ -1448,13 +1525,34 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1465,7 +1563,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1534,6 +1632,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "doxygen-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" +dependencies = [ + "phf 0.11.3", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1624,6 +1731,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.18.0" @@ -1755,7 +1868,7 @@ dependencies = [ "jiff", "libc", "memmap2 0.9.11", - "notify", + "notify 8.2.0", "serde", "serde_json", "smithay", @@ -1892,7 +2005,7 @@ dependencies = [ "iced_accessibility", "image", "libcosmic", - "notify", + "notify 8.2.0", "serde_json", "shlex 1.3.0", "tempfile", @@ -1920,12 +2033,14 @@ dependencies = [ "ferese-protocols", "ferese-theme", "ferese-theme-client", + "fff-search", "freedesktop-desktop-entry", "iced_accessibility", "image", "jiff", "libc", "libcosmic", + "mime_guess", "pipewire", "serde", "serde_json", @@ -1987,6 +2102,83 @@ dependencies = [ "tempfile", ] +[[package]] +name = "fff-grep" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a741baa9142c1cbe7df7948d93a809296cd3a28f5c117964a0048b4711258" +dependencies = [ + "bstr", + "memchr", +] + +[[package]] +name = "fff-notify-debouncer-full" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a4ebea7b8a2840cd59358bbf396f6f04313ce8eae84ac79703ce80298b8731" +dependencies = [ + "file-id", + "log", + "notify 9.0.0-rc.5", + "notify-types", + "rustc-hash 2.1.3", + "walkdir", +] + +[[package]] +name = "fff-query-parser" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1aa71c8268c77b2b7d9982eeaa6aa42abb22eaaaa3b72b1b636946c72a8982" + +[[package]] +name = "fff-search" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a80ed5bdfa89cc81cb314374a4460da880d68e4c81d9574a587f28757a9c943e" +dependencies = [ + "ahash", + "aho-corasick", + "blake3", + "dirs 5.0.1", + "dunce", + "fff-grep", + "fff-notify-debouncer-full", + "fff-query-parser", + "git2", + "glidesort", + "globset", + "heed", + "ignore", + "libc", + "memchr", + "memmap2 0.9.11", + "neo_frizbee", + "notify 9.0.0-rc.5", + "parking_lot", + "pathdiff", + "rayon", + "regex", + "regex-syntax", + "serde", + "signal-hook-registry", + "smallvec", + "thiserror 2.0.21", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "find-crate" version = "0.6.3" @@ -2407,6 +2599,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "git2" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libgit2-sys", + "log", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2468,12 +2672,31 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + [[package]] name = "glob" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "glow" version = "0.16.0" @@ -2657,6 +2880,44 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heed" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "heed-traits", + "heed-types", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "serde", + "synchronoise", + "url", +] + +[[package]] +name = "heed-traits" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" + +[[package]] +name = "heed-types" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d" +dependencies = [ + "bincode", + "byteorder", + "heed-traits", + "serde", + "serde_json", +] + [[package]] name = "hermit-abi" version = "0.3.9" @@ -3122,6 +3383,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -3551,7 +3828,7 @@ dependencies = [ "jiff", "log", "palette", - "phf", + "phf 0.13.1", "rfd", "roxmltree", "rust-embed", @@ -3566,6 +3843,18 @@ dependencies = [ "zbus", ] +[[package]] +name = "libgit2-sys" +version = "0.18.8+1.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3651,6 +3940,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libz-sys" +version = "1.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lilt" version = "0.8.2" @@ -3702,6 +4003,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lmdb-master-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" +dependencies = [ + "cc", + "doxygen-rs", + "libc", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -3991,6 +4303,15 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "neo_frizbee" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb0dd0e69b342eaddb657120a21a56f3e887ddc505fac780cf430dbbbb3aed26" +dependencies = [ + "serde", +] + [[package]] name = "nix" version = "0.31.3" @@ -4040,6 +4361,26 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "notify" +version = "9.0.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7768eb7201a1964d8eb10224e850df046ab584ee3c3d26e340a993d81f748148" +dependencies = [ + "bitflags 2.13.2", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "objc2-core-foundation", + "objc2-core-services", + "walkdir", + "windows-sys 0.61.2", + "xxhash-rust", +] + [[package]] name = "notify-types" version = "2.1.0" @@ -4058,6 +4399,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.47" @@ -4266,6 +4613,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-core-video" version = "0.3.2" @@ -4510,6 +4867,16 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "palette" version = "0.7.7" @@ -4519,7 +4886,7 @@ dependencies = [ "approx 0.5.1", "palette_derive", "palette_math", - "phf", + "phf 0.13.1", "serde", ] @@ -4582,23 +4949,49 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -4606,7 +4999,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4615,14 +5021,23 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", "uncased", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -4789,6 +5204,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4995,6 +5416,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.37.0" @@ -5054,6 +5495,17 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5818,6 +6270,12 @@ dependencies = [ "zeno", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.119" @@ -5840,6 +6298,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synchronoise" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" +dependencies = [ + "crossbeam-queue", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -5962,6 +6429,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -6154,6 +6651,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.21", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -6453,6 +6963,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -7736,7 +8252,7 @@ name = "xdg-desktop-portal-ferese" version = "0.1.0-beta" dependencies = [ "calloop 0.14.4", - "dirs", + "dirs 6.0.0", "ferese-config", "ferese-ipc", "ferese-protocols", @@ -7819,6 +8335,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + [[package]] name = "yansi" version = "1.0.1" diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 8335104..ac3ebdd 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -184,6 +184,7 @@ impl DirectBackendState { window: &smithay::desktop::Window, output: &Output, cursor: Option>, + scale: f64, ) -> Result { let device = self .devices @@ -194,7 +195,7 @@ impl DirectBackendState { &mut device.renderer, window, window.geometry(), - output.current_scale().fractional_scale(), + scale, cursor.map(|rect| (state, rect)), ) } diff --git a/compositor/src/config.rs b/compositor/src/config.rs index a4907ab..bdd3537 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -1214,7 +1214,7 @@ mod tests { let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 62); + assert_eq!(bindings.len(), 63); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -1225,6 +1225,10 @@ mod tests { && binding.action == action)); } assert!(bindings.iter().any(|binding| binding.action == BindingAction::Exit)); + assert!(bindings.iter().any(|binding| binding.modifiers.logo + && !binding.modifiers.shift + && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_space) + && binding.action == BindingAction::ToggleLauncher)); assert!(bindings.iter().any(|binding| binding.modifiers.logo && binding.modifiers.shift && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_t) @@ -1262,7 +1266,7 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 62); + assert_eq!(bindings.len(), 63); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into()) @@ -1270,7 +1274,7 @@ mod tests { let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 61); + assert_eq!(bindings.len(), 62); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); } diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs index 755d970..6715f63 100644 --- a/compositor/src/config/bindings.rs +++ b/compositor/src/config/bindings.rs @@ -378,6 +378,10 @@ pub(super) fn parse_action( no_argument()?; Ok(BindingAction::ToggleDisplayMode) } + "toggle-launcher" => { + no_argument()?; + Ok(BindingAction::ToggleLauncher) + } "toggle-keybinding-guide" => { no_argument()?; Ok(BindingAction::ToggleKeybindingGuide) @@ -405,6 +409,7 @@ pub(super) fn parse_workspace(argument: &str) -> Result { pub(super) fn default_bindings() -> Vec { let mut bindings = vec![ + binding("Super+Space", "toggle-launcher", None), binding("Super+Enter", "spawn", Some("terminal")), binding("Super+Shift+S", "spawn", Some("screenshot")), binding("Print", "spawn", Some("screenshot-full")), @@ -524,6 +529,7 @@ pub enum BindingAction { ToggleFloating, ToggleOverview, ToggleKeybindingGuide, + ToggleLauncher, ToggleDisplayMode, Media(&'static str), } @@ -648,6 +654,7 @@ impl Binding { .into(), BindingAction::ToggleOverview => "Open or close overview".into(), BindingAction::ToggleDisplayMode => "Open display mode chooser".into(), + BindingAction::ToggleLauncher => "Open or close launcher".into(), BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(), }; let mut parts = Vec::new(); diff --git a/compositor/src/handlers/window_capture.rs b/compositor/src/handlers/window_capture.rs index 65561cc..508ea2f 100644 --- a/compositor/src/handlers/window_capture.rs +++ b/compositor/src/handlers/window_capture.rs @@ -330,7 +330,7 @@ impl Ferese { ) } else { let mut backend = self.direct_backend.take()?; - let result = backend.capture_window_frame(self, &window, &output, cursor); + let result = backend.capture_window_frame(self, &window, &output, cursor, scale); self.direct_backend = Some(backend); result }; diff --git a/compositor/src/input.rs b/compositor/src/input.rs index cbfec6c..468c4e7 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -1267,6 +1267,9 @@ impl Ferese { BindingAction::ToggleDisplayMode => { self.toggle_display_mode(); } + BindingAction::ToggleLauncher => { + self.toggle_launcher(); + } BindingAction::ToggleKeybindingGuide => { self.toggle_keybinding_guide(); } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index d1518e6..3f6a6ec 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -1083,6 +1083,14 @@ impl Ferese { "toggle-display-mode" => { self.toggle_display_mode(); } + "toggle-launcher" => { + if !self.toggle_launcher() { + return Err(CommandError::new( + "unavailable", + "Launcher requires an unlocked session and an updated Ferese shell", + )); + } + } "toggle-keybinding-guide" => { if !self.toggle_keybinding_guide() { return Err(CommandError::new( diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 28368ab..9648ce6 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -427,6 +427,7 @@ impl SessionLockHandler for Ferese { } fn lock(&mut self, confirmation: SessionLocker) { + self.dismiss_launcher(); if !self.session_lock.lifecycle.can_acquire() { return; } diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 402e81b..5979af0 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities; use crate::state::ClientState; pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(9, ()); + display.create_global::(10, ()); } pub(crate) fn config_chunks(source: &str) -> Vec<&str> { @@ -98,6 +98,18 @@ impl Dispatch for Ferese { return; } match request { + ferese_shell_v1::Request::Thumbnail { + token, + window_hi, + window_lo, + } => { + if let Some((width, height, file)) = state.shell_thumbnail(join_id(window_hi, window_lo)) { + use std::os::fd::AsFd; + shell.thumbnail(token, window_hi, window_lo, width, height, file.as_fd()); + } else { + shell.thumbnail_unavailable(token, window_hi, window_lo); + } + } ferese_shell_v1::Request::ActivateWindow { window_hi, window_lo } => state.handle_shell_window_request( shell, ferese_shell_v1::FailedRequest::ActivateWindow, @@ -246,6 +258,22 @@ impl Ferese { true } + pub(crate) fn toggle_launcher(&mut self) -> bool { + if self.session_lock.active() { + return false; + } + let Some(shell) = self + .shell_resources + .iter() + .filter_map(|shell| shell.upgrade().ok()) + .find(|shell| shell.version() >= 10) + else { + return false; + }; + shell.toggle_launcher(self.focused_output().map_or_else(String::new, |output| output.name())); + true + } + pub(crate) fn toggle_keybinding_guide(&mut self) -> bool { if self.session_lock.active() { return false; @@ -888,3 +916,76 @@ mod tests { } } } + +impl Ferese { + pub(crate) fn dismiss_launcher(&self) { + for shell in self + .shell_resources + .iter() + .filter_map(|s| s.upgrade().ok()) + .filter(|s| s.version() >= 10) + { + shell.dismiss_launcher(); + } + } + + fn shell_thumbnail(&mut self, id: WindowId) -> Option<(u32, u32, std::fs::File)> { + use std::io::{Seek, Write}; + if self.session_lock.active() { + return None; + } + let window = self + .windows + .ids() + .iter() + .find(|(_, candidate)| **candidate == id)? + .0 + .clone(); + if self.capture_protected(&window) { + return None; + } + let workspace = self.workspaces.workspace_for_window(id)?; + let owner = self.output_workspaces.output_for_workspace(workspace)?; + let output = self + .output_ids + .iter() + .find(|(_, candidate)| **candidate == owner)? + .0 + .clone(); + let geometry = window.geometry(); + if geometry.size.w <= 0 || geometry.size.h <= 0 { + return None; + } + let scale = (320. / f64::from(geometry.size.w)) + .min(168. / f64::from(geometry.size.h)) + .min(1.); + let result = if let Some(backend) = self.nested_backend.clone() { + let mut backend = backend.try_borrow_mut().ok()?; + crate::render::capture_window_frame(backend.renderer(), &window, geometry, scale, None) + } else { + let mut backend = self.direct_backend.take()?; + let result = backend.capture_window_frame(self, &window, &output, None, scale); + self.direct_backend = Some(backend); + result + }; + let mut buffer = result.ok()?; + if buffer.width <= 0 || buffer.width > 320 || buffer.height <= 0 || buffer.height > 168 { + return None; + } + // GL ABGR readback is RGBA bytes on the supported little-endian targets. + // Iced expects straight rather than premultiplied channels. + for pixel in buffer.pixels.chunks_exact_mut(4) { + let alpha = u16::from(pixel[3]); + for channel in &mut pixel[..3] { + *channel = (u16::from(*channel) * 255 + alpha / 2) + .checked_div(alpha) + .unwrap_or(0) + .min(255) as u8; + } + } + let mut file = tempfile::tempfile().ok()?; + file.write_all(&buffer.pixels).ok()?; + file.rewind().ok()?; + Some((buffer.width as u32, buffer.height as u32, file)) + } +} diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 8130002..57907db 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -647,9 +647,6 @@ impl Ferese { let Some(output_id) = self.output_workspaces.output_for_workspace(destination) else { return false; }; - if self.output_workspaces.output_for_workspace(source) != Some(output_id) { - return false; - } if source == destination { return true; } diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index d851481..9366e4f 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -1178,3 +1178,42 @@ fn tab_indicator_yields_to_a_floating_window_and_disappears_with_one_tab() { assert!(state.scrolling_tab_bar(a).is_none()); assert!(state.tab_indicator.hover.is_none()); } + +#[test] +fn shell_can_move_a_window_to_the_current_workspace_on_another_output() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::shell_can_move_a_window_to_the_current_workspace_on_another_output", + ) { + return; + } + let (mut events, mut state, _) = fixture(); + let (window, _wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + let source = state.workspaces.workspace_for_window(id).unwrap(); + let second = Output::new( + "second".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + second.change_current_state( + Some(smithay::output::Mode { + size: (800, 600).into(), + refresh: 60_000, + }), + None, + None, + None, + ); + state.space.map_output(&second, (800, 0)); + state.register_output(&second, "second".into()); + let output = state.output_ids[&second]; + let destination = state.output_workspaces.active_workspace(output).unwrap(); + assert_ne!(source, destination); + assert!(state.move_managed_window_to_workspace(id, destination)); + assert_eq!(state.workspaces.workspace_for_window(id), Some(destination)); + assert!(state.move_managed_window_to_workspace(id, destination)); +} diff --git a/crates/ferese-theme/assets/icons/apps.svg b/crates/ferese-theme/assets/icons/apps.svg new file mode 100644 index 0000000..f527307 --- /dev/null +++ b/crates/ferese-theme/assets/icons/apps.svg @@ -0,0 +1 @@ + diff --git a/crates/ferese-theme/assets/icons/folder.svg b/crates/ferese-theme/assets/icons/folder.svg new file mode 100644 index 0000000..2d9fcf3 --- /dev/null +++ b/crates/ferese-theme/assets/icons/folder.svg @@ -0,0 +1 @@ + diff --git a/crates/ferese-theme/assets/icons/search.svg b/crates/ferese-theme/assets/icons/search.svg new file mode 100644 index 0000000..28bc7be --- /dev/null +++ b/crates/ferese-theme/assets/icons/search.svg @@ -0,0 +1 @@ + diff --git a/crates/ferese-theme/assets/icons/window.svg b/crates/ferese-theme/assets/icons/window.svg new file mode 100644 index 0000000..4edfa38 --- /dev/null +++ b/crates/ferese-theme/assets/icons/window.svg @@ -0,0 +1 @@ + diff --git a/crates/ferese-theme/src/icons.rs b/crates/ferese-theme/src/icons.rs index 62c413d..355df5d 100644 --- a/crates/ferese-theme/src/icons.rs +++ b/crates/ferese-theme/src/icons.rs @@ -30,6 +30,10 @@ pub const DISPLAY_EXTEND: &[u8] = include_bytes!("../assets/icons/display-extend pub const DISPLAY_MIRROR: &[u8] = include_bytes!("../assets/icons/display-mirror.svg"); pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/display.svg"); pub const FERESE: &[u8] = include_bytes!("../../../assets/branding/ferese-symbolic.svg"); +pub const SEARCH: &[u8] = include_bytes!("../assets/icons/search.svg"); +pub const APPS: &[u8] = include_bytes!("../assets/icons/apps.svg"); +pub const WINDOW: &[u8] = include_bytes!("../assets/icons/window.svg"); +pub const FOLDER: &[u8] = include_bytes!("../assets/icons/folder.svg"); pub const OVERVIEW: &[u8] = include_bytes!("../assets/icons/overview.svg"); pub const AIRPLANE: &[u8] = include_bytes!("../assets/icons/status/airplane.svg"); pub const BATTERY_25: &[u8] = include_bytes!("../assets/icons/status/battery-25.svg"); diff --git a/docs/configuration/bindings.md b/docs/configuration/bindings.md index 2b7e6d1..c96bf82 100644 --- a/docs/configuration/bindings.md +++ b/docs/configuration/bindings.md @@ -30,7 +30,7 @@ invalid. A disabled binding must omit `action` and `argument`. | `focus-floating` | None; focus the last focused window in the opposite tiled/floating layer on this workspace | | `focus-mru-next`, `focus-mru-previous` | None; cycle windows in focus order | | `none` | None; ignore this trigger | -| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-column-tabbed`, `leave-tab`, `next-tab`, `previous-tab`, `toggle-floating`, `toggle-overview` | None | +| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-column-tabbed`, `leave-tab`, `next-tab`, `previous-tab`, `toggle-floating`, `toggle-overview`, `toggle-launcher` | None | ```kdl commands { diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 5e09b6e..33986a3 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -6,6 +6,18 @@ guide. Your saved bindings can override these defaults. The [example config](../packaging/config.kdl) adds the app, media and arrow-key shortcuts listed below. +## Launcher + +**Super + Space** opens the launcher. Type to search, press **Tab** to accept +completion, or **Enter** to open the selected match. **Down** shows alternatives. +The **Apps**, **Files** and **Windows** tiles browse each source separately. + +**Escape** clears the query, returns from a browse view, then closes the launcher, +one step per press. In Windows, **Alt + Enter** moves the selected window to the +current workspace. Power off, restart and logout also require **Alt + Enter**. + +Run `feresectl launcher toggle` to open or close it from a script. + ## Move and resize with the mouse | Action | Result | diff --git a/feresectl/src/cli.rs b/feresectl/src/cli.rs index 6e5a29a..55e2d9d 100644 --- a/feresectl/src/cli.rs +++ b/feresectl/src/cli.rs @@ -19,6 +19,11 @@ pub struct Cli { #[derive(Debug, Subcommand)] pub enum Command { + /// Show or hide the application launcher. + Launcher { + #[command(subcommand)] + action: LauncherAction, + }, /// Publish bounded activity data owned by the invoking shell. Activity { #[command(subcommand)] @@ -235,10 +240,18 @@ fn value_name(value: &impl ValueEnum) -> String { .to_owned() } +#[derive(Debug, Subcommand)] +pub enum LauncherAction { + Toggle, +} + impl Command { pub fn ipc(&self) -> Option<(&'static str, Value)> { let (command, args) = match self { Self::Activity { .. } | Self::Run { .. } => return None, + Self::Launcher { + action: LauncherAction::Toggle, + } => ("toggle-launcher", json!({})), Self::FocusedWindow => ("get-focused-window", json!({})), Self::Windows => ("get-windows", json!({})), Self::IdleInhibition => ("get-idle-inhibition", json!({})), diff --git a/feresectl/tests/cli.rs b/feresectl/tests/cli.rs index 6ec9c04..81e0a87 100644 --- a/feresectl/tests/cli.rs +++ b/feresectl/tests/cli.rs @@ -38,6 +38,7 @@ fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() { for args in [ &["--help"][..], &["theme", "--help"], + &["launcher", "--help"], &["media", "--help"], &["xwayland", "--help"], &["screenshot", "--help"], @@ -470,3 +471,17 @@ sys.exit(19) .unwrap(); assert!(elapsed < 1.5, "stdout reader stalled the child for {elapsed}s"); } + +#[test] +fn launcher_toggle_uses_the_shell_command() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ipc.sock"); + let server = serve(&path, "toggle-launcher", json!({})); + success( + ctl(&["launcher", "toggle"]) + .env("FERESE_SOCKET", &path) + .output() + .unwrap(), + ); + server.join().unwrap(); +} diff --git a/packaging/arch/PKGBUILD.in b/packaging/arch/PKGBUILD.in index 89ec0f6..4c8c66c 100644 --- a/packaging/arch/PKGBUILD.in +++ b/packaging/arch/PKGBUILD.in @@ -7,7 +7,7 @@ url='https://github.com/ferese-wm/ferese' license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception') depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm' 'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire' - 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') + 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk' 'glib2') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils') checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection' diff --git a/packaging/arch/ferese-git/.SRCINFO b/packaging/arch/ferese-git/.SRCINFO index a610861..e6a373a 100644 --- a/packaging/arch/ferese-git/.SRCINFO +++ b/packaging/arch/ferese-git/.SRCINFO @@ -52,6 +52,7 @@ pkgbase = ferese-git depends = gstreamer depends = xdg-desktop-portal depends = xdg-desktop-portal-gtk + depends = glib2 optdepends = brightnessctl: brightness controls optdepends = slurp: screenshot region selection optdepends = satty: screenshot editing diff --git a/packaging/arch/ferese-git/PKGBUILD b/packaging/arch/ferese-git/PKGBUILD index b9add9b..690f573 100644 --- a/packaging/arch/ferese-git/PKGBUILD +++ b/packaging/arch/ferese-git/PKGBUILD @@ -7,7 +7,7 @@ url='https://github.com/ferese-wm/ferese' license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception') depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm' 'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire' - 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') + 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk' 'glib2') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils') checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection' diff --git a/packaging/fedora/ferese.spec.in b/packaging/fedora/ferese.spec.in index 534796d..7f8c07c 100644 --- a/packaging/fedora/ferese.spec.in +++ b/packaging/fedora/ferese.spec.in @@ -23,7 +23,7 @@ BuildRequires: mesa-dri-drivers mesa-vulkan-drivers BuildRequires: pipewire-devel gstreamer1-devel dbus-daemon desktop-file-utils polkit-libs pam BuildRequires: xorg-x11-xauth Requires: pam polkit polkit-libs foot pipewire wireplumber python3 bash util-linux dbus-daemon dbus-tools systemd -Requires: xdg-desktop-portal xdg-desktop-portal-gtk +Requires: xdg-desktop-portal xdg-desktop-portal-gtk glib2 # Loaded at runtime, so ELF dependency detection does not find these libraries. Requires: libEGL.so.1()(64bit) fontconfig freetype Recommends: brightnessctl slurp wl-clipboard satty diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index d0f1b77..5834fd5 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the shell-control capability. It does not assign a Wayland surface role. @@ -35,7 +35,7 @@ - + Window, workspace, and output identifiers are unsigned 64-bit values encoded as high and low 32-bit words. Each snapshot atomically replaces @@ -213,5 +213,36 @@ + + + + + + + Only available on the private shell connection. Returns straight RGBA8 + pixels in an anonymous file, at most 320 by 168 pixels. Protected + surfaces and locked sessions are never captured. The token identifies + the invocation; the shell must discard replies from closed invocations. + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/tests/test_shell_motion_isolated.py b/scripts/tests/test_shell_motion_isolated.py index f4ed3ac..f1a83ff 100644 --- a/scripts/tests/test_shell_motion_isolated.py +++ b/scripts/tests/test_shell_motion_isolated.py @@ -223,6 +223,23 @@ class ShellMotionTest(unittest.TestCase): "notification materials did not follow their fade") print(f"Toast: {opening_frames} opening callback requests, {idle_frames / 2:g} settled commits/s; destroyed", flush=True) + subprocess.run([str(ctl), "launcher", "toggle"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + launcher = wait_for(lambda: layer_surface("ferese-launcher")) + wait_for(lambda: frames(launcher) >= 4) + time.sleep(1.5) + if capture_path := os.environ.get("FERESE_TEST_LAUNCHER_IMAGE"): + with open(capture_path, "wb") as capture: + subprocess.run([str(ctl), "screenshot"], env=ipc_environment(env), stdout=capture, + stderr=subprocess.PIPE, check=True, timeout=10) + before = commits(launcher) + time.sleep(1) + self.assertLessEqual(commits(launcher) - before, 10, "settled launcher kept animating") + before_close = frames(launcher) + subprocess.run([str(ctl), "launcher", "toggle"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + wait_for(lambda: destroyed(launcher), timeout=5) + self.assertGreater(frames(launcher), before_close, "launcher did not animate its close") + print("Launcher: animated open and close, idle timer stopped, surface destroyed", flush=True) + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) modal = wait_for(lambda: layer_surface("ferese-system-modal")) wait_for(lambda: frames(modal) >= 4) diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 467ea58..6296592 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true rust-version.workspace = true [dependencies] +fff-search = "=0.11.0" +mime_guess = "2.0.5" ferese-activities = { path = "../../crates/ferese-activities" } ferese-layout = { path = "../../crates/ferese-layout" } ferese-panel = { path = "../../crates/ferese-panel" } diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index fd51587..0a172e9 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; #[derive(Clone, Debug)] pub(crate) struct ShellConfig { + pub(crate) terminal: Vec, pub(crate) activities: ferese_config::activities::Settings, pub(crate) notifications: ferese_config::notifications::NotificationConfig, pub(crate) animations: crate::motion::Settings, @@ -23,6 +24,7 @@ impl Default for ShellConfig { fn default() -> Self { let status = StatusConfig::default(); Self { + terminal: vec!["foot".into()], activities: Default::default(), notifications: Default::default(), animations: Default::default(), @@ -185,6 +187,8 @@ pub(crate) enum WallpaperMode { #[derive(Debug, Default, Deserialize)] struct FereseConfig { + #[serde(default)] + commands: std::collections::HashMap>, #[serde(default)] activities: ferese_config::activities::Settings, #[serde(default)] @@ -404,6 +408,11 @@ fn parse_document( theme.bar_radius = theme.material_radius; Ok(ShellConfig { + terminal: config + .commands + .get("terminal") + .cloned() + .unwrap_or_else(|| vec!["foot".into()]), activities: config.activities, notifications: config.notifications, animations: config.animations, diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 599a954..43d2247 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -85,6 +85,9 @@ pub(crate) enum ControlCommand { Logout(u32, String), CancelLogout(u32), ToggleGuide(String), + ToggleLauncher(String), + DismissLauncher, + Thumbnail(u32, u64, Option), ToggleDisplays(String), MonitorsChanged, } @@ -128,6 +131,9 @@ enum ControlUpdate { Logout(u32, String), LogoutCancelled(u32), ToggleGuide(String), + ToggleLauncher(String), + DismissLauncher, + Thumbnail(u32, u64, Option), ToggleDisplays(String), MonitorsChanged, } @@ -137,7 +143,7 @@ impl ShellControl { let connection = control_connection()?; let (globals, mut queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 7..=9, ())?; + let manager = globals.bind::(&qh, 7..=10, ())?; let shell = manager.get_shell(&qh, ()); let (sender, updates) = mpsc::channel(); let wake = Wake(Arc::new(tokio::sync::Notify::new())); @@ -181,6 +187,11 @@ impl ShellControl { Ok(ControlUpdate::Logout(serial, output)) => poll.commands.push(ControlCommand::Logout(serial, output)), Ok(ControlUpdate::ToggleDisplays(output)) => poll.commands.push(ControlCommand::ToggleDisplays(output)), Ok(ControlUpdate::MonitorsChanged) => poll.commands.push(ControlCommand::MonitorsChanged), + Ok(ControlUpdate::DismissLauncher) => poll.commands.push(ControlCommand::DismissLauncher), + Ok(ControlUpdate::Thumbnail(token, window, image)) => { + poll.commands.push(ControlCommand::Thumbnail(token, window, image)) + } + Ok(ControlUpdate::ToggleLauncher(output)) => poll.commands.push(ControlCommand::ToggleLauncher(output)), Ok(ControlUpdate::ToggleGuide(output)) => poll.commands.push(ControlCommand::ToggleGuide(output)), Ok(ControlUpdate::LogoutCancelled(serial)) => poll.commands.push(ControlCommand::CancelLogout(serial)), Ok(ControlUpdate::Config(source)) => poll.config = Some(source), @@ -195,6 +206,15 @@ impl ShellControl { } } + pub(crate) fn thumbnail(&self, token: u32, window: u64) { + if self.shell.version() < 10 { + return; + } + let (hi, lo) = split_id(window); + self.shell.thumbnail(token, hi, lo); + let _ = self.connection.flush(); + } + pub(crate) fn move_window_to_workspace(&self, window: u64, workspace: u64) { if self.shell.version() < 8 { return; @@ -318,6 +338,47 @@ impl Dispatch for ControlState { _qh: &QueueHandle, ) { match event { + ferese_shell_v1::Event::DismissLauncher => { + let _ = state.sender.send(ControlUpdate::DismissLauncher); + } + ferese_shell_v1::Event::ThumbnailUnavailable { + token, + window_hi, + window_lo, + } => { + let _ = state + .sender + .send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), None)); + } + ferese_shell_v1::Event::Thumbnail { + token, + window_hi, + window_lo, + width, + height, + pixels, + } => { + use std::io::Read; + let image = (|| { + if width == 0 || width > 320 || height == 0 || height > 168 { + return None; + } + let mut file = std::fs::File::from(pixels); + let size = (width * height * 4) as usize; + if file.metadata().ok()?.len() != size as u64 { + return None; + } + let mut bytes = vec![0; size]; + file.read_exact(&mut bytes).ok()?; + Some(cosmic::widget::image::Handle::from_rgba(width, height, bytes)) + })(); + let _ = state + .sender + .send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), image)); + } + ferese_shell_v1::Event::ToggleLauncher { output_name } => { + let _ = state.sender.send(ControlUpdate::ToggleLauncher(output_name)); + } ferese_shell_v1::Event::ConfigBegin => state.config = Some(String::new()), ferese_shell_v1::Event::ConfigChunk { source } => { append_config_chunk(&mut state.config, &source); diff --git a/shell/ferese-shell/src/launcher/apps.rs b/shell/ferese-shell/src/launcher/apps.rs new file mode 100644 index 0000000..4a66975 --- /dev/null +++ b/shell/ferese-shell/src/launcher/apps.rs @@ -0,0 +1,161 @@ +use super::sources::{Matcher, ResultId, Row, Source, Target}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub(crate) struct Apps { + rows: Vec, + normalized_names: Vec, + by_id: HashMap, +} + +#[derive(Default, Deserialize, Serialize)] +struct Usage { + count: u64, + last: u64, +} +fn state_path() -> Option { + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state"))) + .map(|path| path.join("ferese/launcher-apps.json")) +} +fn history() -> HashMap { + state_path() + .and_then(|path| { + use std::io::Read; + let mut bytes = Vec::new(); + std::fs::File::open(path) + .ok()? + .take(1024 * 1024 + 1) + .read_to_end(&mut bytes) + .ok()?; + (bytes.len() <= 1024 * 1024).then_some(bytes) + }) + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +impl Apps { + fn entry(&self, app_id: &str) -> Option<&Row> { + self.by_id + .get(&app_id.trim_end_matches(".desktop").to_ascii_lowercase()) + .map(|index| &self.rows[*index]) + } + pub(super) fn name(&self, app_id: &str) -> Option<&str> { + self.entry(app_id).map(|row| row.title.as_str()) + } + pub(super) fn icon(&self, app_id: &str) -> Option { + self.entry(app_id)?.icon.clone() + } + pub(super) fn empty() -> Self { + Self { + rows: Vec::new(), + normalized_names: Vec::new(), + by_id: HashMap::new(), + } + } + pub(super) fn load(cancel: &super::work::Cancellation) -> Self { + use freedesktop_desktop_entry as fde; + let locales = fde::get_languages_from_env(); + let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_else(|_| "Ferese".into()); + let desktops: Vec<_> = desktops.split(':').collect(); + let mut seen = HashSet::new(); + let usage = history(); + let now = now(); + let rows: Vec = fde::Iter::new(fde::default_paths()) + .entries(Some(&locales)) + .take_while(|_| !cancel.cancelled()) + .filter(|entry| seen.insert(entry.appid.clone())) + .filter(|entry| !entry.hidden() && !entry.no_display()) + .filter(|entry| { + entry + .only_show_in() + .is_none_or(|names| names.iter().any(|name| desktops.contains(name))) + }) + .filter(|entry| { + entry + .not_show_in() + .is_none_or(|names| !names.iter().any(|name| desktops.contains(name))) + }) + .filter_map(|entry| { + let title = entry.name(&locales)?.into_owned(); + let score = usage.get(&entry.appid).map_or(0., |usage| { + let age = now.saturating_sub(usage.last) as f64 / 86400.; + (usage.count as f64).ln_1p() * 8. + 40. / (1. + age) + }); + Some(Row { + id: ResultId::App(entry.appid.as_str().into()), + icon: Some(super::icons::resolve( + entry.icon().unwrap_or("application-x-executable"), + "application-x-executable", + )), + title, + subtitle: "Application".into(), + target: Target::App { + id: entry.appid, + desktop: entry.path, + }, + score, + }) + }) + .collect(); + if cancel.cancelled() { + return Self::empty(); + } + let normalized_names = rows.iter().map(|row| row.title.to_lowercase()).collect(); + let by_id = rows + .iter() + .enumerate() + .filter_map(|(index, row)| match &row.target { + Target::App { id, .. } => Some((id.to_ascii_lowercase(), index)), + _ => None, + }) + .collect(); + Self { + rows, + normalized_names, + by_id, + } + } +} +impl Apps { + pub(super) fn search(&self, query: &str, cancel: &super::work::Cancellation) -> Vec { + let matcher = Matcher::new(query); + self.rows + .iter() + .zip(&self.normalized_names) + .take_while(|_| !cancel.cancelled()) + .filter_map(|(row, name)| { + let score = matcher.normalized(name)?; + let mut row = row.clone(); + row.score += score; + Some(row) + }) + .collect() + } +} +impl Source for Apps { + fn query(&self, query: &str) -> Vec { + self.search(query, &Default::default()) + } +} +pub(super) fn record(id: &str) -> Result<(), String> { + let path = state_path().ok_or("No state directory")?; + let parent = path.parent().ok_or("Invalid state directory")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut history = history(); + let usage = history.entry(id.into()).or_default(); + usage.count = usage.count.saturating_add(1); + usage.last = now(); + let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + serde_json::to_writer(file.as_file_mut(), &history).map_err(|e| e.to_string())?; + file.persist(path).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/shell/ferese-shell/src/launcher/files.rs b/shell/ferese-shell/src/launcher/files.rs new file mode 100644 index 0000000..c1c7d44 --- /dev/null +++ b/shell/ferese-shell/src/launcher/files.rs @@ -0,0 +1,160 @@ +use super::sources::{Matcher, ResultId, Row, Source, Target}; +use fff_search::{ + FilePicker, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency, +}; +use std::sync::{Arc, OnceLock}; + +#[derive(Clone, Debug, Default)] +pub(crate) enum Status { + #[default] + Ready, + Indexing, + Unavailable(String), +} +impl Status { + pub(super) fn message(&self) -> Option<&str> { + match self { + Self::Ready => None, + Self::Indexing => Some("Indexing files…"), + Self::Unavailable(error) => Some(error), + } + } +} + +#[derive(Clone, Default)] +pub(crate) struct Files(Arc>>); +impl Files { + fn picker(&self) -> Result<&SharedFilePicker, &str> { + self.0 + .get_or_init(|| { + let home = std::env::var_os("HOME").ok_or_else(|| "No home directory".to_string())?; + let home = std::fs::canonicalize(home).map_err(|e| e.to_string())?; + if home.parent().is_none() { + return Err("The filesystem root cannot be indexed".into()); + } + let base_path = home + .into_os_string() + .into_string() + .map_err(|_| "Home directory is not UTF-8".to_string())?; + let shared = SharedFilePicker::default(); + FilePicker::new_with_shared_state( + shared.clone(), + SharedFrecency::default(), + FilePickerOptions { + base_path, + enable_home_dir_scanning: true, + ..Default::default() + }, + ) + .map_err(|error| error.to_string())?; + Ok(shared) + }) + .as_ref() + .map_err(String::as_str) + } + pub(super) fn status(&self) -> Status { + match self.0.get() { + Some(Err(error)) => Status::Unavailable(format!("File search unavailable: {error}")), + Some(Ok(picker)) if !picker.wait_for_scan(std::time::Duration::ZERO) => Status::Indexing, + _ => Status::Ready, + } + } +} +impl Files { + pub(super) fn search( + &self, + query: &str, + cancel: &super::work::Cancellation, + icons: &std::sync::Mutex, + ) -> Vec { + if cancel.cancelled() { + return Vec::new(); + } + let Ok(shared) = self.picker() else { + return Vec::new(); + }; + if cancel.cancelled() { + return Vec::new(); + } + let paths = { + let Ok(guard) = shared.read() else { + return Vec::new(); + }; + if cancel.cancelled() { + return Vec::new(); + } + let Some(picker) = guard.as_ref() else { + return Vec::new(); + }; + let parser = QueryParser::default(); + let parsed = parser.parse(query); + picker + .fuzzy_search( + &parsed, + None, + FuzzySearchOptions { + max_threads: 2, + pagination: PaginationArgs { offset: 0, limit: 100 }, + ..Default::default() + }, + ) + .items + .into_iter() + .take_while(|_| !cancel.cancelled()) + .map(|item| item.absolute_path(picker, &picker.base_path)) + .collect::>() + }; + // Filesystem/icon work must not hold the picker's read lock. + let matcher = Matcher::new(query); + let mut icons = icons.lock().unwrap_or_else(|error| error.into_inner()); + paths + .into_iter() + .take_while(|_| !cancel.cancelled()) + .map(|path| { + let title = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let icon = Some(icons.file(&path)); + let subtitle = format!("File · {}", path.parent().unwrap_or(&path).display()); + let path: Arc = path.into(); + Row { + id: ResultId::File(path.clone()), + score: matcher.score(&title).unwrap_or(200.), + icon, + title, + subtitle, + target: Target::File(path), + } + }) + .collect() + } +} +impl Source for Files { + fn query(&self, query: &str) -> Vec { + self.search(query, &Default::default(), &Default::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn index_is_lazy_and_queries_use_the_file_picker() { + assert!(Files::default().0.get().is_none()); + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("launcher-note.txt"), "note").unwrap(); + let shared = SharedFilePicker::default(); + FilePicker::new_with_shared_state( + shared.clone(), + SharedFrecency::default(), + FilePickerOptions { + base_path: directory.path().to_string_lossy().into_owned(), + ..Default::default() + }, + ) + .unwrap(); + assert!(shared.wait_for_scan(std::time::Duration::from_secs(10))); + let files = Files(Arc::new(OnceLock::from(Ok(shared)))); + let matches = files.query("launcher-note"); + assert!(matches.iter().any(|row| row.title == "launcher-note.txt")); + assert!(files.query("no-such-file-xyz").is_empty()); + } +} diff --git a/shell/ferese-shell/src/launcher/icons.rs b/shell/ferese-shell/src/launcher/icons.rs new file mode 100644 index 0000000..bb770b3 --- /dev/null +++ b/shell/ferese-shell/src/launcher/icons.rs @@ -0,0 +1,40 @@ +use cosmic::widget::icon; +use std::path::Path; + +pub(super) fn resolve(name: &str, fallback: &'static str) -> icon::Handle { + if Path::new(name).is_absolute() { + icon::from_path(name.into()) + } else { + icon::from_name(name.to_owned()) + .prefer_svg(true) + .size(32) + .fallback(Some(icon::IconFallback::Names(vec![fallback.into()]))) + .handle() + } +} + +#[derive(Default)] +pub(super) struct FileIcons(std::collections::HashMap); +impl FileIcons { + pub(super) fn file(&mut self, path: &Path) -> icon::Handle { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + if let Some(handle) = self.0.get(mime.essence_str()) { + return handle.clone(); + } + let name = mime.essence_str().replace('/', "-"); + let generic = format!("{}-x-generic", mime.type_()); + let handle = icon::from_name(name) + .prefer_svg(true) + .size(32) + .fallback(Some(icon::IconFallback::Names(vec![ + generic.into(), + "text-x-generic".into(), + ]))) + .handle(); + // The cache belongs to one invocation and has a fixed upper bound. + if self.0.len() < 64 { + self.0.insert(mime.essence_str().into(), handle.clone()); + } + handle + } +} diff --git a/shell/ferese-shell/src/launcher/launch.rs b/shell/ferese-shell/src/launcher/launch.rs new file mode 100644 index 0000000..88fac4c --- /dev/null +++ b/shell/ferese-shell/src/launcher/launch.rs @@ -0,0 +1,135 @@ +use super::{Target, apps, sources}; +use crate::compositor_ipc; + +pub(super) fn run(target: Target, terminal: &[String]) -> Result<(), String> { + use sources::Action; + let mut command = match target { + Target::App { id, desktop } => { + let entry = freedesktop_desktop_entry::DesktopEntry::from_path(&desktop, None::<&[&str]>) + .map_err(|e| e.to_string())?; + let result = if entry.terminal() { + let arguments = entry.parse_exec().map_err(|e| e.to_string())?; + let mut command = terminal_command(terminal)?; + command.args(arguments); + if let Some(path) = entry.path() { + command.current_dir(path); + } + spawn(&mut command) + } else { + open("gio", &[std::ffi::OsStr::new("launch"), desktop.as_os_str()]) + }; + if result.is_ok() { + if let Err(error) = apps::record(&id) { + eprintln!("Could not save launcher history: {error}"); + } + } + return result; + } + Target::File(path) => return open("gio", &[std::ffi::OsStr::new("open"), path.as_os_str()]), + Target::Action(action @ (Action::Light | Action::Dark | Action::Auto)) => { + return compositor_ipc::request("theme-set-mode",serde_json::json!({"mode": match action { Action::Light => "light", Action::Dark => "dark", _ => "auto" }})).map(|_|()); + } + Target::Action(Action::Logout) => { + return compositor_ipc::request("request-logout", serde_json::json!({})).map(|_| ()); + } + Target::Action(Action::Lock) => std::process::Command::new("ferese-lock"), + Target::Action(Action::Screenshot) => return screenshot(), + _ => return Err("Action unavailable".into()), + }; + spawn(&mut command) +} +fn terminal_command(terminal: &[String]) -> Result { + let (program, options) = terminal + .split_first() + .filter(|(program, _)| !program.is_empty()) + .ok_or("No terminal command configured")?; + let mut command = std::process::Command::new(program); + command.args(options); + let name = std::path::Path::new(program) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(program); + match name { + "foot" | "footclient" | "kitty" | "gnome-terminal" | "kgx" => { + command.arg("--"); + } + "wezterm" if !options.iter().any(|option| option == "start") => { + command.args(["start", "--"]); + } + "wezterm" => { + command.arg("--"); + } + _ => { + command.arg("-e"); + } + } + Ok(command) +} + +fn open(program: &str, args: &[&std::ffi::OsStr]) -> Result<(), String> { + let mut command = std::process::Command::new(program); + command.args(args); + public_environment(&mut command); + use std::io::{Read, Seek}; + let mut stderr = tempfile::tempfile().map_err(|e| e.to_string())?; + command + .stdout(std::process::Stdio::null()) + .stderr(stderr.try_clone().map_err(|e| e.to_string())?); + // Waiting for gio's status must not wait for an application's inherited pipes. + let status = command.status().map_err(|e| e.to_string())?; + if status.success() { + return Ok(()); + } + stderr.rewind().map_err(|e| e.to_string())?; + let mut error = String::new(); + let _ = stderr.take(1024).read_to_string(&mut error); + if error.trim().is_empty() { + Err(format!("{program} exited with {status}")) + } else { + Err(error.trim().chars().take(240).collect()) + } +} +fn public_environment(command: &mut std::process::Command) { + command + .stdin(std::process::Stdio::null()) + .env_remove("WAYLAND_SOCKET") + .env_remove("FERESE_SHELL_CONTROL_SOCKET"); + if let Some(display) = std::env::var_os("FERESE_PUBLIC_WAYLAND_DISPLAY") { + command.env("WAYLAND_DISPLAY", display); + } +} +fn spawn(command: &mut std::process::Command) -> Result<(), String> { + public_environment(command); + let mut child = command.spawn().map_err(|e| e.to_string())?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +fn screenshot() -> Result<(), String> { + let home = std::env::var_os("HOME").ok_or("No home directory")?; + let directory = std::path::PathBuf::from(home).join("Pictures/Screenshots"); + std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let file = tempfile::Builder::new() + .prefix("Ferese-") + .suffix(".png") + .tempfile_in(&directory) + .map_err(|e| e.to_string())?; + let mut command = std::process::Command::new("feresectl"); + command + .arg("screenshot") + .stdout(file.reopen().map_err(|e| e.to_string())?) + .stderr(std::process::Stdio::piped()); + public_environment(&mut command); + let output = command.output().map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr) + .trim() + .chars() + .take(240) + .collect()); + } + file.keep().map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/shell/ferese-shell/src/launcher/mod.rs b/shell/ferese-shell/src/launcher/mod.rs new file mode 100644 index 0000000..7a0dd8d --- /dev/null +++ b/shell/ferese-shell/src/launcher/mod.rs @@ -0,0 +1,675 @@ +mod apps; +pub(super) mod files; +mod icons; +mod launch; +mod sources; +#[cfg(test)] +mod tests; +mod thumbnails; +mod view; +mod work; + +use super::*; +use cosmic::iced::keyboard::{Key, key::Named}; +use ferese_animation::{AnimatedValue, SpringConfig}; +use sources::{Category, Row, Source, Target}; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub(super) enum Message { + Input(window::Id, String), + Browse(window::Id, Category), + ActivateRow(window::Id, sources::ResultId), + Accept(bool), + Tick, + AppsScrolled(window::Id, usize), + Loaded(window::Id, Arc), + Results(window::Id, u64, Vec, files::Status), + Launched(window::Id, Result<(), String>), + Finished(Result<(), String>), +} + +pub(super) struct Launcher { + pub id: window::Id, + pub(super) output: String, + top: f32, + input: cosmic::widget::Id, + query: String, + category: Option, + alternatives: bool, + category_focused: bool, + ghost_visible: bool, + selected: usize, + rows: Vec, + apps: Option>, + revision: u64, + running: bool, + activating: bool, + after_close: Option, + error: Option, + file_status: files::Status, + preferred_result: Option, + closing: bool, + keyboard_nav: bool, + progress: AnimatedValue, + height: AnimatedValue, + tick: Instant, + thumbnails: thumbnails::Thumbnails, + effects: Option, + regions: motion::Regions, + refresh: Instant, + lifetime: work::Cancellation, + search_cancel: work::Cancellation, + file_icons: Arc>, + apps_first_row: usize, + windows: sources::Windows, + apps_scroll_id: cosmic::widget::Id, +} +impl Launcher { + fn status_message(&self) -> Option<&str> { + self.error.as_deref().or_else(|| self.file_status.message()) + } + fn apps_browse(&self) -> bool { + self.query.is_empty() && self.category == Some(Category::Apps) + } + fn target_height(&self) -> f64 { + if self.query.is_empty() && self.category.is_none() { + 72. + } else if self.query.is_empty() && self.category == Some(Category::Windows) { + 220. + } else if self.apps_browse() { + 80. + self.rows.len().div_ceil(4).clamp(1, 3) as f64 * 108. + } else if self.alternatives || (self.query.is_empty() && self.category.is_some()) { + 88. + self.rows.len().clamp(1, 5) as f64 * 60. + } else { + 72. + } + } + fn changed(&mut self) { + self.search_cancel.cancel(); + self.apps_first_row = 0; + self.revision = self.revision.wrapping_add(1); + self.error = None; + self.file_status = files::Status::Ready; + self.preferred_result = None; + self.selected = 0; + self.category_focused = false; + self.rows.clear(); + } + fn escape(&mut self) -> bool { + if !self.query.is_empty() { + self.query.clear(); + self.alternatives = false; + self.changed(); + false + } else if self.category.take().is_some() { + self.alternatives = false; + self.changed(); + false + } else { + true + } + } +} +impl FereseShell { + pub(super) fn attach_launcher_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) { + let Some(launcher) = self.launcher.as_mut().filter(|launcher| launcher.id == id) else { + return; + }; + if launcher.effects.is_none() { + match EffectsBinding::attach_role(&mut self.effects_context, surface, None, 0.) { + Ok(binding) => launcher.effects = Some(binding), + Err(error) => eprintln!("ferese-shell: launcher material unavailable: {error}"), + } + } + } + pub(super) fn toggle_launcher(&mut self, name: String) -> Task { + if self.launcher.is_some() { + return self.close_launcher(); + } + let Some(output) = self + .outputs + .iter() + .find(|o| o.name.as_deref() == Some(&name)) + .or_else(|| self.outputs.first()) + else { + return Task::none(); + }; + let name = output.name.clone().unwrap_or(name); + let wl_output = output.output.clone(); + let top = output.size.map_or(240., |(_, h)| h as f32 * 0.3); + let dismissed = Task::batch([self.destroy_menu(), self.destroy_system_modal(true)]); + let id = window::Id::unique(); + let mut progress = AnimatedValue::new(0.); + progress.set_target(1.); + if !self.render.motion.motion_enabled() { + progress.snap(); + } + let lifetime = work::Cancellation::default(); + let load_cancel = lifetime.clone(); + self.launcher = Some(Launcher { + id, + output: name, + top, + input: cosmic::widget::Id::unique(), + query: String::new(), + category: None, + alternatives: false, + category_focused: false, + ghost_visible: true, + selected: 0, + rows: Vec::new(), + apps: None, + revision: 0, + running: false, + activating: false, + after_close: None, + error: None, + file_status: files::Status::Ready, + preferred_result: None, + closing: false, + keyboard_nav: self.core.keyboard_nav(), + progress, + height: AnimatedValue::new(72.), + tick: Instant::now(), + thumbnails: Default::default(), + effects: None, + regions: Default::default(), + refresh: Instant::now(), + lifetime, + search_cancel: Default::default(), + file_icons: Default::default(), + apps_first_row: 0, + windows: Default::default(), + apps_scroll_id: cosmic::widget::Id::unique(), + }); + self.core.set_keyboard_nav(false); + let surface = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id, + layer: Layer::Overlay, + keyboard_interactivity: KeyboardInteractivity::Exclusive, + anchor: Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT, + output: IcedOutput::Output(wl_output.clone()), + namespace: "ferese-launcher".into(), + exclusive_zone: -1, + size: Some((None, None)), + ..Default::default() + }, + Some(Box::new(move |app| app.view_launcher(id))), + ); + Task::batch([ + dismissed, + cosmic::task::message(cosmic::Action::Surface(surface)), + Task::perform( + async move { + tokio::task::spawn_blocking(move || apps::Apps::load(&load_cancel)) + .await + .map(Arc::new) + .unwrap_or_else(|_| Arc::new(apps::Apps::empty())) + }, + move |apps| cosmic::Action::App(super::Message::Launcher(Message::Loaded(id, apps))), + ), + ]) + } + pub(super) fn destroy_launcher(&mut self) -> Task { + let Some(launcher) = self.launcher.take() else { + return Task::none(); + }; + launcher.lifetime.cancel(); + launcher.search_cancel.cancel(); + self.core.set_keyboard_nav(launcher.keyboard_nav); + destroy_layer_surface(launcher.id) + } + fn close_launcher(&mut self) -> Task { + let Some(launcher) = &mut self.launcher else { + return Task::none(); + }; + launcher.lifetime.cancel(); + launcher.search_cancel.cancel(); + launcher.closing = true; + launcher.progress.set_target(0.); + if !self.render.motion.motion_enabled() { + return self.finish_launcher(); + } + Task::none() + } + fn finish_launcher(&mut self) -> Task { + let target = self.launcher.as_mut().and_then(|l| l.after_close.take()); + let close = self.destroy_launcher(); + if let Some(target) = target { + let terminal = self.config.terminal.clone(); + close.chain(Task::perform( + async move { + tokio::time::sleep(Duration::from_millis(100)).await; + tokio::task::spawn_blocking(move || launch::run(target, &terminal)) + .await + .unwrap_or_else(|e| Err(e.to_string())) + }, + |result| cosmic::Action::App(super::Message::Launcher(Message::Finished(result))), + )) + } else { + close + } + } + pub(super) fn launcher_subscription(&self) -> Subscription { + let Some(launcher) = &self.launcher else { + return Subscription::none(); + }; + let interval = if launcher.progress.is_animating() || launcher.height.needs_update(launcher.target_height()) { + 16 + } else if launcher.category == Some(Category::Windows) && launcher.query.is_empty() { + 500 + } else if matches!(launcher.file_status, files::Status::Indexing) { + 1000 + } else { + return Subscription::none(); + }; + cosmic::iced::time::every(Duration::from_millis(interval)).map(|_| super::Message::Launcher(Message::Tick)) + } + pub(super) fn refresh_launcher(&mut self) -> Task { + self.merge_launcher_windows(false) + } + fn merge_launcher_windows(&mut self, force: bool) -> Task { + let Some(launcher) = &mut self.launcher else { + return Task::none(); + }; + if launcher.closing + || (launcher.query.is_empty() && launcher.category.is_none()) + || !launcher.category.is_none_or(|category| category == Category::Windows) + { + return Task::none(); + } + let changed = launcher + .windows + .sync(&self.snapshot, launcher.apps.as_deref(), &launcher.query); + if !changed && !force { + return Task::none(); + } + let selected = launcher.rows.get(launcher.selected).map(|row| row.id.clone()); + launcher.rows.retain(|row| !matches!(row.target, Target::Window(_))); + launcher.rows.extend(launcher.windows.rows().cloned()); + sources::rank(&mut launcher.rows); + if launcher.category.is_none() { + launcher.rows.truncate(100); + } + launcher.selected = selected + .and_then(|id| launcher.rows.iter().position(|row| row.id == id)) + .unwrap_or(0); + Task::none() + } + fn query_launcher(&mut self) -> Task { + let Some(launcher) = &mut self.launcher else { + return Task::none(); + }; + if launcher.running || launcher.closing || (launcher.query.is_empty() && launcher.category.is_none()) { + return Task::none(); + } + let Some(apps) = launcher.apps.clone() else { + return Task::none(); + }; + launcher.running = true; + launcher.search_cancel = work::Cancellation::default(); + let cancel = launcher.search_cancel.clone(); + let file_icons = launcher.file_icons.clone(); + let (id, revision, query, category) = ( + launcher.id, + launcher.revision, + launcher.query.clone(), + launcher.category, + ); + let files = self.launcher_files.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let mut rows = Vec::new(); + if !cancel.cancelled() && category.is_none_or(|c| c == Category::Apps) { + rows.extend(apps.search(&query, &cancel)); + } + if !cancel.cancelled() && category.is_none_or(|c| c == Category::Files) { + rows.extend(files.search(&query, &cancel, &file_icons)); + } + if !cancel.cancelled() && category.is_none() { + rows.extend(sources::Actions.query(&query)); + } + if cancel.cancelled() { + return (Vec::new(), files::Status::Ready); + } + sources::rank(&mut rows); + if category.is_none() { + rows.truncate(100); + } + ( + rows, + if category.is_none_or(|c| c == Category::Files) { + files.status() + } else { + files::Status::Ready + }, + ) + }) + .await + .unwrap_or_else(|e| (Vec::new(), files::Status::Unavailable(e.to_string()))) + }, + move |(rows, status)| { + cosmic::Action::App(super::Message::Launcher(Message::Results(id, revision, rows, status))) + }, + ) + } + pub(super) fn update_launcher(&mut self, message: Message) -> Task { + if let Message::Finished(result) = message { + if let Err(error) = result { + self.notifications.service_error("Launcher action failed", &error); + } + return Task::none(); + } + let Some(launcher) = &mut self.launcher else { + return Task::none(); + }; + match message { + Message::AppsScrolled(id, first) if id == launcher.id && launcher.apps_browse() => { + launcher.apps_first_row = first.min(launcher.rows.len().div_ceil(4).saturating_sub(1)); + Task::none() + } + Message::Input(id, query) if id == launcher.id && !launcher.closing => { + launcher.ghost_visible = launcher.query.starts_with(&query) || query.starts_with(&launcher.query); + launcher.query = query; + launcher.alternatives = false; + launcher.changed(); + self.query_launcher() + } + Message::Browse(id, category) if id == launcher.id && !launcher.closing => { + launcher.category = Some(category); + launcher.query.clear(); + launcher.alternatives = false; + launcher.changed(); + launcher.thumbnails = Default::default(); + let focus = cosmic::widget::text_input::focus(launcher.input.clone()); + Task::batch([focus, self.query_launcher()]) + } + Message::ActivateRow(id, row) if id == launcher.id && !launcher.closing => { + let Some(index) = launcher.rows.iter().position(|candidate| candidate.id == row) else { + return Task::none(); + }; + launcher.selected = index; + self.activate_launcher(false) + } + Message::Accept(alt) => self.activate_launcher(alt), + Message::Loaded(id, apps) if id == launcher.id => { + launcher.apps = Some(apps); + self.query_launcher() + } + Message::Results(id, revision, rows, status) if id == launcher.id => { + launcher.running = false; + if revision != launcher.revision { + return self.query_launcher(); + } + let selected = launcher + .preferred_result + .take() + .or_else(|| launcher.rows.get(launcher.selected).map(|row| row.id.clone())); + launcher.rows = rows; + launcher.file_status = status; + let task = self.merge_launcher_windows(true); + let launcher = self.launcher.as_mut().unwrap(); + launcher.selected = selected + .and_then(|id| launcher.rows.iter().position(|row| row.id == id)) + .unwrap_or(0); + task + } + Message::Launched(id, result) if id == launcher.id => match result { + Ok(()) => self.close_launcher(), + Err(error) => { + launcher.activating = false; + launcher.error = Some(error); + Task::none() + } + }, + Message::Tick => { + let now = Instant::now(); + let delta = now + .duration_since(launcher.tick) + .min(Duration::from_millis(64)) + .mul_f64(self.render.motion.speed); + launcher.tick = now; + let base = self.render.motion.spring_config().unwrap_or_default(); + let stiffness = base.stiffness * if launcher.closing { 1.6 } else { 1. }; + let spring = SpringConfig { + stiffness, + damping: 2. * (stiffness * base.mass).sqrt(), + position_tolerance: 0.001, + velocity_tolerance: 0.001, + crossing: ferese_animation::CrossingPolicy::NoCrossing, + ..base + }; + launcher.height.set_target(launcher.target_height()); + if self.render.motion.motion_enabled() { + launcher.progress.advance(delta, spring); + launcher.height.advance(delta, spring); + } else { + launcher.progress.snap(); + launcher.height.snap(); + } + if launcher.closing && !launcher.progress.is_animating() { + return self.finish_launcher(); + } + if !launcher.closing && launcher.query.is_empty() && launcher.category == Some(Category::Windows) { + if let Some(control) = &self.control { + launcher + .thumbnails + .refresh(&launcher.rows, launcher.selected, control, now); + } + } + if !launcher.closing + && now.duration_since(launcher.refresh) >= Duration::from_secs(1) + && matches!(launcher.file_status, files::Status::Indexing) + { + launcher.refresh = now; + return self.query_launcher(); + } + Task::none() + } + _ => Task::none(), + } + } + pub(super) fn launcher_event(&mut self, event: &Event, id: window::Id) -> Option> { + let launcher = self.launcher.as_mut().filter(|l| l.id == id)?; + if let Event::Window(window::Event::Opened { .. }) = event { + return Some(Task::batch([ + cosmic::widget::text_input::focus(launcher.input.clone()), + window::run(id, native_wayland_surface) + .map(move |surface| cosmic::Action::App(super::Message::NativeSurface(id, surface))), + ])); + } + if matches!( + event, + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer( + wayland::LayerEvent::Unfocused, + .. + ))) + ) { + return Some(self.destroy_launcher()); + } + if let Event::Window(window::Event::Closed) = event { + return Some(self.destroy_launcher()); + } + if launcher.closing { + return Some(Task::none()); + } + if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_))) { + launcher.ghost_visible = false; + } + + let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key, modifiers, .. }) = event else { + return None; + }; + if launcher.query.is_empty() && launcher.category.is_none() { + match key { + Key::Named(Named::ArrowDown | Named::ArrowRight | Named::Tab) => { + launcher.selected = if launcher.category_focused { + (launcher.selected + 1) % 3 + } else { + 0 + }; + launcher.category_focused = true; + return Some(cosmic::widget::text_input::focus(launcher.input.clone())); + } + Key::Named(Named::ArrowLeft | Named::ArrowUp) => { + launcher.selected = (launcher.selected + 2) % 3; + launcher.category_focused = true; + return Some(cosmic::widget::text_input::focus(launcher.input.clone())); + } + _ => {} + } + } + if launcher.apps_browse() { + let next = match key { + Key::Named(Named::ArrowLeft) => Some(launcher.selected.saturating_sub(1)), + Key::Named(Named::ArrowRight) => Some(launcher.selected + 1), + Key::Named(Named::ArrowUp) => Some(launcher.selected.saturating_sub(4)), + Key::Named(Named::ArrowDown) => Some(launcher.selected + 4), + _ => None, + }; + if let Some(next) = next { + launcher.selected = next.min(launcher.rows.len().saturating_sub(1)); + let rows = launcher.rows.len().div_ceil(4); + launcher.apps_first_row = (launcher.selected / 4).saturating_sub(2); + return Some(cosmic::iced::widget::scrollable::snap_to( + launcher.apps_scroll_id.clone(), + cosmic::iced::widget::scrollable::RelativeOffset { + x: None, + y: Some(if rows > 3 { + (launcher.selected / 4).saturating_sub(2) as f32 / (rows - 3) as f32 + } else { + 0. + }), + }, + )); + } + } + Some(match key { + Key::Named(Named::Escape) => { + if launcher.escape() { + self.close_launcher() + } else { + let focus = cosmic::widget::text_input::focus(launcher.input.clone()); + Task::batch([focus, self.query_launcher()]) + } + } + Key::Named(Named::ArrowDown) => { + if launcher.alternatives || launcher.query.is_empty() { + launcher.selected = (launcher.selected + 1).min(launcher.rows.len().saturating_sub(1)); + } + launcher.alternatives = true; + Task::none() + } + Key::Named(Named::ArrowUp) => { + launcher.selected = launcher.selected.saturating_sub(1); + Task::none() + } + Key::Named(Named::ArrowRight) + if launcher.query.is_empty() && launcher.category == Some(Category::Windows) => + { + launcher.selected = (launcher.selected + 1).min(launcher.rows.len().saturating_sub(1)); + Task::none() + } + Key::Named(Named::ArrowLeft) + if launcher.query.is_empty() && launcher.category == Some(Category::Windows) => + { + launcher.selected = launcher.selected.saturating_sub(1); + Task::none() + } + Key::Named(Named::Tab) if !launcher.query.is_empty() => { + if let Some(row) = launcher.rows.get(launcher.selected) { + let preferred = row.id.clone(); + launcher.query = row.title.clone(); + launcher.changed(); + launcher.preferred_result = Some(preferred); + } + let focus = cosmic::widget::text_input::focus(launcher.input.clone()); + let cursor = cosmic::widget::text_input::move_cursor_to_end(launcher.input.clone()); + Task::batch([focus, cursor, self.query_launcher()]) + } + Key::Named(Named::ArrowLeft | Named::ArrowRight | Named::Home) => { + launcher.ghost_visible = false; + Task::none() + } + Key::Named(Named::End) => { + launcher.ghost_visible = true; + Task::none() + } + Key::Named(Named::Tab) => cosmic::widget::text_input::focus(launcher.input.clone()), + Key::Named(Named::Enter) => self.activate_launcher(modifiers.alt()), + _ => Task::none(), + }) + } + fn activate_launcher(&mut self, alt: bool) -> Task { + let Some(launcher) = &mut self.launcher else { + return Task::none(); + }; + if launcher.closing || launcher.running || launcher.activating { + return Task::none(); + } + if launcher.query.is_empty() && launcher.category.is_none() && launcher.category_focused { + let category = [Category::Apps, Category::Files, Category::Windows][launcher.selected.min(2)]; + let id = launcher.id; + return self.update_launcher(Message::Browse(id, category)); + } + let Some(row) = launcher.rows.get(launcher.selected) else { + return Task::none(); + }; + if row.requires_alt() && !alt { + launcher.error = Some("Use Alt+Enter to confirm".into()); + return Task::none(); + } + let target = row.target.clone(); + let id = launcher.id; + let terminal = self.config.terminal.clone(); + if matches!(target, Target::Action(sources::Action::Screenshot)) { + launcher.after_close = Some(target); + return self.close_launcher(); + } + if let Target::Window(window) = target { + if let Some(control) = &self.control { + if alt { + if let Some(workspace) = self + .snapshot + .outputs + .iter() + .find(|o| o.name == launcher.output) + .map(|o| o.active_workspace) + { + control.move_window_to_workspace(window, workspace); + } + } else { + control.activate_window(window); + } + } + return self.close_launcher(); + } + use sources::Action; + if let Target::Action(action) = target { + let power = match action { + Action::Poweroff => Some(system_modal::PowerAction::Poweroff), + Action::Reboot => Some(system_modal::PowerAction::Reboot), + Action::Suspend => Some(system_modal::PowerAction::Suspend), + _ => None, + }; + if let Some(power) = power { + let close = self.destroy_launcher(); + let open = self.open_system_modal(power, None); + return Task::batch([close, open]); + } + } + launcher.activating = true; + Task::perform( + async move { + tokio::task::spawn_blocking(move || launch::run(target, &terminal)) + .await + .unwrap_or_else(|e| Err(e.to_string())) + }, + move |result| cosmic::Action::App(super::Message::Launcher(Message::Launched(id, result))), + ) + } +} diff --git a/shell/ferese-shell/src/launcher/sources.rs b/shell/ferese-shell/src/launcher/sources.rs new file mode 100644 index 0000000..cf400cd --- /dev/null +++ b/shell/ferese-shell/src/launcher/sources.rs @@ -0,0 +1,297 @@ +use crate::control::ShellSnapshot; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Category { + Apps, + Files, + Windows, +} +impl Category { + pub(super) fn label(self) -> &'static str { + match self { + Self::Apps => "Apps", + Self::Files => "Files", + Self::Windows => "Windows", + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Action { + Light, + Dark, + Auto, + Screenshot, + Lock, + Suspend, + Poweroff, + Reboot, + Logout, +} +impl Action { + pub(super) fn destructive(self) -> bool { + matches!(self, Self::Poweroff | Self::Reboot | Self::Logout) + } +} +#[derive(Clone, Debug)] +pub(crate) enum Target { + App { id: String, desktop: PathBuf }, + File(Arc), + Window(u64), + Action(Action), +} +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum ResultId { + App(Arc), + File(Arc), + Window(u64), + Action(Action), +} +impl From<&str> for ResultId { + fn from(value: &str) -> Self { + Self::App(value.into()) + } +} +#[derive(Clone, Debug)] +pub(crate) struct Row { + pub id: ResultId, + pub title: String, + pub icon: Option, + pub subtitle: String, + pub target: Target, + pub score: f64, +} +impl Row { + pub(super) fn category(&self) -> Option { + match self.target { + Target::App { .. } => Some(Category::Apps), + Target::File(_) => Some(Category::Files), + Target::Window(_) => Some(Category::Windows), + Target::Action(_) => None, + } + } + pub(super) fn requires_alt(&self) -> bool { + matches!(self.target, Target::Action(action) if action.destructive()) + } +} +pub(super) trait Source { + fn query(&self, query: &str) -> Vec; +} + +// Normalize the query once, and reuse normalized catalog names across keystrokes. +pub(super) struct Matcher(String); +impl Matcher { + pub(super) fn new(query: &str) -> Self { + Self(query.trim().to_lowercase()) + } + pub(super) fn score(&self, text: &str) -> Option { + if self.0.is_empty() { + return Some(0.); + } + self.normalized(&text.to_lowercase()) + } + pub(super) fn normalized(&self, text: &str) -> Option { + let query = &self.0; + if query.is_empty() { + return Some(0.); + } + if text == query { + return Some(1000.); + } + if text.starts_with(query) { + return Some(800. - text.chars().count() as f64 * 0.1); + } + if let Some(offset) = text.find(query) { + return Some(600. - offset as f64); + } + let mut remaining = query.chars(); + let mut next = remaining.next()?; + for ch in text.chars() { + if ch == next { + match remaining.next() { + Some(ch) => next = ch, + None => return Some(300. - text.chars().count() as f64 * 0.1), + } + } + } + None + } +} +#[cfg(test)] +pub(super) fn match_score(query: &str, text: &str) -> Option { + Matcher::new(query).score(text) +} +pub(super) fn rank(rows: &mut Vec) { + // Group duplicate identities without allocating cloned IDs or a hash table. + rows.sort_unstable_by(|a, b| { + a.id.cmp(&b.id) + .then_with(|| b.score.total_cmp(&a.score)) + .then_with(|| a.title.cmp(&b.title)) + }); + rows.dedup_by(|a, b| a.id == b.id); + rows.sort_unstable_by(|a, b| { + b.score + .total_cmp(&a.score) + .then_with(|| a.title.cmp(&b.title)) + .then_with(|| a.id.cmp(&b.id)) + }); +} +#[derive(Default)] +pub(super) struct Windows { + entries: std::collections::HashMap, + workspaces: std::collections::HashMap, + workspace_ids: std::collections::HashSet, + query: String, + apps_ready: bool, +} +struct WindowEntry { + title: String, + app_id: String, + workspace: u64, + workspace_name: String, + result: Option, + icon: Option, + seen: bool, +} +impl Windows { + pub(super) fn sync(&mut self, snapshot: &ShellSnapshot, apps: Option<&super::apps::Apps>, query: &str) -> bool { + let query_changed = self.query != query || self.apps_ready != apps.is_some(); + if self.query != query { + self.query.clear(); + self.query.push_str(query); + } + self.apps_ready = apps.is_some(); + let matcher = Matcher::new(query); + self.workspace_ids.clear(); + for workspace in &snapshot.workspaces { + self.workspace_ids.insert(workspace.id); + self.workspaces + .entry(workspace.id) + .or_default() + .clone_from(&workspace.name); + } + self.workspaces.retain(|id, _| self.workspace_ids.contains(id)); + for entry in self.entries.values_mut() { + entry.seen = false; + } + let mut changed = query_changed; + for window in &snapshot.windows { + let name = self.workspaces.get(&window.workspace).map_or("?", String::as_str); + if let Some(entry) = self.entries.get_mut(&window.id) { + entry.seen = true; + if !query_changed + && entry.title == window.title + && entry.app_id == window.app_id + && entry.workspace == window.workspace + && entry.workspace_name == name + { + continue; + } + } + let title = if window.title.is_empty() { + &window.app_id + } else { + &window.title + }; + let score = matcher + .score(title) + .into_iter() + .chain(matcher.score(&window.app_id)) + .max_by(f64::total_cmp); + let icon = apps.and_then(|apps| apps.icon(&window.app_id)); + let result = score.map(|score| Row { + id: ResultId::Window(window.id), + title: title.clone(), + icon: icon.clone(), + subtitle: format!("Window · Workspace {name}"), + target: Target::Window(window.id), + score, + }); + self.entries.insert( + window.id, + WindowEntry { + title: window.title.clone(), + app_id: window.app_id.clone(), + workspace: window.workspace, + workspace_name: name.into(), + result, + icon, + seen: true, + }, + ); + changed = true; + } + self.entries.retain(|_, entry| { + if !entry.seen { + changed = true; + } + entry.seen + }); + changed + } + pub(super) fn rows(&self) -> impl Iterator { + self.entries.values().filter_map(|entry| entry.result.as_ref()) + } +} +impl Source for Windows { + fn query(&self, query: &str) -> Vec { + if self.query == query { + return self.rows().cloned().collect(); + } + let matcher = Matcher::new(query); + self.entries + .iter() + .filter_map(|(id, entry)| { + let title = if entry.title.is_empty() { + &entry.app_id + } else { + &entry.title + }; + let score = matcher + .score(title) + .into_iter() + .chain(matcher.score(&entry.app_id)) + .max_by(f64::total_cmp)?; + Some(Row { + id: ResultId::Window(*id), + title: title.clone(), + icon: entry.icon.clone(), + subtitle: format!("Window · Workspace {}", entry.workspace_name), + target: Target::Window(*id), + score, + }) + }) + .collect() + } +} +pub(super) struct Actions; +impl Source for Actions { + fn query(&self, query: &str) -> Vec { + let matcher = Matcher::new(query); + [ + ("Light theme", Action::Light), + ("Dark theme", Action::Dark), + ("Automatic theme", Action::Auto), + ("Take screenshot", Action::Screenshot), + ("Lock screen", Action::Lock), + ("Suspend", Action::Suspend), + ("Power off", Action::Poweroff), + ("Restart", Action::Reboot), + ("Log out", Action::Logout), + ] + .into_iter() + .filter_map(|(title, action)| { + let score = matcher.score(title)?; + Some(Row { + id: ResultId::Action(action), + icon: None, + title: title.into(), + subtitle: "Action".into(), + target: Target::Action(action), + score, + }) + }) + .collect() + } +} diff --git a/shell/ferese-shell/src/launcher/tests.rs b/shell/ferese-shell/src/launcher/tests.rs new file mode 100644 index 0000000..a71a07c --- /dev/null +++ b/shell/ferese-shell/src/launcher/tests.rs @@ -0,0 +1,180 @@ +use super::*; + +fn shell() -> FereseShell { + let mut shell = crate::tests::shell_with_measured_panel(); + let _ = shell.toggle_launcher("test".into()); + shell +} +fn row(title: &str) -> Row { + Row { + id: title.into(), + title: title.into(), + icon: None, + subtitle: "Action".into(), + target: Target::Action(sources::Action::Lock), + score: 1., + } +} +#[test] +fn escape_pops_one_layer_in_every_category() { + let mut shell = shell(); + let launcher = shell.launcher.as_mut().unwrap(); + for category in [ + None, + Some(Category::Apps), + Some(Category::Files), + Some(Category::Windows), + ] { + launcher.category = category; + launcher.query = "term".into(); + launcher.alternatives = true; + assert!(!launcher.escape()); + assert!(launcher.query.is_empty()); + assert!(!launcher.alternatives); + assert_eq!(launcher.category, category); + if category.is_some() { + assert!(!launcher.escape()); + assert_eq!(launcher.category, None); + } + assert!(launcher.escape()); + } +} +#[test] +fn typing_never_opens_alternates_and_empty_home_has_pills() { + let mut shell = shell(); + let _ = shell.update_launcher(Message::Input(shell.launcher.as_ref().unwrap().id, "fire".into())); + let launcher = shell.launcher.as_mut().unwrap(); + assert!(!launcher.alternatives); + assert_eq!(launcher.target_height(), 72.); + launcher.rows = vec![row("Firefox"), row("Files")]; + launcher.alternatives = true; + assert_eq!(launcher.target_height(), 208.); + assert!(!launcher.escape()); + assert_eq!(launcher.target_height(), 72.); +} +#[test] +fn late_results_cannot_replace_a_new_query_or_invocation() { + let mut shell = shell(); + let id = shell.launcher.as_ref().unwrap().id; + let _ = shell.update_launcher(Message::Input(id, "new".into())); + let _ = shell.update_launcher(Message::Results(id, 0, vec![row("old")], files::Status::Ready)); + assert!(shell.launcher.as_ref().unwrap().rows.is_empty()); + let _ = shell.destroy_launcher(); + let _ = shell.update_launcher(Message::Results(id, 1, vec![row("old")], files::Status::Ready)); + assert!(shell.launcher.is_none()); + let _ = shell.toggle_launcher("test".into()); + let _ = shell.update_launcher(Message::Results(id, 0, vec![row("old")], files::Status::Ready)); + assert!(shell.launcher.as_ref().unwrap().rows.is_empty()); +} +#[test] +fn selection_survives_result_reordering() { + let mut shell = shell(); + let launcher = shell.launcher.as_mut().unwrap(); + launcher.rows = vec![row("one"), row("two")]; + launcher.selected = 1; + let (id, revision) = (launcher.id, launcher.revision); + let _ = shell.update_launcher(Message::Results( + id, + revision, + vec![row("two"), row("one")], + files::Status::Ready, + )); + assert_eq!(shell.launcher.as_ref().unwrap().selected, 0); +} +#[test] +fn destructive_actions_require_alt() { + for action in [ + sources::Action::Poweroff, + sources::Action::Reboot, + sources::Action::Logout, + ] { + let mut shell = shell(); + let mut result = row("dangerous"); + result.target = Target::Action(action); + shell.launcher.as_mut().unwrap().rows = vec![result]; + let _ = shell.activate_launcher(false); + let launcher = shell.launcher.as_ref().unwrap(); + assert!(!launcher.closing); + assert!(!launcher.activating); + assert_eq!(launcher.error.as_deref(), Some("Use Alt+Enter to confirm")); + assert!(shell.system_modal.is_none()); + } +} +#[test] +fn rank_is_deterministic_and_deduplicates_ids() { + let mut rows = vec![row("B"), row("A"), row("B")]; + sources::rank(&mut rows); + assert_eq!( + rows.iter().map(|r| r.title.as_str()).collect::>(), + vec!["A", "B"] + ); + assert!(sources::match_score("é", "Éditeur").is_some()); + assert!(sources::match_score("nope", "Terminal").is_none()); +} +#[test] +fn close_releases_catalog_and_restores_keyboard_navigation() { + let mut shell = shell(); + let apps = Arc::new(apps::Apps::empty()); + let weak = Arc::downgrade(&apps); + shell.launcher.as_mut().unwrap().apps = Some(apps); + shell.launcher.as_mut().unwrap().keyboard_nav = true; + let _ = shell.destroy_launcher(); + assert!(weak.upgrade().is_none()); + assert!(shell.core.keyboard_nav()); + assert!(shell.launcher.is_none()); +} + +#[test] +fn queries_are_coalesced_and_only_the_latest_result_is_applied() { + let mut shell = shell(); + let launcher = shell.launcher.as_mut().unwrap(); + launcher.apps = Some(Arc::new(apps::Apps::empty())); + launcher.running = true; + let id = launcher.id; + let _ = shell.update_launcher(Message::Input(id, "first".into())); + let _ = shell.update_launcher(Message::Input(id, "second".into())); + let revision = shell.launcher.as_ref().unwrap().revision; + let _ = shell.update_launcher(Message::Results(id, 0, vec![row("stale")], files::Status::Ready)); + assert!(shell.launcher.as_ref().unwrap().running); + assert!(shell.launcher.as_ref().unwrap().rows.is_empty()); + let _ = shell.update_launcher(Message::Results( + id, + revision, + vec![row("current")], + files::Status::Ready, + )); + assert!(!shell.launcher.as_ref().unwrap().running); + assert_eq!(shell.launcher.as_ref().unwrap().rows[0].title, "current"); +} + +#[test] +fn queued_widget_messages_cannot_change_a_reopened_launcher() { + let mut shell = shell(); + let old = shell.launcher.as_ref().unwrap().id; + let _ = shell.destroy_launcher(); + let _ = shell.toggle_launcher("test".into()); + let _ = shell.update_launcher(Message::Input(old, "stale paste".into())); + let _ = shell.update_launcher(Message::Browse(old, Category::Files)); + let launcher = shell.launcher.as_ref().unwrap(); + assert!(launcher.query.is_empty()); + assert_eq!(launcher.category, None); +} + +#[test] +fn clicked_rows_keep_their_identity_after_a_result_refresh() { + let mut shell = shell(); + let launcher = shell.launcher.as_mut().unwrap(); + let id = launcher.id; + let mut power = row("power"); + power.target = Target::Action(sources::Action::Poweroff); + let mut window = row("window"); + window.target = Target::Window(99); + launcher.rows = vec![window, power]; + let _ = shell.update_launcher(Message::ActivateRow(id, "power".into())); + let launcher = shell.launcher.as_ref().unwrap(); + assert_eq!(launcher.selected, 1); + assert!(!launcher.closing); + assert_eq!(launcher.error.as_deref(), Some("Use Alt+Enter to confirm")); + let _ = shell.update_launcher(Message::ActivateRow(id, "vanished".into())); + assert_eq!(shell.launcher.as_ref().unwrap().selected, 1); +} diff --git a/shell/ferese-shell/src/launcher/thumbnails.rs b/shell/ferese-shell/src/launcher/thumbnails.rs new file mode 100644 index 0000000..e45d1ba --- /dev/null +++ b/shell/ferese-shell/src/launcher/thumbnails.rs @@ -0,0 +1,83 @@ +use super::*; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU32, Ordering}; +static NEXT_TOKEN: AtomicU32 = AtomicU32::new(1); +pub(super) struct Thumbnails { + token: u32, + pub images: HashMap, + pending: HashSet, + updated: Option, + visible: [Option; 3], +} +impl Default for Thumbnails { + fn default() -> Self { + Self { + token: NEXT_TOKEN.fetch_add(1, Ordering::Relaxed), + images: HashMap::new(), + pending: HashSet::new(), + updated: None, + visible: [None; 3], + } + } +} +impl Thumbnails { + pub fn refresh(&mut self, rows: &[Row], selected: usize, control: &ShellControl, now: Instant) { + if self + .updated + .is_some_and(|updated| now.duration_since(updated) < Duration::from_millis(500)) + { + return; + } + self.visible = std::array::from_fn(|offset| { + rows.get(selected.saturating_sub(2) + offset) + .and_then(|row| match row.target { + Target::Window(id) => Some(id), + _ => None, + }) + }); + self.images.retain(|id, _| self.visible.contains(&Some(*id))); + for id in self.visible.into_iter().flatten() { + if self.pending.len() < 3 && self.pending.insert(id) { + control.thumbnail(self.token, id); + } + } + self.updated = Some(now); + } +} +impl FereseShell { + pub(crate) fn launcher_thumbnail(&mut self, token: u32, window: u64, image: Option) { + let Some(launcher) = &mut self.launcher else { + return; + }; + if launcher.closing || launcher.thumbnails.token != token || !launcher.thumbnails.pending.remove(&window) { + return; + } + if let Some(image) = image { + launcher.thumbnails.images.insert(window, image); + } else { + launcher.thumbnails.images.remove(&window); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn thumbnail_replies_must_belong_to_the_open_invocation() { + let mut shell = crate::tests::shell_with_measured_panel(); + let _ = shell.toggle_launcher("test".into()); + let launcher = shell.launcher.as_mut().unwrap(); + let token = launcher.thumbnails.token; + launcher.thumbnails.pending.insert(1); + let image = image::Handle::from_rgba(1, 1, vec![255, 0, 0, 255]); + shell.launcher_thumbnail(token.wrapping_add(1), 1, Some(image.clone())); + assert!(shell.launcher.as_ref().unwrap().thumbnails.images.is_empty()); + shell.launcher_thumbnail(token, 1, Some(image.clone())); + assert_eq!(shell.launcher.as_ref().unwrap().thumbnails.images.len(), 1); + let _ = shell.destroy_launcher(); + let _ = shell.toggle_launcher("test".into()); + shell.launcher_thumbnail(token, 1, Some(image)); + assert!(shell.launcher.as_ref().unwrap().thumbnails.images.is_empty()); + } +} diff --git a/shell/ferese-shell/src/launcher/view.rs b/shell/ferese-shell/src/launcher/view.rs new file mode 100644 index 0000000..d3eaa5d --- /dev/null +++ b/shell/ferese-shell/src/launcher/view.rs @@ -0,0 +1,531 @@ +use super::*; +use cosmic::iced::border::Shape; +use cosmic::iced::widget::{Space, rich_text, span, stack}; +use cosmic::widget::{column, text_input}; + +type Ui<'a> = Element<'a, cosmic::Action>; +fn message(message: Message) -> cosmic::Action { + cosmic::Action::App(super::super::Message::Launcher(message)) +} +fn fill(color: Color, radius: f32) -> theme::Container<'static> { + theme::Container::custom(move |_| container::Style { + background: Some(color.into()), + border: Border { + radius: radius.into(), + shape: Shape::Continuous, + ..Default::default() + }, + ..Default::default() + }) +} +fn button_style(color: Color) -> theme::Button { + button_radius(color, 20.) +} +fn button_radius(color: Color, radius: f32) -> theme::Button { + let paint = move |hovered| button::Style { + background: Some(color.scale_alpha(if hovered { 0.18 } else { 0.09 }).into()), + border_radius: radius.into(), + border_width: 0., + shape: Some(Shape::Continuous), + ..Default::default() + }; + theme::Button::Custom { + active: Box::new(move |_, _| paint(false)), + hovered: Box::new(move |_, _| paint(true)), + pressed: Box::new(move |_, _| paint(true)), + disabled: Box::new(move |_| paint(false)), + } +} +impl FereseShell { + pub(super) fn view_launcher(&self, id: window::Id) -> Ui<'_> { + motion::frame_driven( + None, + move |_| self.launcher_content(id), + |_| false, + move |_| { + if let Some(launcher) = self.launcher.as_ref().filter(|launcher| launcher.id == id) + && let Some(effects) = &launcher.effects + { + let _ = effects.set_presentation( + &launcher.regions.lock().unwrap(), + launcher.progress.current as f32, + [], + ferese_surface_effects_v1::Role::Popover, + ); + } + }, + |_| None, + ) + } + fn launcher_content(&self, id: window::Id) -> Ui<'_> { + let Some(launcher) = self.launcher.as_ref().filter(|l| l.id == id) else { + return Space::new().into(); + }; + let palette = self.config.theme.palette(); + let opacity = launcher.progress.current as f32; + let text = palette.text.scale_alpha(opacity); + let muted = palette.muted.scale_alpha(opacity); + let appearance = move |_: &cosmic::Theme| text_input::Appearance { + shape: Some(Shape::Continuous), + outline: None, + background: Color::TRANSPARENT.into(), + border_radius: 0.into(), + border_offset: None, + border_width: 0., + border_color: Color::TRANSPARENT, + label_color: text, + placeholder_color: muted, + selected_text_color: text, + icon_color: Some(text), + text_color: Some(text), + selected_fill: palette.accent.scale_alpha(0.3 * opacity), + }; + let accent = palette.accent.scale_alpha(opacity); + let medium = cosmic::font::Font { + weight: cosmic::iced::font::Weight::Medium, + ..self.render.font + }; + let placeholder = launcher.category.map_or("Search", Category::label); + let input = text_input(placeholder, &launcher.query) + .id(launcher.input.clone()) + .font(self.render.font) + .size(24) + .line_height(cosmic::iced::widget::text::LineHeight::Absolute(32.into())) + .padding(0) + .on_input(move |value| message(Message::Input(id, value))) + .style(theme::TextInput::Custom { + active: Box::new(appearance), + error: Box::new(appearance), + hovered: Box::new(appearance), + focused: Box::new(appearance), + disabled: Box::new(appearance), + }); + let completion = launcher + .rows + .get(launcher.selected) + .filter(|_| !launcher.query.is_empty() && launcher.ghost_visible && launcher.query.chars().count() <= 28) + .map(|row| { + if row + .title + .get(..launcher.query.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(&launcher.query)) + { + std::borrow::Cow::Borrowed(&row.title[launcher.query.len()..]) + } else { + std::borrow::Cow::Owned(format!(" {}", row.title)) + } + }); + let show_tab = completion.is_some(); + let completion_text = rich_text([ + span::<(), cosmic::font::Font>(&launcher.query).color(Color::TRANSPARENT), + span(completion.unwrap_or_default()).color(text.scale_alpha(0.3)), + ]) + .size(24) + .line_height(cosmic::iced::widget::text::LineHeight::Absolute(32.into())) + .wrapping(cosmic::iced::widget::text::Wrapping::None) + .font(self.render.font); + let mut suggestion = row![completion_text] + .spacing(6) + .align_y(cosmic::iced::Alignment::Center); + if show_tab { + suggestion = suggestion.push( + container( + self.render + .text("Tab") + .size(12) + .line_height(cosmic::iced::widget::text::LineHeight::Absolute(14.into())) + .class(theme::Text::Color(muted)), + ) + .padding([2, 6]) + .class(fill(text.scale_alpha(0.06), 5.)), + ); + } + // The input determines the layout even when the completion is empty. + // Keep both layers present so result updates preserve focus and cursor state. + let editor: Ui<'_> = stack![input] + .push_under(suggestion) + .width(Length::Fill) + .clip(true) + .into(); + let search_match = launcher + .rows + .get(launcher.selected) + .filter(|_| !launcher.query.is_empty()); + let icon_category = search_match.map_or(launcher.category, Row::category); + let icon = match icon_category { + Some(Category::Windows) => ferese_theme::icons::WINDOW, + Some(Category::Apps) => ferese_theme::icons::APPS, + Some(Category::Files) => ferese_theme::icons::FOLDER, + None if search_match.is_some() => ferese_theme::icons::morph::CONTROLS, + None => ferese_theme::icons::SEARCH, + }; + let icon_color = if search_match.is_some() || launcher.category.is_some() { + accent + } else { + muted + }; + let mut input_row = row![bar_icon(icon, 24, icon_color), editor] + .spacing(16) + .align_y(cosmic::iced::Alignment::Center); + if let Some(handle) = search_match.and_then(|result| result.icon.clone()) { + input_row = input_row.push(cosmic::widget::icon::icon(handle).size(28).opacity(opacity)); + } + if launcher.query.is_empty() && launcher.category.is_none() { + let dark = self.config.theme.appearance == ferese_config::theme::Appearance::Dark; + let mut pills = row![].spacing(6); + for (index, category) in [Category::Apps, Category::Files, Category::Windows] + .into_iter() + .enumerate() + { + let symbol = match category { + Category::Apps => ferese_theme::icons::APPS, + Category::Files => ferese_theme::icons::FOLDER, + Category::Windows => ferese_theme::icons::WINDOW, + }; + let selected = launcher.category_focused && launcher.selected == index; + let normal = if dark { 0.065 } else { 0.04 }; + let strong = if dark { 0.10 } else { 0.07 }; + let paint = move |alpha| button::Style { + background: Some(text.scale_alpha(alpha).into()), + border_radius: 16.into(), + border_width: 0., + ..Default::default() + }; + let label = self + .render + .text(category.label()) + .font(medium) + .size(13) + .line_height(cosmic::iced::widget::text::LineHeight::Absolute(18.into())) + .class(theme::Text::Color(text)); + pills = pills.push( + button::custom( + row![bar_icon(symbol, 16, accent), label] + .spacing(7) + .align_y(cosmic::iced::Alignment::Center), + ) + .height(32) + .padding(cosmic::iced::Padding { + top: 7., + bottom: 7., + left: 10., + right: 13., + }) + .class(theme::Button::Custom { + active: Box::new(move |_, _| paint(if selected { strong } else { normal })), + hovered: Box::new(move |_, _| paint(strong)), + pressed: Box::new(move |_, _| paint(strong - 0.02)), + disabled: Box::new(move |_| paint(normal)), + }) + .on_press(message(Message::Browse(id, category))), + ); + } + input_row = input_row.push(pills); + } + let input_content = if let Some(error) = launcher.status_message() { + column![ + input_row, + self.render.text(error).size(12).class(theme::Text::Color(muted)) + ] + .spacing(2) + } else { + column![input_row] + }; + let mut body = column![ + container(input_content) + .height(72) + .padding(cosmic::iced::Padding { + left: 24., + right: 20., + ..Default::default() + }) + .align_y(cosmic::iced::Alignment::Center) + ]; + if launcher.apps_browse() { + let total = launcher.rows.len().div_ceil(4); + let first = launcher.apps_first_row.min(total.saturating_sub(1)).saturating_sub(1); + let end = (first + 6).min(total); + let mut grid = cosmic::iced::widget::keyed_column(Vec::<(usize, Ui<'_>)>::new()); + if first > 0 { + grid = grid.push(usize::MAX, Space::new().height(first as f32 * 108.)); + } + for (row_index, apps) in launcher.rows.chunks(4).enumerate().skip(first).take(end - first) { + let mut tiles = row![].spacing(12); + for (column_index, app) in apps.iter().enumerate() { + let glyph: Ui<'_> = match &app.icon { + Some(handle) => cosmic::widget::icon::icon(handle.clone()) + .size(44) + .opacity(opacity) + .into(), + None => bar_icon(ferese_theme::icons::APPS, 44, accent).into(), + }; + let label = container( + self.render + .text(&app.title) + .font(medium) + .size(14) + .class(theme::Text::Color(text)), + ) + .center_x(Length::Fill) + .height(20) + .clip(true); + tiles = tiles.push( + button::custom( + column![container(glyph).center_x(Length::Fill), label] + .spacing(8) + .align_x(cosmic::iced::Alignment::Center), + ) + .width(Length::Fill) + .height(100) + .padding(12) + .class(button_style(if launcher.selected == row_index * 4 + column_index { + palette.accent.scale_alpha(opacity) + } else { + Color::TRANSPARENT + })) + .on_press(message(Message::ActivateRow(id, app.id.clone()))), + ); + } + for _ in apps.len()..4 { + tiles = tiles.push(Space::new().width(Length::Fill)); + } + grid = grid.push( + row_index, + container(tiles).height(if row_index + 1 == total { 100 } else { 108 }), + ); + } + if end < total { + grid = grid.push(usize::MAX - 1, Space::new().height((total - end) as f32 * 108. - 8.)); + } + if launcher.rows.is_empty() { + grid = grid.push( + usize::MAX - 2, + container( + self.render + .text(if launcher.running { "Loading apps…" } else { "No apps" }) + .size(14) + .class(theme::Text::Color(muted)), + ) + .height(100), + ); + } + let height = launcher.rows.len().div_ceil(4).clamp(1, 3) as f32 * 108. - 8.; + body = body.push( + container( + cosmic::iced::widget::scrollable(grid) + .id(launcher.apps_scroll_id.clone()) + .on_scroll(move |viewport| { + message(Message::AppsScrolled( + id, + (viewport.absolute_offset().y / 108.) as usize, + )) + }) + .height(height), + ) + .padding([0, 16]), + ); + } else if launcher.query.is_empty() && launcher.category == Some(Category::Windows) { + let mut cards = row![].spacing(12); + for (index, result) in launcher + .rows + .iter() + .enumerate() + .skip(launcher.selected.saturating_sub(2)) + .take(3) + { + let Target::Window(id) = result.target else { + continue; + }; + let window = self.snapshot.windows.iter().find(|w| w.id == id); + let workspace = window.and_then(|w| self.snapshot.workspaces.iter().find(|ws| ws.id == w.workspace)); + let thumbnail: Ui<'_> = match launcher.thumbnails.images.get(&id) { + Some(handle) => image(handle.clone()) + .width(Length::Fill) + .height(84) + .content_fit(ContentFit::Contain) + .opacity(opacity) + .into(), + None => container(bar_icon(ferese_theme::icons::WINDOW, 28, muted)) + .center_x(Length::Fill) + .center_y(84) + .into(), + }; + let badge = container( + self.render + .text(workspace.map_or("?", |w| w.name.as_str())) + .size(12) + .class(theme::Text::Color(text)), + ) + .padding([3, 6]) + .class(fill(palette.sidebar.scale_alpha(opacity), 7.)); + let preview = container(stack![thumbnail, container(badge).padding(6)]) + .clip(true) + .class(fill(palette.card.scale_alpha(opacity), 15.)); + let label = self + .render + .text(window.map_or(result.title.as_str(), |w| { + launcher + .apps + .as_ref() + .and_then(|apps| apps.name(&w.app_id)) + .unwrap_or(if w.app_id.is_empty() { &w.title } else { &w.app_id }) + })) + .size(14) + .class(theme::Text::Color(text)); + let selected = index == launcher.selected; + let card = container(column![preview, label].spacing(8)) + .padding(6) + .class(theme::Container::custom(move |_| container::Style { + border: Border { + radius: 20.into(), + width: if selected { 2. } else { 0. }, + color: palette.accent.scale_alpha(opacity), + shape: Shape::Continuous, + ..Default::default() + }, + ..Default::default() + })); + cards = cards.push( + button::custom(card) + .width(Length::Fill) + .padding(0) + .class(button_style(Color::TRANSPARENT)) + .on_press(message(Message::ActivateRow(launcher.id, result.id.clone()))), + ); + } + if launcher.rows.is_empty() { + cards = cards.push( + self.render + .text("No open windows") + .size(14) + .class(theme::Text::Color(muted)), + ); + } + body = body.push(container(cards).padding([0, 16])); + } else if launcher.alternatives || (launcher.query.is_empty() && launcher.category.is_some()) { + let start = launcher.selected.saturating_sub(4); + let mut rows = column![].spacing(0); + for (index, result) in launcher.rows.iter().enumerate().skip(start).take(5) { + let symbol = match result.category() { + Some(Category::Windows) => ferese_theme::icons::WINDOW, + Some(Category::Files) => ferese_theme::icons::FOLDER, + Some(Category::Apps) => ferese_theme::icons::APPS, + None => ferese_theme::icons::morph::CONTROLS, + }; + let tone = accent; + let result_icon: Ui<'_> = match &result.icon { + Some(handle) => cosmic::widget::icon::icon(handle.clone()) + .size(24) + .opacity(opacity) + .into(), + None => bar_icon(symbol, 20, tone).into(), + }; + let glyph = container(result_icon) + .center_x(36) + .center_y(36) + .class(fill(tone.scale_alpha(0.1), 12.)); + let labels = column![ + self.render + .text(&result.title) + .font(medium) + .size(15) + .class(theme::Text::Color(text)), + self.render + .text(&result.subtitle) + .size(12.5) + .class(theme::Text::Color(muted)) + ] + .spacing(3) + .width(Length::Fill); + let mut content = row![glyph, labels].spacing(12).align_y(cosmic::iced::Alignment::Center); + if index == launcher.selected { + content = content.push( + self.render + .text(if result.requires_alt() { "Alt+Enter" } else { "Enter" }) + .size(12) + .class(theme::Text::Color(muted)), + ); + } + rows = rows.push( + button::custom( + container(content) + .height(60) + .clip(true) + .align_y(cosmic::iced::Alignment::Center), + ) + .width(Length::Fill) + .padding([0, 12]) + .class(button_radius( + if index == launcher.selected { + palette.accent.scale_alpha(opacity) + } else { + Color::TRANSPARENT + }, + 18., + )) + .on_press(message(Message::ActivateRow(launcher.id, result.id.clone()))), + ); + } + if launcher.rows.is_empty() { + rows = rows.push( + container( + self.render + .text(launcher.status_message().unwrap_or(if launcher.running { + "Searching…" + } else { + "No matches" + })) + .size(13) + .class(theme::Text::Color(muted)), + ) + .height(60) + .padding(16), + ); + } + body = body.push(container(rows).padding([0, 12])); + } + let available_height = self + .outputs + .iter() + .find(|o| o.name.as_deref() == Some(&launcher.output)) + .and_then(|o| o.size) + .map(|(_, h)| h as f32); + let top = available_height.map_or(launcher.top, |h| { + (h * 0.3).min(h - launcher.height.current as f32 - 16.).max(16.) + }); + let mut background = color_with_opacity(self.config.theme.surface_popover, opacity); + if self.config.theme.high_contrast || self.config.theme.reduce_transparency { + background.a = opacity; + } + let compositor_material = launcher.effects.is_some(); + let panel = container(body) + .id("ferese-blur-card") + .width(if launcher.apps_browse() { 720 } else { 560 }) + .height(launcher.height.current as f32) + .clip(true) + .class(theme::Container::custom(move |_| container::Style { + background: (!compositor_material).then_some(background.into()), + text_color: Some(text), + border: Border { + color: muted.scale_alpha(0.16), + width: 0.5, + radius: 28.into(), + shape: Shape::Continuous, + ..Default::default() + }, + ..Default::default() + })); + container(motion::scaled( + panel.into(), + 0.97 + 0.03 * opacity, + launcher.regions.clone(), + )) + .width(Length::Fill) + .align_x(cosmic::iced::Alignment::Center) + .padding(cosmic::iced::Padding { + top, + ..Default::default() + }) + .into() + } +} diff --git a/shell/ferese-shell/src/launcher/work.rs b/shell/ferese-shell/src/launcher/work.rs new file mode 100644 index 0000000..fe4e6f1 --- /dev/null +++ b/shell/ferese-shell/src/launcher/work.rs @@ -0,0 +1,15 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +#[derive(Clone, Default, Debug)] +pub(super) struct Cancellation(Arc); +impl Cancellation { + pub(super) fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + pub(super) fn cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index ff3589c..bed0ea6 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -12,6 +12,7 @@ mod control; mod display_mode; mod ipc_subscription; mod keybinding_guide; +mod launcher; mod media; mod motion; mod notification_ui; @@ -170,6 +171,8 @@ struct FereseShell { pending_config_error: Option, menu: Option, system_modal: Option, + launcher: Option, + launcher_files: launcher::files::Files, display_mode: display_mode::Model, guide_shown: bool, guide_load: keybinding_guide::LoadState, @@ -194,6 +197,8 @@ struct OutputSurfaces { #[derive(Clone, Debug)] enum Message { + Launcher(launcher::Message), + LauncherEnter(window::Id, bool), NightLightTemperature(u16), CommitNightLightTemperature, HubOpen(window::Id, status_ui::Menu), @@ -335,6 +340,8 @@ impl cosmic::Application for FereseShell { pending_config_error, menu: None, system_modal: None, + launcher: None, + launcher_files: Default::default(), display_mode: display_mode::Model::default(), guide_shown: false, guide_load: Default::default(), @@ -365,6 +372,7 @@ impl cosmic::Application for FereseShell { fn subscription(&self) -> Subscription { Subscription::batch([ + self.launcher_subscription(), self.control.as_ref().map_or_else(Subscription::none, |control| { control.subscription().map(|_| Message::ControlReady) }), @@ -376,8 +384,11 @@ impl cosmic::Application for FereseShell { event::listen_with(|event, status, id| match &event { Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter), + modifiers, .. - }) if status == cosmic::iced::event::Status::Captured => None, + }) if status == cosmic::iced::event::Status::Captured => { + Some(Message::LauncherEnter(id, modifiers.alt())) + } Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING .load(Ordering::Relaxed) .then_some(Message::Event(event, id)), @@ -407,6 +418,14 @@ impl cosmic::Application for FereseShell { fn update(&mut self, message: Self::Message) -> Task { let task = match message { + Message::Launcher(message) => self.update_launcher(message), + Message::LauncherEnter(id, alt) => { + if self.launcher.as_ref().is_some_and(|l| l.id == id) { + self.update_launcher(launcher::Message::Accept(alt)) + } else { + Task::none() + } + } Message::HubOpen(id, kind) => self.open_hub(id, kind), Message::HubClose(id) => self.close_hub(id), Message::HubWorkspaceScroll(id, delta) => self.scroll_hub_workspaces(id, delta), @@ -562,6 +581,7 @@ impl cosmic::Application for FereseShell { } Ok((_connection, surface)) => { self.attach_power_material(id, &surface); + self.attach_launcher_effects(id, &surface); if let Some(entry) = &mut self.notification_surface && entry.id == id && entry.effects.is_none() @@ -857,6 +877,7 @@ impl cosmic::Application for FereseShell { if let Some(snapshot) = poll.snapshot { let mode_was_active = self.snapshot.binding_mode.is_some(); self.snapshot = snapshot; + reload_task = Task::batch([reload_task, self.refresh_launcher()]); let mode = self.binding_mode_transition(mode_was_active); let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active); let mut tasks = vec![ @@ -919,6 +940,12 @@ impl cosmic::Application for FereseShell { } for command in poll.commands { let task = match command { + control::ControlCommand::DismissLauncher => self.destroy_launcher(), + control::ControlCommand::Thumbnail(token, window, image) => { + self.launcher_thumbnail(token, window, image); + Task::none() + } + control::ControlCommand::ToggleLauncher(output) => self.toggle_launcher(output), control::ControlCommand::ToggleDisplays(output) => self.open_display_modal(Some(&output)), control::ControlCommand::MonitorsChanged => self.load_display_inventory(), control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output), @@ -1177,6 +1204,8 @@ mod tests { pending_config_error: None, menu: None, system_modal: None, + launcher: None, + launcher_files: Default::default(), display_mode: Default::default(), guide_shown: false, guide_load: Default::default(), diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index fa75541..81a44eb 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -348,18 +348,31 @@ pub fn animated_from_edge<'a, M: 'a>( Element::new(Motion { content, progress, - regions, + regions: Some(regions), radius, + scale: 1., direction: if edge == ferese_panel::Edge::Top { -1. } else { 1. }, }) } +pub fn scaled<'a, M: 'a>(content: Element<'a, M>, scale: f32, regions: Regions) -> Element<'a, M> { + Element::new(Motion { + content, + progress: 1., + regions: Some(regions), + radius: 28., + direction: 0., + scale, + }) +} + struct Motion<'a, M> { content: Element<'a, M>, progress: f32, - regions: Regions, + regions: Option, radius: f32, direction: f32, + scale: f32, } impl Motion<'_, M> { @@ -367,9 +380,11 @@ impl Motion<'_, M> { Vector::new(0.0, self.direction * 4.0 * (1.0 - self.progress)) } - fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor { + fn cursor(&self, layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Cursor { cursor.position().map_or(mouse::Cursor::Unavailable, |p| { - mouse::Cursor::Available(p - self.translation()) + mouse::Cursor::Available( + layout.bounds().center() + (p - layout.bounds().center() - self.translation()) / self.scale, + ) }) } } @@ -381,13 +396,16 @@ impl Widget for Motion<'_, M> { tree: &widget::Tree, cursor: mouse::Cursor, ) -> iced_accessibility::A11yTree { - let mut nodes = self.content.as_widget().a11y_nodes(layout, tree, self.cursor(cursor)); + let mut nodes = self + .content + .as_widget() + .a11y_nodes(layout, tree, self.cursor(layout, cursor)); let offset = self.translation(); for node in nodes.root_mut() { - translate_accessible_node(node, offset); + transform_accessible_node(node, offset, layout.bounds().center(), self.scale); } for node in nodes.children_mut() { - translate_accessible_node(node, offset); + transform_accessible_node(node, offset, layout.bounds().center(), self.scale); } nodes } @@ -432,19 +450,27 @@ impl Widget for Motion<'_, M> { shell: &mut Shell<'_, M>, viewport: &Rectangle, ) { - let mut collector = CollectRegions(Vec::new()); - self.content - .as_widget_mut() - .operate(tree, layout, renderer, &mut collector); - if collector.0.is_empty() { - // A single-content popover (e.g. battery) has one inner card, not - // a full-surface rectangle including the outer transparent padding. - if let Some(content) = layout.children().next() { - collector.0.push(content.bounds()); + if let Some(regions) = &self.regions { + let mut collected = regions.lock().unwrap(); + collected.clear(); + self.content + .as_widget_mut() + .operate(tree, layout, renderer, &mut ReuseRegions(&mut collected)); + if collected.is_empty() + && let Some(content) = layout.children().next() + { + let bounds = content.bounds(); + collected.push([bounds.x, bounds.y, bounds.width, bounds.height, 0.]); } + transform_regions( + &mut collected, + layout.bounds().center(), + self.scale, + self.translation(), + self.radius, + ); } - *self.regions.lock().unwrap() = collector.into_regions(self.translation(), self.radius); - let cursor = self.cursor(cursor); + let cursor = self.cursor(layout, cursor); self.content .as_widget_mut() .update(tree, event, layout, cursor, renderer, clipboard, shell, viewport); @@ -461,16 +487,25 @@ impl Widget for Motion<'_, M> { viewport: &Rectangle, ) { let v = self.translation(); - let transform = Transformation::translate(v.x, v.y); + let center = layout.bounds().center(); + let transform = Transformation::translate(center.x + v.x, center.y + v.y) + * Transformation::scale(self.scale) + * Transformation::translate(-center.x, -center.y); // Layout/region collection still run before the surface starts opening. // No child may paint an opaque control before the presentation begins. if self.progress <= 0.0 { return; } renderer.with_transformation(transform, |renderer| { - self.content - .as_widget() - .draw(tree, renderer, theme, style, layout, self.cursor(cursor), viewport) + self.content.as_widget().draw( + tree, + renderer, + theme, + style, + layout, + self.cursor(layout, cursor), + viewport, + ) }); } @@ -484,7 +519,7 @@ impl Widget for Motion<'_, M> { ) -> mouse::Interaction { self.content .as_widget() - .mouse_interaction(tree, layout, self.cursor(cursor), viewport, renderer) + .mouse_interaction(tree, layout, self.cursor(layout, cursor), viewport, renderer) } fn operate( @@ -512,56 +547,55 @@ impl Widget for Motion<'_, M> { } } -fn translate_accessible_node(node: &mut iced_accessibility::A11yNode, offset: Vector) { +fn transform_accessible_node( + node: &mut iced_accessibility::A11yNode, + offset: Vector, + center: cosmic::iced::Point, + scale: f32, +) { if let Some(mut bounds) = node.node().bounds() { - bounds.x0 += f64::from(offset.x); - bounds.x1 += f64::from(offset.x); - bounds.y0 += f64::from(offset.y); - bounds.y1 += f64::from(offset.y); + let x = f64::from(center.x); + let y = f64::from(center.y); + bounds.x0 = x + (bounds.x0 - x) * f64::from(scale) + f64::from(offset.x); + bounds.x1 = x + (bounds.x1 - x) * f64::from(scale) + f64::from(offset.x); + bounds.y0 = y + (bounds.y0 - y) * f64::from(scale) + f64::from(offset.y); + bounds.y1 = y + (bounds.y1 - y) * f64::from(scale) + f64::from(offset.y); node.node_mut().set_bounds(bounds); } } -struct CollectRegions(Vec); - -impl CollectRegions { - fn into_regions(self, translation: Vector, radius: f32) -> Vec<[f32; 5]> { - self.0 - .into_iter() - .take(32) - .filter(|r| r.width > 0.0 && r.height > 0.0) - .map(|r| { - [ - r.x + translation.x, - r.y + translation.y, - r.width, - r.height, - radius.max(0.0), - ] - }) - .collect() +fn transform_regions(regions: &mut [[f32; 5]], center: cosmic::iced::Point, scale: f32, offset: Vector, radius: f32) { + for region in regions { + region[0] = center.x + (region[0] - center.x) * scale + offset.x; + region[1] = center.y + (region[1] - center.y) * scale + offset.y; + region[2] *= scale; + region[3] *= scale; + region[4] = radius.max(0.) * scale; } } -impl widget::Operation for CollectRegions { +struct ReuseRegions<'a>(&'a mut Vec<[f32; 5]>); +impl widget::Operation for ReuseRegions<'_> { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { children(self); } - fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { - if id == Some(&widget::Id::new("ferese-blur-card")) { - let contains = |outer: &Rectangle, inner: &Rectangle| { - outer.x <= inner.x - && outer.y <= inner.y - && outer.x + outer.width >= inner.x + inner.width - && outer.y + outer.height >= inner.y + inner.height - }; - // Nested controls belong to their enclosing card's single blur pass. - if self.0.iter().any(|outer| contains(outer, &bounds)) { - return; - } - self.0.retain(|inner| !contains(&bounds, inner)); - self.0.push(bounds); + if id != Some(&widget::Id::new("ferese-blur-card")) || bounds.width <= 0. || bounds.height <= 0. { + return; + } + let r = [bounds.x, bounds.y, bounds.width, bounds.height, 0.]; + let contains = |outer: &[f32; 5], inner: &[f32; 5]| { + outer[0] <= inner[0] + && outer[1] <= inner[1] + && outer[0] + outer[2] >= inner[0] + inner[2] + && outer[1] + outer[3] >= inner[1] + inner[3] + }; + if self.0.iter().any(|outer| contains(outer, &r)) { + return; + } + self.0.retain(|inner| !contains(&r, inner)); + if self.0.len() < 32 { + self.0.push(r); } } } @@ -728,22 +762,31 @@ mod tests { #[test] fn material_regions_preserve_fractional_layout_and_translation() { - let collector = CollectRegions(vec![Rectangle::new((0.25, 2.5).into(), (100.25, 40.5).into())]); - assert_eq!( - collector.into_regions(Vector::new(0.125, -0.25), 13.5), - vec![[0.375, 2.25, 100.25, 40.5, 13.5]] + let mut regions = vec![[0.25, 2.5, 100.25, 40.5, 0.]]; + transform_regions( + &mut regions, + cosmic::iced::Point::ORIGIN, + 1., + Vector::new(0.125, -0.25), + 13.5, ); + assert_eq!(regions, vec![[0.375, 2.25, 100.25, 40.5, 13.5]]); } #[test] fn centered_modal_material_tracks_surface_position_and_animation() { - let mut collector = CollectRegions(Vec::new()); + let mut regions = Vec::new(); + let mut collector = ReuseRegions(&mut regions); let card = Rectangle::new((380.0, 420.0).into(), (440.0, 220.0).into()); widget::Operation::container(&mut collector, Some(&widget::Id::new("ferese-blur-card")), card); - assert_eq!( - collector.into_regions(Vector::new(0.0, -2.0), 14.0), - vec![[380., 418., 440., 220., 14.]] + transform_regions( + &mut regions, + cosmic::iced::Point::ORIGIN, + 1., + Vector::new(0.0, -2.0), + 14.0, ); + assert_eq!(regions, vec![[380., 418., 440., 220., 14.]]); } #[test] @@ -901,7 +944,8 @@ mod tests { #[test] fn material_regions_include_cards_but_not_the_parent_or_gaps() { - let mut collector = CollectRegions(Vec::new()); + let mut regions = Vec::new(); + let mut collector = ReuseRegions(&mut regions); let id = widget::Id::new("ferese-blur-card"); widget::Operation::container( &mut collector, @@ -912,12 +956,18 @@ mod tests { let second = Rectangle::new((160.0, 12.0).into(), (140.0, 80.0).into()); widget::Operation::container(&mut collector, Some(&id), first); widget::Operation::container(&mut collector, Some(&id), second); - assert_eq!(collector.0, vec![first, second]); + assert_eq!( + *collector.0, + vec![[12., 12., 140., 80., 0.], [160., 12., 140., 80., 0.]] + ); widget::Operation::container( &mut collector, Some(&id), Rectangle::new((20.0, 20.0).into(), (40.0, 40.0).into()), ); - assert_eq!(collector.0, vec![first, second]); + assert_eq!( + *collector.0, + vec![[12., 12., 140., 80., 0.], [160., 12., 140., 80., 0.]] + ); } } diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 4dc8326..580d1be 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -37,7 +37,17 @@ impl FereseShell { } else { Task::none() }; + let launcher = if self + .launcher + .as_ref() + .is_some_and(|l| Some(&l.output) == entry.name.as_ref()) + { + self.destroy_launcher() + } else { + Task::none() + }; let mut tasks = vec![ + launcher, self.dismiss_workspace_tooltip(), menu.chain(destroy_layer_surface(entry.bar)), self.rebuild_system_modal(), @@ -211,6 +221,7 @@ impl FereseShell { self.outputs .iter() .any(|entry| entry.bar == id || entry.wallpaper == Some(id)) + || self.launcher.as_ref().is_some_and(|launcher| launcher.id == id) || self.menu.as_ref().is_some_and(|menu| menu.id == id) || self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) || self @@ -221,6 +232,9 @@ impl FereseShell { } pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task { + if let Some(task) = self.launcher_event(&event, id) { + return task; + } if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) { return self.hub_hover(id, true); } @@ -736,16 +750,29 @@ impl EffectsBinding { .map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32) .collect(); validate_region_counts(regions.len(), values.len())?; - let next = (regions.to_vec(), opacity, values, role); - if self.presentation.borrow().as_ref() != Some(&next) { - self.surface.set_presentation( - role, - encode_regions(regions), - opacity, - next.2.iter().flat_map(|value| value.to_ne_bytes()).collect(), - ); - self.connection.flush()?; - *self.presentation.borrow_mut() = Some(next); + let mut previous = self.presentation.borrow_mut(); + if previous + .as_ref() + .is_some_and(|old| old.0 == regions && old.1 == opacity && old.2 == values && old.3 == role) + { + return Ok(()); + } + self.surface.set_presentation( + role, + encode_regions(regions), + opacity, + values.iter().flat_map(|value| value.to_ne_bytes()).collect(), + ); + self.connection.flush()?; + if let Some(old) = previous.as_mut() { + old.0.clear(); + old.0.extend_from_slice(regions); + old.1 = opacity; + old.2.clear(); + old.2.extend_from_slice(&values); + old.3 = role; + } else { + *previous = Some((regions.to_vec(), opacity, values, role)); } Ok(()) } diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index ef142ca..1250c58 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -181,6 +181,7 @@ impl FereseShell { }) .unwrap_or(0); let mut tasks = vec![ + self.destroy_launcher(), self.destroy_system_modal(true), self.destroy_menu(), self.close_notification_history(),