Add native shell launcher with themed search and browsing

This commit is contained in:
Samuel Onoja committed 2026-10-13 23:15:44 +01:00
1 parent 2a400b26d0
commit d8bdb68b2a
43 files changed
+3341 -105

No files matched your search

Generated
+538 -16
View File
@@ -568,6 +568,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.72.1" version = "0.72.1"
@@ -617,6 +626,19 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "block" name = "block"
version = "0.1.6" version = "0.1.6"
@@ -742,6 +764,12 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "byteorder-lite" name = "byteorder-lite"
version = "0.1.0" version = "0.1.0"
@@ -1032,6 +1060,12 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]] [[package]]
name = "cookie-factory" name = "cookie-factory"
version = "0.3.3" version = "0.3.3"
@@ -1129,11 +1163,11 @@ dependencies = [
"atomicwrites", "atomicwrites",
"cosmic-config-derive", "cosmic-config-derive",
"cosmic-settings-daemon", "cosmic-settings-daemon",
"dirs", "dirs 6.0.0",
"futures-util", "futures-util",
"iced_futures", "iced_futures",
"known-folders", "known-folders",
"notify", "notify 8.2.0",
"ron", "ron",
"serde", "serde",
"tokio", "tokio",
@@ -1220,7 +1254,7 @@ dependencies = [
"configparser", "configparser",
"cosmic-config", "cosmic-config",
"csscolorparser", "csscolorparser",
"dirs", "dirs 6.0.0",
"hex_color", "hex_color",
"palette", "palette",
"ron", "ron",
@@ -1256,6 +1290,43 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.23" version = "0.8.23"
@@ -1312,7 +1383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "168dbdf4dced8f57fa4246ac9080598452b49d70bb2d0e46422142f113140dd8" checksum = "168dbdf4dced8f57fa4246ac9080598452b49d70bb2d0e46422142f113140dd8"
dependencies = [ dependencies = [
"num-traits", "num-traits",
"phf", "phf 0.13.1",
"serde", "serde",
"uncased", "uncased",
] ]
@@ -1404,6 +1475,12 @@ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
[[package]]
name = "deranged"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]] [[package]]
name = "derive_setters" name = "derive_setters"
version = "0.1.9" version = "0.1.9"
@@ -1448,13 +1525,34 @@ dependencies = [
"crypto-common 0.2.2", "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]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [ 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]] [[package]]
@@ -1465,7 +1563,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users 0.5.2",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1534,6 +1632,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" 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]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -1624,6 +1731,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "either" name = "either"
version = "1.18.0" version = "1.18.0"
@@ -1755,7 +1868,7 @@ dependencies = [
"jiff", "jiff",
"libc", "libc",
"memmap2 0.9.11", "memmap2 0.9.11",
"notify", "notify 8.2.0",
"serde", "serde",
"serde_json", "serde_json",
"smithay", "smithay",
@@ -1892,7 +2005,7 @@ dependencies = [
"iced_accessibility", "iced_accessibility",
"image", "image",
"libcosmic", "libcosmic",
"notify", "notify 8.2.0",
"serde_json", "serde_json",
"shlex 1.3.0", "shlex 1.3.0",
"tempfile", "tempfile",
@@ -1920,12 +2033,14 @@ dependencies = [
"ferese-protocols", "ferese-protocols",
"ferese-theme", "ferese-theme",
"ferese-theme-client", "ferese-theme-client",
"fff-search",
"freedesktop-desktop-entry", "freedesktop-desktop-entry",
"iced_accessibility", "iced_accessibility",
"image", "image",
"jiff", "jiff",
"libc", "libc",
"libcosmic", "libcosmic",
"mime_guess",
"pipewire", "pipewire",
"serde", "serde",
"serde_json", "serde_json",
@@ -1987,6 +2102,83 @@ dependencies = [
"tempfile", "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]] [[package]]
name = "find-crate" name = "find-crate"
version = "0.6.3" version = "0.6.3"
@@ -2407,6 +2599,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "gl_generator" name = "gl_generator"
version = "0.14.0" version = "0.14.0"
@@ -2468,12 +2672,31 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "glidesort"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0"
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.4" version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" 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]] [[package]]
name = "glow" name = "glow"
version = "0.16.0" version = "0.16.0"
@@ -2657,6 +2880,44 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 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]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.3.9" version = "0.3.9"
@@ -3122,6 +3383,22 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "image" name = "image"
version = "0.25.10" version = "0.25.10"
@@ -3551,7 +3828,7 @@ dependencies = [
"jiff", "jiff",
"log", "log",
"palette", "palette",
"phf", "phf 0.13.1",
"rfd", "rfd",
"roxmltree", "roxmltree",
"rust-embed", "rust-embed",
@@ -3566,6 +3843,18 @@ dependencies = [
"zbus", "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]] [[package]]
name = "libloading" name = "libloading"
version = "0.8.9" version = "0.8.9"
@@ -3651,6 +3940,18 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "lilt" name = "lilt"
version = "0.8.2" version = "0.8.2"
@@ -3702,6 +4003,17 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" 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]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -3991,6 +4303,15 @@ dependencies = [
"jni-sys 0.3.1", "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]] [[package]]
name = "nix" name = "nix"
version = "0.31.3" version = "0.31.3"
@@ -4040,6 +4361,26 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "notify-types" name = "notify-types"
version = "2.1.0" version = "2.1.0"
@@ -4058,6 +4399,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.47" version = "0.1.47"
@@ -4266,6 +4613,16 @@ dependencies = [
"objc2-foundation 0.2.2", "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]] [[package]]
name = "objc2-core-video" name = "objc2-core-video"
version = "0.3.2" version = "0.3.2"
@@ -4510,6 +4867,16 @@ dependencies = [
"ttf-parser", "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]] [[package]]
name = "palette" name = "palette"
version = "0.7.7" version = "0.7.7"
@@ -4519,7 +4886,7 @@ dependencies = [
"approx 0.5.1", "approx 0.5.1",
"palette_derive", "palette_derive",
"palette_math", "palette_math",
"phf", "phf 0.13.1",
"serde", "serde",
] ]
@@ -4582,23 +4949,49 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [ dependencies = [
"phf_macros", "phf_macros 0.13.1",
"phf_shared", "phf_shared 0.13.1",
"serde", "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]] [[package]]
name = "phf_generator" name = "phf_generator"
version = "0.13.1" version = "0.13.1"
@@ -4606,7 +4999,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [ dependencies = [
"fastrand", "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]] [[package]]
@@ -4615,14 +5021,23 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [ dependencies = [
"phf_generator", "phf_generator 0.13.1",
"phf_shared", "phf_shared 0.13.1",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"uncased", "uncased",
] ]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.13.1" version = "0.13.1"
@@ -4789,6 +5204,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -4995,6 +5416,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" 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]] [[package]]
name = "read-fonts" name = "read-fonts"
version = "0.37.0" version = "0.37.0"
@@ -5054,6 +5495,17 @@ dependencies = [
"bitflags 2.13.2", "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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@@ -5818,6 +6270,12 @@ dependencies = [
"zeno", "zeno",
] ]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@@ -5840,6 +6298,15 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "synstructure" name = "synstructure"
version = "0.14.0" version = "0.14.0"
@@ -5962,6 +6429,36 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "tiny-skia" name = "tiny-skia"
version = "0.11.4" version = "0.11.4"
@@ -6154,6 +6651,19 @@ dependencies = [
"tracing-core", "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]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
@@ -6453,6 +6963,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -7736,7 +8252,7 @@ name = "xdg-desktop-portal-ferese"
version = "0.1.0-beta" version = "0.1.0-beta"
dependencies = [ dependencies = [
"calloop 0.14.4", "calloop 0.14.4",
"dirs", "dirs 6.0.0",
"ferese-config", "ferese-config",
"ferese-ipc", "ferese-ipc",
"ferese-protocols", "ferese-protocols",
@@ -7819,6 +8335,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xxhash-rust"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]] [[package]]
name = "yansi" name = "yansi"
version = "1.0.1" version = "1.0.1"
+2 -1
View File
@@ -184,6 +184,7 @@ impl DirectBackendState {
window: &smithay::desktop::Window, window: &smithay::desktop::Window,
output: &Output, output: &Output,
cursor: Option<smithay::utils::Rectangle<i32, smithay::utils::Logical>>, cursor: Option<smithay::utils::Rectangle<i32, smithay::utils::Logical>>,
scale: f64,
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> { ) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
let device = self let device = self
.devices .devices
@@ -194,7 +195,7 @@ impl DirectBackendState {
&mut device.renderer, &mut device.renderer,
window, window,
window.geometry(), window.geometry(),
output.current_scale().fractional_scale(), scale,
cursor.map(|rect| (state, rect)), cursor.map(|rect| (state, rect)),
) )
} }
+7 -3
View File
@@ -1214,7 +1214,7 @@ mod tests {
let input = config.input_settings().unwrap(); let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap(); let bindings = config.bindings(&input).unwrap();
assert_eq!(bindings.len(), 62); assert_eq!(bindings.len(), 63);
for (shift, action) in [ for (shift, action) in [
(false, BindingAction::ToggleMaximized), (false, BindingAction::ToggleMaximized),
(true, BindingAction::ToggleFullscreen), (true, BindingAction::ToggleFullscreen),
@@ -1225,6 +1225,10 @@ mod tests {
&& binding.action == action)); && binding.action == action));
} }
assert!(bindings.iter().any(|binding| binding.action == BindingAction::Exit)); 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 assert!(bindings.iter().any(|binding| binding.modifiers.logo
&& binding.modifiers.shift && binding.modifiers.shift
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_t) && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_t)
@@ -1262,7 +1266,7 @@ mod tests {
let input = replaced.input_settings().unwrap(); let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap(); let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 62); assert_eq!(bindings.len(), 63);
assert!(bindings.iter().any(|binding| { assert!(bindings.iter().any(|binding| {
binding.action binding.action
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into()) == 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 input = unbound.input_settings().unwrap();
let bindings = unbound.bindings(&input).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)); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
} }
+7
View File
@@ -378,6 +378,10 @@ pub(super) fn parse_action(
no_argument()?; no_argument()?;
Ok(BindingAction::ToggleDisplayMode) Ok(BindingAction::ToggleDisplayMode)
} }
"toggle-launcher" => {
no_argument()?;
Ok(BindingAction::ToggleLauncher)
}
"toggle-keybinding-guide" => { "toggle-keybinding-guide" => {
no_argument()?; no_argument()?;
Ok(BindingAction::ToggleKeybindingGuide) Ok(BindingAction::ToggleKeybindingGuide)
@@ -405,6 +409,7 @@ pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
pub(super) fn default_bindings() -> Vec<BindingConfig> { pub(super) fn default_bindings() -> Vec<BindingConfig> {
let mut bindings = vec![ let mut bindings = vec![
binding("Super+Space", "toggle-launcher", None),
binding("Super+Enter", "spawn", Some("terminal")), binding("Super+Enter", "spawn", Some("terminal")),
binding("Super+Shift+S", "spawn", Some("screenshot")), binding("Super+Shift+S", "spawn", Some("screenshot")),
binding("Print", "spawn", Some("screenshot-full")), binding("Print", "spawn", Some("screenshot-full")),
@@ -524,6 +529,7 @@ pub enum BindingAction {
ToggleFloating, ToggleFloating,
ToggleOverview, ToggleOverview,
ToggleKeybindingGuide, ToggleKeybindingGuide,
ToggleLauncher,
ToggleDisplayMode, ToggleDisplayMode,
Media(&'static str), Media(&'static str),
} }
@@ -648,6 +654,7 @@ impl Binding {
.into(), .into(),
BindingAction::ToggleOverview => "Open or close overview".into(), BindingAction::ToggleOverview => "Open or close overview".into(),
BindingAction::ToggleDisplayMode => "Open display mode chooser".into(), BindingAction::ToggleDisplayMode => "Open display mode chooser".into(),
BindingAction::ToggleLauncher => "Open or close launcher".into(),
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(), BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
}; };
let mut parts = Vec::new(); let mut parts = Vec::new();
+1 -1
View File
@@ -330,7 +330,7 @@ impl Ferese {
) )
} else { } else {
let mut backend = self.direct_backend.take()?; 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); self.direct_backend = Some(backend);
result result
}; };
+3
View File
@@ -1267,6 +1267,9 @@ impl Ferese {
BindingAction::ToggleDisplayMode => { BindingAction::ToggleDisplayMode => {
self.toggle_display_mode(); self.toggle_display_mode();
} }
BindingAction::ToggleLauncher => {
self.toggle_launcher();
}
BindingAction::ToggleKeybindingGuide => { BindingAction::ToggleKeybindingGuide => {
self.toggle_keybinding_guide(); self.toggle_keybinding_guide();
} }
+8
View File
@@ -1083,6 +1083,14 @@ impl Ferese {
"toggle-display-mode" => { "toggle-display-mode" => {
self.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" => { "toggle-keybinding-guide" => {
if !self.toggle_keybinding_guide() { if !self.toggle_keybinding_guide() {
return Err(CommandError::new( return Err(CommandError::new(
+1
View File
@@ -427,6 +427,7 @@ impl SessionLockHandler for Ferese {
} }
fn lock(&mut self, confirmation: SessionLocker) { fn lock(&mut self, confirmation: SessionLocker) {
self.dismiss_launcher();
if !self.session_lock.lifecycle.can_acquire() { if !self.session_lock.lifecycle.can_acquire() {
return; return;
} }
+102 -1
View File
@@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities;
use crate::state::ClientState; use crate::state::ClientState;
pub(crate) fn init_global(display: &DisplayHandle) { pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseShellManagerV1, _>(9, ()); display.create_global::<Ferese, FereseShellManagerV1, _>(10, ());
} }
pub(crate) fn config_chunks(source: &str) -> Vec<&str> { pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
@@ -98,6 +98,18 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
return; return;
} }
match request { 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( ferese_shell_v1::Request::ActivateWindow { window_hi, window_lo } => state.handle_shell_window_request(
shell, shell,
ferese_shell_v1::FailedRequest::ActivateWindow, ferese_shell_v1::FailedRequest::ActivateWindow,
@@ -246,6 +258,22 @@ impl Ferese {
true 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 { pub(crate) fn toggle_keybinding_guide(&mut self) -> bool {
if self.session_lock.active() { if self.session_lock.active() {
return false; 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))
}
}
-3
View File
@@ -647,9 +647,6 @@ impl Ferese {
let Some(output_id) = self.output_workspaces.output_for_workspace(destination) else { let Some(output_id) = self.output_workspaces.output_for_workspace(destination) else {
return false; return false;
}; };
if self.output_workspaces.output_for_workspace(source) != Some(output_id) {
return false;
}
if source == destination { if source == destination {
return true; return true;
} }
@@ -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.scrolling_tab_bar(a).is_none());
assert!(state.tab_indicator.hover.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));
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="4" width="6" height="6" rx="2"/><rect x="14" y="4" width="6" height="6" rx="2"/><rect x="4" y="14" width="6" height="6" rx="2"/><rect x="14" y="14" width="6" height="6" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><rect x="3.5" y="4.5" width="17" height="15" rx="3"/><path d="M4 9h16"/></svg>

After

Width:  |  Height:  |  Size: 238 B

+4
View File
@@ -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_MIRROR: &[u8] = include_bytes!("../assets/icons/display-mirror.svg");
pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/display.svg"); pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/display.svg");
pub const FERESE: &[u8] = include_bytes!("../../../assets/branding/ferese-symbolic.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 OVERVIEW: &[u8] = include_bytes!("../assets/icons/overview.svg");
pub const AIRPLANE: &[u8] = include_bytes!("../assets/icons/status/airplane.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"); pub const BATTERY_25: &[u8] = include_bytes!("../assets/icons/status/battery-25.svg");
+1 -1
View File
@@ -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-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 | | `focus-mru-next`, `focus-mru-previous` | None; cycle windows in focus order |
| `none` | None; ignore this trigger | | `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 ```kdl
commands { commands {
+12
View File
@@ -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 The [example config](../packaging/config.kdl) adds the app, media and arrow-key
shortcuts listed below. 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 ## Move and resize with the mouse
| Action | Result | | Action | Result |
+13
View File
@@ -19,6 +19,11 @@ pub struct Cli {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum Command { pub enum Command {
/// Show or hide the application launcher.
Launcher {
#[command(subcommand)]
action: LauncherAction,
},
/// Publish bounded activity data owned by the invoking shell. /// Publish bounded activity data owned by the invoking shell.
Activity { Activity {
#[command(subcommand)] #[command(subcommand)]
@@ -235,10 +240,18 @@ fn value_name(value: &impl ValueEnum) -> String {
.to_owned() .to_owned()
} }
#[derive(Debug, Subcommand)]
pub enum LauncherAction {
Toggle,
}
impl Command { impl Command {
pub fn ipc(&self) -> Option<(&'static str, Value)> { pub fn ipc(&self) -> Option<(&'static str, Value)> {
let (command, args) = match self { let (command, args) = match self {
Self::Activity { .. } | Self::Run { .. } => return None, Self::Activity { .. } | Self::Run { .. } => return None,
Self::Launcher {
action: LauncherAction::Toggle,
} => ("toggle-launcher", json!({})),
Self::FocusedWindow => ("get-focused-window", json!({})), Self::FocusedWindow => ("get-focused-window", json!({})),
Self::Windows => ("get-windows", json!({})), Self::Windows => ("get-windows", json!({})),
Self::IdleInhibition => ("get-idle-inhibition", json!({})), Self::IdleInhibition => ("get-idle-inhibition", json!({})),
+15
View File
@@ -38,6 +38,7 @@ fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() {
for args in [ for args in [
&["--help"][..], &["--help"][..],
&["theme", "--help"], &["theme", "--help"],
&["launcher", "--help"],
&["media", "--help"], &["media", "--help"],
&["xwayland", "--help"], &["xwayland", "--help"],
&["screenshot", "--help"], &["screenshot", "--help"],
@@ -470,3 +471,17 @@ sys.exit(19)
.unwrap(); .unwrap();
assert!(elapsed < 1.5, "stdout reader stalled the child for {elapsed}s"); 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();
}
+1 -1
View File
@@ -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') 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' depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm'
'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire' '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') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils')
checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth')
optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection' optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection'
+1
View File
@@ -52,6 +52,7 @@ pkgbase = ferese-git
depends = gstreamer depends = gstreamer
depends = xdg-desktop-portal depends = xdg-desktop-portal
depends = xdg-desktop-portal-gtk depends = xdg-desktop-portal-gtk
depends = glib2
optdepends = brightnessctl: brightness controls optdepends = brightnessctl: brightness controls
optdepends = slurp: screenshot region selection optdepends = slurp: screenshot region selection
optdepends = satty: screenshot editing optdepends = satty: screenshot editing
+1 -1
View File
@@ -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') 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' depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm'
'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire' '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') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils')
checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth')
optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection' optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection'
+1 -1
View File
@@ -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: pipewire-devel gstreamer1-devel dbus-daemon desktop-file-utils polkit-libs pam
BuildRequires: xorg-x11-xauth BuildRequires: xorg-x11-xauth
Requires: pam polkit polkit-libs foot pipewire wireplumber python3 bash util-linux dbus-daemon dbus-tools systemd 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. # Loaded at runtime, so ELF dependency detection does not find these libraries.
Requires: libEGL.so.1()(64bit) fontconfig freetype Requires: libEGL.so.1()(64bit) fontconfig freetype
Recommends: brightnessctl slurp wl-clipboard satty Recommends: brightnessctl slurp wl-clipboard satty
+33 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
</copyright> </copyright>
<interface name="ferese_shell_manager_v1" version="9"> <interface name="ferese_shell_manager_v1" version="10">
<description summary="create a capability-scoped Ferese shell controller"> <description summary="create a capability-scoped Ferese shell controller">
This private global is visible only to clients granted the shell-control This private global is visible only to clients granted the shell-control
capability. It does not assign a Wayland surface role. capability. It does not assign a Wayland surface role.
@@ -35,7 +35,7 @@
<request name="destroy" type="destructor"/> <request name="destroy" type="destructor"/>
</interface> </interface>
<interface name="ferese_shell_v1" version="9"> <interface name="ferese_shell_v1" version="10">
<description summary="managed-window metadata and shell control"> <description summary="managed-window metadata and shell control">
Window, workspace, and output identifiers are unsigned 64-bit values Window, workspace, and output identifiers are unsigned 64-bit values
encoded as high and low 32-bit words. Each snapshot atomically replaces encoded as high and low 32-bit words. Each snapshot atomically replaces
@@ -213,5 +213,36 @@
</description> </description>
<arg name="name" type="string"/> <arg name="name" type="string"/>
</event> </event>
<event name="toggle_launcher" since="10">
<description summary="toggle the launcher on the focused output"/>
<arg name="output_name" type="string"/>
</event>
<request name="thumbnail" since="10">
<description summary="request a bounded window thumbnail">
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.
</description>
<arg name="token" type="uint"/>
<arg name="window_hi" type="uint"/>
<arg name="window_lo" type="uint"/>
</request>
<event name="thumbnail" since="10">
<arg name="token" type="uint"/>
<arg name="window_hi" type="uint"/>
<arg name="window_lo" type="uint"/>
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
<arg name="pixels" type="fd"/>
</event>
<event name="thumbnail_unavailable" since="10">
<arg name="token" type="uint"/>
<arg name="window_hi" type="uint"/>
<arg name="window_lo" type="uint"/>
</event>
<event name="dismiss_launcher" since="10">
<description summary="discard launcher state before locking"/>
</event>
</interface> </interface>
</protocol> </protocol>
@@ -223,6 +223,23 @@ class ShellMotionTest(unittest.TestCase):
"notification materials did not follow their fade") "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) 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) 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")) modal = wait_for(lambda: layer_surface("ferese-system-modal"))
wait_for(lambda: frames(modal) >= 4) wait_for(lambda: frames(modal) >= 4)
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
fff-search = "=0.11.0"
mime_guess = "2.0.5"
ferese-activities = { path = "../../crates/ferese-activities" } ferese-activities = { path = "../../crates/ferese-activities" }
ferese-layout = { path = "../../crates/ferese-layout" } ferese-layout = { path = "../../crates/ferese-layout" }
ferese-panel = { path = "../../crates/ferese-panel" } ferese-panel = { path = "../../crates/ferese-panel" }
+9
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct ShellConfig { pub(crate) struct ShellConfig {
pub(crate) terminal: Vec<String>,
pub(crate) activities: ferese_config::activities::Settings, pub(crate) activities: ferese_config::activities::Settings,
pub(crate) notifications: ferese_config::notifications::NotificationConfig, pub(crate) notifications: ferese_config::notifications::NotificationConfig,
pub(crate) animations: crate::motion::Settings, pub(crate) animations: crate::motion::Settings,
@@ -23,6 +24,7 @@ impl Default for ShellConfig {
fn default() -> Self { fn default() -> Self {
let status = StatusConfig::default(); let status = StatusConfig::default();
Self { Self {
terminal: vec!["foot".into()],
activities: Default::default(), activities: Default::default(),
notifications: Default::default(), notifications: Default::default(),
animations: Default::default(), animations: Default::default(),
@@ -185,6 +187,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct FereseConfig { struct FereseConfig {
#[serde(default)]
commands: std::collections::HashMap<String, Vec<String>>,
#[serde(default)] #[serde(default)]
activities: ferese_config::activities::Settings, activities: ferese_config::activities::Settings,
#[serde(default)] #[serde(default)]
@@ -404,6 +408,11 @@ fn parse_document(
theme.bar_radius = theme.material_radius; theme.bar_radius = theme.material_radius;
Ok(ShellConfig { Ok(ShellConfig {
terminal: config
.commands
.get("terminal")
.cloned()
.unwrap_or_else(|| vec!["foot".into()]),
activities: config.activities, activities: config.activities,
notifications: config.notifications, notifications: config.notifications,
animations: config.animations, animations: config.animations,
+62 -1
View File
@@ -85,6 +85,9 @@ pub(crate) enum ControlCommand {
Logout(u32, String), Logout(u32, String),
CancelLogout(u32), CancelLogout(u32),
ToggleGuide(String), ToggleGuide(String),
ToggleLauncher(String),
DismissLauncher,
Thumbnail(u32, u64, Option<cosmic::widget::image::Handle>),
ToggleDisplays(String), ToggleDisplays(String),
MonitorsChanged, MonitorsChanged,
} }
@@ -128,6 +131,9 @@ enum ControlUpdate {
Logout(u32, String), Logout(u32, String),
LogoutCancelled(u32), LogoutCancelled(u32),
ToggleGuide(String), ToggleGuide(String),
ToggleLauncher(String),
DismissLauncher,
Thumbnail(u32, u64, Option<cosmic::widget::image::Handle>),
ToggleDisplays(String), ToggleDisplays(String),
MonitorsChanged, MonitorsChanged,
} }
@@ -137,7 +143,7 @@ impl ShellControl {
let connection = control_connection()?; let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?; let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle(); let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=9, ())?; let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=10, ())?;
let shell = manager.get_shell(&qh, ()); let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel(); let (sender, updates) = mpsc::channel();
let wake = Wake(Arc::new(tokio::sync::Notify::new())); 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::Logout(serial, output)) => poll.commands.push(ControlCommand::Logout(serial, output)),
Ok(ControlUpdate::ToggleDisplays(output)) => poll.commands.push(ControlCommand::ToggleDisplays(output)), Ok(ControlUpdate::ToggleDisplays(output)) => poll.commands.push(ControlCommand::ToggleDisplays(output)),
Ok(ControlUpdate::MonitorsChanged) => poll.commands.push(ControlCommand::MonitorsChanged), 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::ToggleGuide(output)) => poll.commands.push(ControlCommand::ToggleGuide(output)),
Ok(ControlUpdate::LogoutCancelled(serial)) => poll.commands.push(ControlCommand::CancelLogout(serial)), Ok(ControlUpdate::LogoutCancelled(serial)) => poll.commands.push(ControlCommand::CancelLogout(serial)),
Ok(ControlUpdate::Config(source)) => poll.config = Some(source), 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) { pub(crate) fn move_window_to_workspace(&self, window: u64, workspace: u64) {
if self.shell.version() < 8 { if self.shell.version() < 8 {
return; return;
@@ -318,6 +338,47 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
_qh: &QueueHandle<Self>, _qh: &QueueHandle<Self>,
) { ) {
match event { 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::ConfigBegin => state.config = Some(String::new()),
ferese_shell_v1::Event::ConfigChunk { source } => { ferese_shell_v1::Event::ConfigChunk { source } => {
append_config_chunk(&mut state.config, &source); append_config_chunk(&mut state.config, &source);
+161
View File
@@ -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<Row>,
normalized_names: Vec<String>,
by_id: HashMap<String, usize>,
}
#[derive(Default, Deserialize, Serialize)]
struct Usage {
count: u64,
last: u64,
}
fn state_path() -> Option<PathBuf> {
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<String, Usage> {
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<cosmic::widget::icon::Handle> {
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<Row> = 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<Row> {
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<Row> {
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(())
}
+160
View File
@@ -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<OnceLock<Result<SharedFilePicker, String>>>);
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<super::icons::FileIcons>,
) -> Vec<Row> {
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::<Vec<_>>()
};
// 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<std::path::Path> = 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<Row> {
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());
}
}
+40
View File
@@ -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<String, icon::Handle>);
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
}
}
+135
View File
@@ -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<std::process::Command, String> {
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(())
}
+675
View File
@@ -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<apps::Apps>),
Results(window::Id, u64, Vec<Row>, 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<Category>,
alternatives: bool,
category_focused: bool,
ghost_visible: bool,
selected: usize,
rows: Vec<Row>,
apps: Option<Arc<apps::Apps>>,
revision: u64,
running: bool,
activating: bool,
after_close: Option<Target>,
error: Option<String>,
file_status: files::Status,
preferred_result: Option<sources::ResultId>,
closing: bool,
keyboard_nav: bool,
progress: AnimatedValue,
height: AnimatedValue,
tick: Instant,
thumbnails: thumbnails::Thumbnails,
effects: Option<EffectsBinding>,
regions: motion::Regions,
refresh: Instant,
lifetime: work::Cancellation,
search_cancel: work::Cancellation,
file_icons: Arc<std::sync::Mutex<icons::FileIcons>>,
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<super::Message> {
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::<Self>(
|_| 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<super::Message> {
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<super::Message> {
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<super::Message> {
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<super::Message> {
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<super::Message> {
self.merge_launcher_windows(false)
}
fn merge_launcher_windows(&mut self, force: bool) -> Task<super::Message> {
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<super::Message> {
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<super::Message> {
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<Task<super::Message>> {
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<super::Message> {
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))),
)
}
}
+297
View File
@@ -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<Path>),
Window(u64),
Action(Action),
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum ResultId {
App(Arc<str>),
File(Arc<Path>),
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<cosmic::widget::icon::Handle>,
pub subtitle: String,
pub target: Target,
pub score: f64,
}
impl Row {
pub(super) fn category(&self) -> Option<Category> {
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<Row>;
}
// 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<f64> {
if self.0.is_empty() {
return Some(0.);
}
self.normalized(&text.to_lowercase())
}
pub(super) fn normalized(&self, text: &str) -> Option<f64> {
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<f64> {
Matcher::new(query).score(text)
}
pub(super) fn rank(rows: &mut Vec<Row>) {
// 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<u64, WindowEntry>,
workspaces: std::collections::HashMap<u64, String>,
workspace_ids: std::collections::HashSet<u64>,
query: String,
apps_ready: bool,
}
struct WindowEntry {
title: String,
app_id: String,
workspace: u64,
workspace_name: String,
result: Option<Row>,
icon: Option<cosmic::widget::icon::Handle>,
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<Item = &Row> {
self.entries.values().filter_map(|entry| entry.result.as_ref())
}
}
impl Source for Windows {
fn query(&self, query: &str) -> Vec<Row> {
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<Row> {
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()
}
}
+180
View File
@@ -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<_>>(),
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);
}
@@ -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<u64, image::Handle>,
pending: HashSet<u64>,
updated: Option<Instant>,
visible: [Option<u64>; 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<image::Handle>) {
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());
}
}
+531
View File
@@ -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<super::super::Message>>;
fn message(message: Message) -> cosmic::Action<super::super::Message> {
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()
}
}
+15
View File
@@ -0,0 +1,15 @@
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
#[derive(Clone, Default, Debug)]
pub(super) struct Cancellation(Arc<AtomicBool>);
impl Cancellation {
pub(super) fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub(super) fn cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
+30 -1
View File
@@ -12,6 +12,7 @@ mod control;
mod display_mode; mod display_mode;
mod ipc_subscription; mod ipc_subscription;
mod keybinding_guide; mod keybinding_guide;
mod launcher;
mod media; mod media;
mod motion; mod motion;
mod notification_ui; mod notification_ui;
@@ -170,6 +171,8 @@ struct FereseShell {
pending_config_error: Option<String>, pending_config_error: Option<String>,
menu: Option<status_ui::OpenMenu>, menu: Option<status_ui::OpenMenu>,
system_modal: Option<system_modal::SystemModal>, system_modal: Option<system_modal::SystemModal>,
launcher: Option<launcher::Launcher>,
launcher_files: launcher::files::Files,
display_mode: display_mode::Model, display_mode: display_mode::Model,
guide_shown: bool, guide_shown: bool,
guide_load: keybinding_guide::LoadState, guide_load: keybinding_guide::LoadState,
@@ -194,6 +197,8 @@ struct OutputSurfaces {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
Launcher(launcher::Message),
LauncherEnter(window::Id, bool),
NightLightTemperature(u16), NightLightTemperature(u16),
CommitNightLightTemperature, CommitNightLightTemperature,
HubOpen(window::Id, status_ui::Menu), HubOpen(window::Id, status_ui::Menu),
@@ -335,6 +340,8 @@ impl cosmic::Application for FereseShell {
pending_config_error, pending_config_error,
menu: None, menu: None,
system_modal: None, system_modal: None,
launcher: None,
launcher_files: Default::default(),
display_mode: display_mode::Model::default(), display_mode: display_mode::Model::default(),
guide_shown: false, guide_shown: false,
guide_load: Default::default(), guide_load: Default::default(),
@@ -365,6 +372,7 @@ impl cosmic::Application for FereseShell {
fn subscription(&self) -> Subscription<Self::Message> { fn subscription(&self) -> Subscription<Self::Message> {
Subscription::batch([ Subscription::batch([
self.launcher_subscription(),
self.control.as_ref().map_or_else(Subscription::none, |control| { self.control.as_ref().map_or_else(Subscription::none, |control| {
control.subscription().map(|_| Message::ControlReady) control.subscription().map(|_| Message::ControlReady)
}), }),
@@ -376,8 +384,11 @@ impl cosmic::Application for FereseShell {
event::listen_with(|event, status, id| match &event { event::listen_with(|event, status, id| match &event {
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter), 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 Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING
.load(Ordering::Relaxed) .load(Ordering::Relaxed)
.then_some(Message::Event(event, id)), .then_some(Message::Event(event, id)),
@@ -407,6 +418,14 @@ impl cosmic::Application for FereseShell {
fn update(&mut self, message: Self::Message) -> Task<Self::Message> { fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
let task = match message { 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::HubOpen(id, kind) => self.open_hub(id, kind),
Message::HubClose(id) => self.close_hub(id), Message::HubClose(id) => self.close_hub(id),
Message::HubWorkspaceScroll(id, delta) => self.scroll_hub_workspaces(id, delta), Message::HubWorkspaceScroll(id, delta) => self.scroll_hub_workspaces(id, delta),
@@ -562,6 +581,7 @@ impl cosmic::Application for FereseShell {
} }
Ok((_connection, surface)) => { Ok((_connection, surface)) => {
self.attach_power_material(id, &surface); self.attach_power_material(id, &surface);
self.attach_launcher_effects(id, &surface);
if let Some(entry) = &mut self.notification_surface if let Some(entry) = &mut self.notification_surface
&& entry.id == id && entry.id == id
&& entry.effects.is_none() && entry.effects.is_none()
@@ -857,6 +877,7 @@ impl cosmic::Application for FereseShell {
if let Some(snapshot) = poll.snapshot { if let Some(snapshot) = poll.snapshot {
let mode_was_active = self.snapshot.binding_mode.is_some(); let mode_was_active = self.snapshot.binding_mode.is_some();
self.snapshot = snapshot; self.snapshot = snapshot;
reload_task = Task::batch([reload_task, self.refresh_launcher()]);
let mode = self.binding_mode_transition(mode_was_active); let mode = self.binding_mode_transition(mode_was_active);
let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active); let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
let mut tasks = vec![ let mut tasks = vec![
@@ -919,6 +940,12 @@ impl cosmic::Application for FereseShell {
} }
for command in poll.commands { for command in poll.commands {
let task = match command { 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::ToggleDisplays(output) => self.open_display_modal(Some(&output)),
control::ControlCommand::MonitorsChanged => self.load_display_inventory(), control::ControlCommand::MonitorsChanged => self.load_display_inventory(),
control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output), control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output),
@@ -1177,6 +1204,8 @@ mod tests {
pending_config_error: None, pending_config_error: None,
menu: None, menu: None,
system_modal: None, system_modal: None,
launcher: None,
launcher_files: Default::default(),
display_mode: Default::default(), display_mode: Default::default(),
guide_shown: false, guide_shown: false,
guide_load: Default::default(), guide_load: Default::default(),
+118 -68
View File
@@ -348,18 +348,31 @@ pub fn animated_from_edge<'a, M: 'a>(
Element::new(Motion { Element::new(Motion {
content, content,
progress, progress,
regions, regions: Some(regions),
radius, radius,
scale: 1.,
direction: if edge == ferese_panel::Edge::Top { -1. } else { 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> { struct Motion<'a, M> {
content: Element<'a, M>, content: Element<'a, M>,
progress: f32, progress: f32,
regions: Regions, regions: Option<Regions>,
radius: f32, radius: f32,
direction: f32, direction: f32,
scale: f32,
} }
impl<M> Motion<'_, M> { impl<M> Motion<'_, M> {
@@ -367,9 +380,11 @@ impl<M> Motion<'_, M> {
Vector::new(0.0, self.direction * 4.0 * (1.0 - self.progress)) 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| { 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<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
tree: &widget::Tree, tree: &widget::Tree,
cursor: mouse::Cursor, cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree { ) -> 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(); let offset = self.translation();
for node in nodes.root_mut() { 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() { for node in nodes.children_mut() {
translate_accessible_node(node, offset); transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
} }
nodes nodes
} }
@@ -432,19 +450,27 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
shell: &mut Shell<'_, M>, shell: &mut Shell<'_, M>,
viewport: &Rectangle, viewport: &Rectangle,
) { ) {
let mut collector = CollectRegions(Vec::new()); if let Some(regions) = &self.regions {
let mut collected = regions.lock().unwrap();
collected.clear();
self.content self.content
.as_widget_mut() .as_widget_mut()
.operate(tree, layout, renderer, &mut collector); .operate(tree, layout, renderer, &mut ReuseRegions(&mut collected));
if collector.0.is_empty() { if collected.is_empty()
// A single-content popover (e.g. battery) has one inner card, not && let Some(content) = layout.children().next()
// a full-surface rectangle including the outer transparent padding. {
if let Some(content) = layout.children().next() { let bounds = content.bounds();
collector.0.push(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(layout, cursor);
let cursor = self.cursor(cursor);
self.content self.content
.as_widget_mut() .as_widget_mut()
.update(tree, event, layout, cursor, renderer, clipboard, shell, viewport); .update(tree, event, layout, cursor, renderer, clipboard, shell, viewport);
@@ -461,16 +487,25 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
viewport: &Rectangle, viewport: &Rectangle,
) { ) {
let v = self.translation(); 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. // Layout/region collection still run before the surface starts opening.
// No child may paint an opaque control before the presentation begins. // No child may paint an opaque control before the presentation begins.
if self.progress <= 0.0 { if self.progress <= 0.0 {
return; return;
} }
renderer.with_transformation(transform, |renderer| { renderer.with_transformation(transform, |renderer| {
self.content self.content.as_widget().draw(
.as_widget() tree,
.draw(tree, renderer, theme, style, layout, self.cursor(cursor), viewport) renderer,
theme,
style,
layout,
self.cursor(layout, cursor),
viewport,
)
}); });
} }
@@ -484,7 +519,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
) -> mouse::Interaction { ) -> mouse::Interaction {
self.content self.content
.as_widget() .as_widget()
.mouse_interaction(tree, layout, self.cursor(cursor), viewport, renderer) .mouse_interaction(tree, layout, self.cursor(layout, cursor), viewport, renderer)
} }
fn operate( fn operate(
@@ -512,56 +547,55 @@ impl<M> Widget<M, Theme, cosmic::Renderer> 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() { if let Some(mut bounds) = node.node().bounds() {
bounds.x0 += f64::from(offset.x); let x = f64::from(center.x);
bounds.x1 += f64::from(offset.x); let y = f64::from(center.y);
bounds.y0 += f64::from(offset.y); bounds.x0 = x + (bounds.x0 - x) * f64::from(scale) + f64::from(offset.x);
bounds.y1 += f64::from(offset.y); 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); node.node_mut().set_bounds(bounds);
} }
} }
struct CollectRegions(Vec<Rectangle>); fn transform_regions(regions: &mut [[f32; 5]], center: cosmic::iced::Point, scale: f32, offset: Vector, radius: f32) {
for region in regions {
impl CollectRegions { region[0] = center.x + (region[0] - center.x) * scale + offset.x;
fn into_regions(self, translation: Vector, radius: f32) -> Vec<[f32; 5]> { region[1] = center.y + (region[1] - center.y) * scale + offset.y;
self.0 region[2] *= scale;
.into_iter() region[3] *= scale;
.take(32) region[4] = radius.max(0.) * scale;
.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()
} }
} }
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)) { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self); children(self);
} }
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("ferese-blur-card")) { if id != Some(&widget::Id::new("ferese-blur-card")) || bounds.width <= 0. || bounds.height <= 0. {
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; return;
} }
self.0.retain(|inner| !contains(&bounds, inner)); let r = [bounds.x, bounds.y, bounds.width, bounds.height, 0.];
self.0.push(bounds); 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] #[test]
fn material_regions_preserve_fractional_layout_and_translation() { fn material_regions_preserve_fractional_layout_and_translation() {
let collector = CollectRegions(vec![Rectangle::new((0.25, 2.5).into(), (100.25, 40.5).into())]); let mut regions = vec![[0.25, 2.5, 100.25, 40.5, 0.]];
assert_eq!( transform_regions(
collector.into_regions(Vector::new(0.125, -0.25), 13.5), &mut regions,
vec![[0.375, 2.25, 100.25, 40.5, 13.5]] 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] #[test]
fn centered_modal_material_tracks_surface_position_and_animation() { 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()); 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); widget::Operation::container(&mut collector, Some(&widget::Id::new("ferese-blur-card")), card);
assert_eq!( transform_regions(
collector.into_regions(Vector::new(0.0, -2.0), 14.0), &mut regions,
vec![[380., 418., 440., 220., 14.]] cosmic::iced::Point::ORIGIN,
1.,
Vector::new(0.0, -2.0),
14.0,
); );
assert_eq!(regions, vec![[380., 418., 440., 220., 14.]]);
} }
#[test] #[test]
@@ -901,7 +944,8 @@ mod tests {
#[test] #[test]
fn material_regions_include_cards_but_not_the_parent_or_gaps() { 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"); let id = widget::Id::new("ferese-blur-card");
widget::Operation::container( widget::Operation::container(
&mut collector, &mut collector,
@@ -912,12 +956,18 @@ mod tests {
let second = Rectangle::new((160.0, 12.0).into(), (140.0, 80.0).into()); 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), first);
widget::Operation::container(&mut collector, Some(&id), second); 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( widget::Operation::container(
&mut collector, &mut collector,
Some(&id), Some(&id),
Rectangle::new((20.0, 20.0).into(), (40.0, 40.0).into()), 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.]]
);
} }
} }
+31 -4
View File
@@ -37,7 +37,17 @@ impl FereseShell {
} else { } else {
Task::none() 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![ let mut tasks = vec![
launcher,
self.dismiss_workspace_tooltip(), self.dismiss_workspace_tooltip(),
menu.chain(destroy_layer_surface(entry.bar)), menu.chain(destroy_layer_surface(entry.bar)),
self.rebuild_system_modal(), self.rebuild_system_modal(),
@@ -211,6 +221,7 @@ impl FereseShell {
self.outputs self.outputs
.iter() .iter()
.any(|entry| entry.bar == id || entry.wallpaper == Some(id)) .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.menu.as_ref().is_some_and(|menu| menu.id == id)
|| self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) || self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|| self || self
@@ -221,6 +232,9 @@ impl FereseShell {
} }
pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> { pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
if let Some(task) = self.launcher_event(&event, id) {
return task;
}
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) { if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) {
return self.hub_hover(id, true); 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) .map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
.collect(); .collect();
validate_region_counts(regions.len(), values.len())?; validate_region_counts(regions.len(), values.len())?;
let next = (regions.to_vec(), opacity, values, role); let mut previous = self.presentation.borrow_mut();
if self.presentation.borrow().as_ref() != Some(&next) { 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( self.surface.set_presentation(
role, role,
encode_regions(regions), encode_regions(regions),
opacity, opacity,
next.2.iter().flat_map(|value| value.to_ne_bytes()).collect(), values.iter().flat_map(|value| value.to_ne_bytes()).collect(),
); );
self.connection.flush()?; self.connection.flush()?;
*self.presentation.borrow_mut() = Some(next); 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(()) Ok(())
} }
+1
View File
@@ -181,6 +181,7 @@ impl FereseShell {
}) })
.unwrap_or(0); .unwrap_or(0);
let mut tasks = vec![ let mut tasks = vec![
self.destroy_launcher(),
self.destroy_system_modal(true), self.destroy_system_modal(true),
self.destroy_menu(), self.destroy_menu(),
self.close_notification_history(), self.close_notification_history(),