Add native shell launcher with themed search and browsing
This commit is contained in:
1 parent
2a400b26d0
commit
d8bdb68b2a
43 files changed
+3341
-105
No files matched your search
Generated
+538
-16
@@ -568,6 +568,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
@@ -617,6 +626,19 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
@@ -742,6 +764,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -1032,6 +1060,12 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cookie-factory"
|
||||
version = "0.3.3"
|
||||
@@ -1129,11 +1163,11 @@ dependencies = [
|
||||
"atomicwrites",
|
||||
"cosmic-config-derive",
|
||||
"cosmic-settings-daemon",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"futures-util",
|
||||
"iced_futures",
|
||||
"known-folders",
|
||||
"notify",
|
||||
"notify 8.2.0",
|
||||
"ron",
|
||||
"serde",
|
||||
"tokio",
|
||||
@@ -1220,7 +1254,7 @@ dependencies = [
|
||||
"configparser",
|
||||
"cosmic-config",
|
||||
"csscolorparser",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"hex_color",
|
||||
"palette",
|
||||
"ron",
|
||||
@@ -1256,6 +1290,43 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
@@ -1312,7 +1383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "168dbdf4dced8f57fa4246ac9080598452b49d70bb2d0e46422142f113140dd8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"serde",
|
||||
"uncased",
|
||||
]
|
||||
@@ -1404,6 +1475,12 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_setters"
|
||||
version = "0.1.9"
|
||||
@@ -1448,13 +1525,34 @@ dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1465,7 +1563,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1534,6 +1632,15 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "doxygen-rs"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9"
|
||||
dependencies = [
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -1624,6 +1731,12 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -1755,7 +1868,7 @@ dependencies = [
|
||||
"jiff",
|
||||
"libc",
|
||||
"memmap2 0.9.11",
|
||||
"notify",
|
||||
"notify 8.2.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smithay",
|
||||
@@ -1892,7 +2005,7 @@ dependencies = [
|
||||
"iced_accessibility",
|
||||
"image",
|
||||
"libcosmic",
|
||||
"notify",
|
||||
"notify 8.2.0",
|
||||
"serde_json",
|
||||
"shlex 1.3.0",
|
||||
"tempfile",
|
||||
@@ -1920,12 +2033,14 @@ dependencies = [
|
||||
"ferese-protocols",
|
||||
"ferese-theme",
|
||||
"ferese-theme-client",
|
||||
"fff-search",
|
||||
"freedesktop-desktop-entry",
|
||||
"iced_accessibility",
|
||||
"image",
|
||||
"jiff",
|
||||
"libc",
|
||||
"libcosmic",
|
||||
"mime_guess",
|
||||
"pipewire",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1987,6 +2102,83 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-grep"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d75a741baa9142c1cbe7df7948d93a809296cd3a28f5c117964a0048b4711258"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-notify-debouncer-full"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29a4ebea7b8a2840cd59358bbf396f6f04313ce8eae84ac79703ce80298b8731"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify 9.0.0-rc.5",
|
||||
"notify-types",
|
||||
"rustc-hash 2.1.3",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-query-parser"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1aa71c8268c77b2b7d9982eeaa6aa42abb22eaaaa3b72b1b636946c72a8982"
|
||||
|
||||
[[package]]
|
||||
name = "fff-search"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a80ed5bdfa89cc81cb314374a4460da880d68e4c81d9574a587f28757a9c943e"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"aho-corasick",
|
||||
"blake3",
|
||||
"dirs 5.0.1",
|
||||
"dunce",
|
||||
"fff-grep",
|
||||
"fff-notify-debouncer-full",
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"globset",
|
||||
"heed",
|
||||
"ignore",
|
||||
"libc",
|
||||
"memchr",
|
||||
"memmap2 0.9.11",
|
||||
"neo_frizbee",
|
||||
"notify 9.0.0-rc.5",
|
||||
"parking_lot",
|
||||
"pathdiff",
|
||||
"rayon",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde",
|
||||
"signal-hook-registry",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "file-id"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-crate"
|
||||
version = "0.6.3"
|
||||
@@ -2407,6 +2599,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git2"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libc",
|
||||
"libgit2-sys",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -2468,12 +2672,31 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glidesort"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0"
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.16.0"
|
||||
@@ -2657,6 +2880,44 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heed"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
"heed-traits",
|
||||
"heed-types",
|
||||
"libc",
|
||||
"lmdb-master-sys",
|
||||
"once_cell",
|
||||
"page_size",
|
||||
"serde",
|
||||
"synchronoise",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heed-traits"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff"
|
||||
|
||||
[[package]]
|
||||
name = "heed-types"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"byteorder",
|
||||
"heed-traits",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.3.9"
|
||||
@@ -3122,6 +3383,22 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
"log",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"same-file",
|
||||
"walkdir",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -3551,7 +3828,7 @@ dependencies = [
|
||||
"jiff",
|
||||
"log",
|
||||
"palette",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"rfd",
|
||||
"roxmltree",
|
||||
"rust-embed",
|
||||
@@ -3566,6 +3843,18 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
version = "0.18.8+1.9.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
@@ -3651,6 +3940,18 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lilt"
|
||||
version = "0.8.2"
|
||||
@@ -3702,6 +4003,17 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lmdb-master-sys"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"doxygen-rs",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -3991,6 +4303,15 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neo_frizbee"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb0dd0e69b342eaddb657120a21a56f3e887ddc505fac780cf430dbbbb3aed26"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -4040,6 +4361,26 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "9.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7768eb7201a1964d8eb10224e850df046ab584ee3c3d26e340a993d81f748148"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"notify-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-services",
|
||||
"walkdir",
|
||||
"windows-sys 0.61.2",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "2.1.0"
|
||||
@@ -4058,6 +4399,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
@@ -4266,6 +4613,16 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-services"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
@@ -4510,6 +4867,16 @@ dependencies = [
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.7"
|
||||
@@ -4519,7 +4886,7 @@ dependencies = [
|
||||
"approx 0.5.1",
|
||||
"palette_derive",
|
||||
"palette_math",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -4582,23 +4949,49 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
@@ -4606,7 +4999,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4615,14 +5021,23 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"uncased",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
@@ -4789,6 +5204,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -4995,6 +5416,26 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.37.0"
|
||||
@@ -5054,6 +5495,17 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -5818,6 +6270,12 @@ dependencies = [
|
||||
"zeno",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -5840,6 +6298,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synchronoise"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2"
|
||||
dependencies = [
|
||||
"crossbeam-queue",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
@@ -5962,6 +6429,36 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
@@ -6154,6 +6651,19 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@@ -6453,6 +6963,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -7736,7 +8252,7 @@ name = "xdg-desktop-portal-ferese"
|
||||
version = "0.1.0-beta"
|
||||
dependencies = [
|
||||
"calloop 0.14.4",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"ferese-config",
|
||||
"ferese-ipc",
|
||||
"ferese-protocols",
|
||||
@@ -7819,6 +8335,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
|
||||
@@ -184,6 +184,7 @@ impl DirectBackendState {
|
||||
window: &smithay::desktop::Window,
|
||||
output: &Output,
|
||||
cursor: Option<smithay::utils::Rectangle<i32, smithay::utils::Logical>>,
|
||||
scale: f64,
|
||||
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
|
||||
let device = self
|
||||
.devices
|
||||
@@ -194,7 +195,7 @@ impl DirectBackendState {
|
||||
&mut device.renderer,
|
||||
window,
|
||||
window.geometry(),
|
||||
output.current_scale().fractional_scale(),
|
||||
scale,
|
||||
cursor.map(|rect| (state, rect)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1214,7 +1214,7 @@ mod tests {
|
||||
let input = config.input_settings().unwrap();
|
||||
let bindings = config.bindings(&input).unwrap();
|
||||
|
||||
assert_eq!(bindings.len(), 62);
|
||||
assert_eq!(bindings.len(), 63);
|
||||
for (shift, action) in [
|
||||
(false, BindingAction::ToggleMaximized),
|
||||
(true, BindingAction::ToggleFullscreen),
|
||||
@@ -1225,6 +1225,10 @@ mod tests {
|
||||
&& binding.action == action));
|
||||
}
|
||||
assert!(bindings.iter().any(|binding| binding.action == BindingAction::Exit));
|
||||
assert!(bindings.iter().any(|binding| binding.modifiers.logo
|
||||
&& !binding.modifiers.shift
|
||||
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_space)
|
||||
&& binding.action == BindingAction::ToggleLauncher));
|
||||
assert!(bindings.iter().any(|binding| binding.modifiers.logo
|
||||
&& binding.modifiers.shift
|
||||
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_t)
|
||||
@@ -1262,7 +1266,7 @@ mod tests {
|
||||
|
||||
let input = replaced.input_settings().unwrap();
|
||||
let bindings = replaced.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 62);
|
||||
assert_eq!(bindings.len(), 63);
|
||||
assert!(bindings.iter().any(|binding| {
|
||||
binding.action
|
||||
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
|
||||
@@ -1270,7 +1274,7 @@ mod tests {
|
||||
|
||||
let input = unbound.input_settings().unwrap();
|
||||
let bindings = unbound.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 61);
|
||||
assert_eq!(bindings.len(), 62);
|
||||
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
|
||||
}
|
||||
|
||||
|
||||
@@ -378,6 +378,10 @@ pub(super) fn parse_action(
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleDisplayMode)
|
||||
}
|
||||
"toggle-launcher" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleLauncher)
|
||||
}
|
||||
"toggle-keybinding-guide" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleKeybindingGuide)
|
||||
@@ -405,6 +409,7 @@ pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
|
||||
|
||||
pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
||||
let mut bindings = vec![
|
||||
binding("Super+Space", "toggle-launcher", None),
|
||||
binding("Super+Enter", "spawn", Some("terminal")),
|
||||
binding("Super+Shift+S", "spawn", Some("screenshot")),
|
||||
binding("Print", "spawn", Some("screenshot-full")),
|
||||
@@ -524,6 +529,7 @@ pub enum BindingAction {
|
||||
ToggleFloating,
|
||||
ToggleOverview,
|
||||
ToggleKeybindingGuide,
|
||||
ToggleLauncher,
|
||||
ToggleDisplayMode,
|
||||
Media(&'static str),
|
||||
}
|
||||
@@ -648,6 +654,7 @@ impl Binding {
|
||||
.into(),
|
||||
BindingAction::ToggleOverview => "Open or close overview".into(),
|
||||
BindingAction::ToggleDisplayMode => "Open display mode chooser".into(),
|
||||
BindingAction::ToggleLauncher => "Open or close launcher".into(),
|
||||
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
|
||||
@@ -330,7 +330,7 @@ impl Ferese {
|
||||
)
|
||||
} else {
|
||||
let mut backend = self.direct_backend.take()?;
|
||||
let result = backend.capture_window_frame(self, &window, &output, cursor);
|
||||
let result = backend.capture_window_frame(self, &window, &output, cursor, scale);
|
||||
self.direct_backend = Some(backend);
|
||||
result
|
||||
};
|
||||
|
||||
@@ -1267,6 +1267,9 @@ impl Ferese {
|
||||
BindingAction::ToggleDisplayMode => {
|
||||
self.toggle_display_mode();
|
||||
}
|
||||
BindingAction::ToggleLauncher => {
|
||||
self.toggle_launcher();
|
||||
}
|
||||
BindingAction::ToggleKeybindingGuide => {
|
||||
self.toggle_keybinding_guide();
|
||||
}
|
||||
|
||||
@@ -1083,6 +1083,14 @@ impl Ferese {
|
||||
"toggle-display-mode" => {
|
||||
self.toggle_display_mode();
|
||||
}
|
||||
"toggle-launcher" => {
|
||||
if !self.toggle_launcher() {
|
||||
return Err(CommandError::new(
|
||||
"unavailable",
|
||||
"Launcher requires an unlocked session and an updated Ferese shell",
|
||||
));
|
||||
}
|
||||
}
|
||||
"toggle-keybinding-guide" => {
|
||||
if !self.toggle_keybinding_guide() {
|
||||
return Err(CommandError::new(
|
||||
|
||||
@@ -427,6 +427,7 @@ impl SessionLockHandler for Ferese {
|
||||
}
|
||||
|
||||
fn lock(&mut self, confirmation: SessionLocker) {
|
||||
self.dismiss_launcher();
|
||||
if !self.session_lock.lifecycle.can_acquire() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities;
|
||||
use crate::state::ClientState;
|
||||
|
||||
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> {
|
||||
@@ -98,6 +98,18 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
|
||||
return;
|
||||
}
|
||||
match request {
|
||||
ferese_shell_v1::Request::Thumbnail {
|
||||
token,
|
||||
window_hi,
|
||||
window_lo,
|
||||
} => {
|
||||
if let Some((width, height, file)) = state.shell_thumbnail(join_id(window_hi, window_lo)) {
|
||||
use std::os::fd::AsFd;
|
||||
shell.thumbnail(token, window_hi, window_lo, width, height, file.as_fd());
|
||||
} else {
|
||||
shell.thumbnail_unavailable(token, window_hi, window_lo);
|
||||
}
|
||||
}
|
||||
ferese_shell_v1::Request::ActivateWindow { window_hi, window_lo } => state.handle_shell_window_request(
|
||||
shell,
|
||||
ferese_shell_v1::FailedRequest::ActivateWindow,
|
||||
@@ -246,6 +258,22 @@ impl Ferese {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_launcher(&mut self) -> bool {
|
||||
if self.session_lock.active() {
|
||||
return false;
|
||||
}
|
||||
let Some(shell) = self
|
||||
.shell_resources
|
||||
.iter()
|
||||
.filter_map(|shell| shell.upgrade().ok())
|
||||
.find(|shell| shell.version() >= 10)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
shell.toggle_launcher(self.focused_output().map_or_else(String::new, |output| output.name()));
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_keybinding_guide(&mut self) -> bool {
|
||||
if self.session_lock.active() {
|
||||
return false;
|
||||
@@ -888,3 +916,76 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn dismiss_launcher(&self) {
|
||||
for shell in self
|
||||
.shell_resources
|
||||
.iter()
|
||||
.filter_map(|s| s.upgrade().ok())
|
||||
.filter(|s| s.version() >= 10)
|
||||
{
|
||||
shell.dismiss_launcher();
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_thumbnail(&mut self, id: WindowId) -> Option<(u32, u32, std::fs::File)> {
|
||||
use std::io::{Seek, Write};
|
||||
if self.session_lock.active() {
|
||||
return None;
|
||||
}
|
||||
let window = self
|
||||
.windows
|
||||
.ids()
|
||||
.iter()
|
||||
.find(|(_, candidate)| **candidate == id)?
|
||||
.0
|
||||
.clone();
|
||||
if self.capture_protected(&window) {
|
||||
return None;
|
||||
}
|
||||
let workspace = self.workspaces.workspace_for_window(id)?;
|
||||
let owner = self.output_workspaces.output_for_workspace(workspace)?;
|
||||
let output = self
|
||||
.output_ids
|
||||
.iter()
|
||||
.find(|(_, candidate)| **candidate == owner)?
|
||||
.0
|
||||
.clone();
|
||||
let geometry = window.geometry();
|
||||
if geometry.size.w <= 0 || geometry.size.h <= 0 {
|
||||
return None;
|
||||
}
|
||||
let scale = (320. / f64::from(geometry.size.w))
|
||||
.min(168. / f64::from(geometry.size.h))
|
||||
.min(1.);
|
||||
let result = if let Some(backend) = self.nested_backend.clone() {
|
||||
let mut backend = backend.try_borrow_mut().ok()?;
|
||||
crate::render::capture_window_frame(backend.renderer(), &window, geometry, scale, None)
|
||||
} else {
|
||||
let mut backend = self.direct_backend.take()?;
|
||||
let result = backend.capture_window_frame(self, &window, &output, None, scale);
|
||||
self.direct_backend = Some(backend);
|
||||
result
|
||||
};
|
||||
let mut buffer = result.ok()?;
|
||||
if buffer.width <= 0 || buffer.width > 320 || buffer.height <= 0 || buffer.height > 168 {
|
||||
return None;
|
||||
}
|
||||
// GL ABGR readback is RGBA bytes on the supported little-endian targets.
|
||||
// Iced expects straight rather than premultiplied channels.
|
||||
for pixel in buffer.pixels.chunks_exact_mut(4) {
|
||||
let alpha = u16::from(pixel[3]);
|
||||
for channel in &mut pixel[..3] {
|
||||
*channel = (u16::from(*channel) * 255 + alpha / 2)
|
||||
.checked_div(alpha)
|
||||
.unwrap_or(0)
|
||||
.min(255) as u8;
|
||||
}
|
||||
}
|
||||
let mut file = tempfile::tempfile().ok()?;
|
||||
file.write_all(&buffer.pixels).ok()?;
|
||||
file.rewind().ok()?;
|
||||
Some((buffer.width as u32, buffer.height as u32, file))
|
||||
}
|
||||
}
|
||||
@@ -647,9 +647,6 @@ impl Ferese {
|
||||
let Some(output_id) = self.output_workspaces.output_for_workspace(destination) else {
|
||||
return false;
|
||||
};
|
||||
if self.output_workspaces.output_for_workspace(source) != Some(output_id) {
|
||||
return false;
|
||||
}
|
||||
if source == destination {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1178,3 +1178,42 @@ fn tab_indicator_yields_to_a_floating_window_and_disappears_with_one_tab() {
|
||||
assert!(state.scrolling_tab_bar(a).is_none());
|
||||
assert!(state.tab_indicator.hover.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_can_move_a_window_to_the_current_workspace_on_another_output() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"state::window_lifecycle_tests::shell_can_move_a_window_to_the_current_workspace_on_another_output",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut events, mut state, _) = fixture();
|
||||
let (window, _wire) = window(&mut state, &mut events, 0xff112233);
|
||||
let id = state.windows.ids()[&window];
|
||||
let source = state.workspaces.workspace_for_window(id).unwrap();
|
||||
let second = Output::new(
|
||||
"second".into(),
|
||||
smithay::output::PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: smithay::output::Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
second.change_current_state(
|
||||
Some(smithay::output::Mode {
|
||||
size: (800, 600).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&second, (800, 0));
|
||||
state.register_output(&second, "second".into());
|
||||
let output = state.output_ids[&second];
|
||||
let destination = state.output_workspaces.active_workspace(output).unwrap();
|
||||
assert_ne!(source, destination);
|
||||
assert!(state.move_managed_window_to_workspace(id, destination));
|
||||
assert_eq!(state.workspaces.workspace_for_window(id), Some(destination));
|
||||
assert!(state.move_managed_window_to_workspace(id, destination));
|
||||
}
|
||||
@@ -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 |
@@ -30,6 +30,10 @@ pub const DISPLAY_EXTEND: &[u8] = include_bytes!("../assets/icons/display-extend
|
||||
pub const DISPLAY_MIRROR: &[u8] = include_bytes!("../assets/icons/display-mirror.svg");
|
||||
pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/display.svg");
|
||||
pub const FERESE: &[u8] = include_bytes!("../../../assets/branding/ferese-symbolic.svg");
|
||||
pub const SEARCH: &[u8] = include_bytes!("../assets/icons/search.svg");
|
||||
pub const APPS: &[u8] = include_bytes!("../assets/icons/apps.svg");
|
||||
pub const WINDOW: &[u8] = include_bytes!("../assets/icons/window.svg");
|
||||
pub const FOLDER: &[u8] = include_bytes!("../assets/icons/folder.svg");
|
||||
pub const OVERVIEW: &[u8] = include_bytes!("../assets/icons/overview.svg");
|
||||
pub const AIRPLANE: &[u8] = include_bytes!("../assets/icons/status/airplane.svg");
|
||||
pub const BATTERY_25: &[u8] = include_bytes!("../assets/icons/status/battery-25.svg");
|
||||
|
||||
@@ -30,7 +30,7 @@ invalid. A disabled binding must omit `action` and `argument`.
|
||||
| `focus-floating` | None; focus the last focused window in the opposite tiled/floating layer on this workspace |
|
||||
| `focus-mru-next`, `focus-mru-previous` | None; cycle windows in focus order |
|
||||
| `none` | None; ignore this trigger |
|
||||
| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-column-tabbed`, `leave-tab`, `next-tab`, `previous-tab`, `toggle-floating`, `toggle-overview` | None |
|
||||
| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-column-tabbed`, `leave-tab`, `next-tab`, `previous-tab`, `toggle-floating`, `toggle-overview`, `toggle-launcher` | None |
|
||||
|
||||
```kdl
|
||||
commands {
|
||||
|
||||
@@ -6,6 +6,18 @@ guide. Your saved bindings can override these defaults.
|
||||
The [example config](../packaging/config.kdl) adds the app, media and arrow-key
|
||||
shortcuts listed below.
|
||||
|
||||
## Launcher
|
||||
|
||||
**Super + Space** opens the launcher. Type to search, press **Tab** to accept
|
||||
completion, or **Enter** to open the selected match. **Down** shows alternatives.
|
||||
The **Apps**, **Files** and **Windows** tiles browse each source separately.
|
||||
|
||||
**Escape** clears the query, returns from a browse view, then closes the launcher,
|
||||
one step per press. In Windows, **Alt + Enter** moves the selected window to the
|
||||
current workspace. Power off, restart and logout also require **Alt + Enter**.
|
||||
|
||||
Run `feresectl launcher toggle` to open or close it from a script.
|
||||
|
||||
## Move and resize with the mouse
|
||||
|
||||
| Action | Result |
|
||||
|
||||
@@ -19,6 +19,11 @@ pub struct Cli {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Show or hide the application launcher.
|
||||
Launcher {
|
||||
#[command(subcommand)]
|
||||
action: LauncherAction,
|
||||
},
|
||||
/// Publish bounded activity data owned by the invoking shell.
|
||||
Activity {
|
||||
#[command(subcommand)]
|
||||
@@ -235,10 +240,18 @@ fn value_name(value: &impl ValueEnum) -> String {
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum LauncherAction {
|
||||
Toggle,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn ipc(&self) -> Option<(&'static str, Value)> {
|
||||
let (command, args) = match self {
|
||||
Self::Activity { .. } | Self::Run { .. } => return None,
|
||||
Self::Launcher {
|
||||
action: LauncherAction::Toggle,
|
||||
} => ("toggle-launcher", json!({})),
|
||||
Self::FocusedWindow => ("get-focused-window", json!({})),
|
||||
Self::Windows => ("get-windows", json!({})),
|
||||
Self::IdleInhibition => ("get-idle-inhibition", json!({})),
|
||||
|
||||
@@ -38,6 +38,7 @@ fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() {
|
||||
for args in [
|
||||
&["--help"][..],
|
||||
&["theme", "--help"],
|
||||
&["launcher", "--help"],
|
||||
&["media", "--help"],
|
||||
&["xwayland", "--help"],
|
||||
&["screenshot", "--help"],
|
||||
@@ -470,3 +471,17 @@ sys.exit(19)
|
||||
.unwrap();
|
||||
assert!(elapsed < 1.5, "stdout reader stalled the child for {elapsed}s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_toggle_uses_the_shell_command() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ipc.sock");
|
||||
let server = serve(&path, "toggle-launcher", json!({}));
|
||||
success(
|
||||
ctl(&["launcher", "toggle"])
|
||||
.env("FERESE_SOCKET", &path)
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
@@ -7,7 +7,7 @@ url='https://github.com/ferese-wm/ferese'
|
||||
license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception')
|
||||
depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm'
|
||||
'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire'
|
||||
'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk')
|
||||
'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk' 'glib2')
|
||||
makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils')
|
||||
checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth')
|
||||
optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection'
|
||||
|
||||
@@ -52,6 +52,7 @@ pkgbase = ferese-git
|
||||
depends = gstreamer
|
||||
depends = xdg-desktop-portal
|
||||
depends = xdg-desktop-portal-gtk
|
||||
depends = glib2
|
||||
optdepends = brightnessctl: brightness controls
|
||||
optdepends = slurp: screenshot region selection
|
||||
optdepends = satty: screenshot editing
|
||||
|
||||
@@ -7,7 +7,7 @@ url='https://github.com/ferese-wm/ferese'
|
||||
license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception')
|
||||
depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm'
|
||||
'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire'
|
||||
'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk')
|
||||
'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk' 'glib2')
|
||||
makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils')
|
||||
checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth')
|
||||
optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection'
|
||||
|
||||
@@ -23,7 +23,7 @@ BuildRequires: mesa-dri-drivers mesa-vulkan-drivers
|
||||
BuildRequires: pipewire-devel gstreamer1-devel dbus-daemon desktop-file-utils polkit-libs pam
|
||||
BuildRequires: xorg-x11-xauth
|
||||
Requires: pam polkit polkit-libs foot pipewire wireplumber python3 bash util-linux dbus-daemon dbus-tools systemd
|
||||
Requires: xdg-desktop-portal xdg-desktop-portal-gtk
|
||||
Requires: xdg-desktop-portal xdg-desktop-portal-gtk glib2
|
||||
# Loaded at runtime, so ELF dependency detection does not find these libraries.
|
||||
Requires: libEGL.so.1()(64bit) fontconfig freetype
|
||||
Recommends: brightnessctl slurp wl-clipboard satty
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</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">
|
||||
This private global is visible only to clients granted the shell-control
|
||||
capability. It does not assign a Wayland surface role.
|
||||
@@ -35,7 +35,7 @@
|
||||
<request name="destroy" type="destructor"/>
|
||||
</interface>
|
||||
|
||||
<interface name="ferese_shell_v1" version="9">
|
||||
<interface name="ferese_shell_v1" version="10">
|
||||
<description summary="managed-window metadata and shell control">
|
||||
Window, workspace, and output identifiers are unsigned 64-bit values
|
||||
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
||||
@@ -213,5 +213,36 @@
|
||||
</description>
|
||||
<arg name="name" type="string"/>
|
||||
</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>
|
||||
</protocol>
|
||||
@@ -223,6 +223,23 @@ class ShellMotionTest(unittest.TestCase):
|
||||
"notification materials did not follow their fade")
|
||||
print(f"Toast: {opening_frames} opening callback requests, {idle_frames / 2:g} settled commits/s; destroyed", flush=True)
|
||||
|
||||
subprocess.run([str(ctl), "launcher", "toggle"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
launcher = wait_for(lambda: layer_surface("ferese-launcher"))
|
||||
wait_for(lambda: frames(launcher) >= 4)
|
||||
time.sleep(1.5)
|
||||
if capture_path := os.environ.get("FERESE_TEST_LAUNCHER_IMAGE"):
|
||||
with open(capture_path, "wb") as capture:
|
||||
subprocess.run([str(ctl), "screenshot"], env=ipc_environment(env), stdout=capture,
|
||||
stderr=subprocess.PIPE, check=True, timeout=10)
|
||||
before = commits(launcher)
|
||||
time.sleep(1)
|
||||
self.assertLessEqual(commits(launcher) - before, 10, "settled launcher kept animating")
|
||||
before_close = frames(launcher)
|
||||
subprocess.run([str(ctl), "launcher", "toggle"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: destroyed(launcher), timeout=5)
|
||||
self.assertGreater(frames(launcher), before_close, "launcher did not animate its close")
|
||||
print("Launcher: animated open and close, idle timer stopped, surface destroyed", flush=True)
|
||||
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
modal = wait_for(lambda: layer_surface("ferese-system-modal"))
|
||||
wait_for(lambda: frames(modal) >= 4)
|
||||
|
||||
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fff-search = "=0.11.0"
|
||||
mime_guess = "2.0.5"
|
||||
ferese-activities = { path = "../../crates/ferese-activities" }
|
||||
ferese-layout = { path = "../../crates/ferese-layout" }
|
||||
ferese-panel = { path = "../../crates/ferese-panel" }
|
||||
|
||||
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ShellConfig {
|
||||
pub(crate) terminal: Vec<String>,
|
||||
pub(crate) activities: ferese_config::activities::Settings,
|
||||
pub(crate) notifications: ferese_config::notifications::NotificationConfig,
|
||||
pub(crate) animations: crate::motion::Settings,
|
||||
@@ -23,6 +24,7 @@ impl Default for ShellConfig {
|
||||
fn default() -> Self {
|
||||
let status = StatusConfig::default();
|
||||
Self {
|
||||
terminal: vec!["foot".into()],
|
||||
activities: Default::default(),
|
||||
notifications: Default::default(),
|
||||
animations: Default::default(),
|
||||
@@ -185,6 +187,8 @@ pub(crate) enum WallpaperMode {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct FereseConfig {
|
||||
#[serde(default)]
|
||||
commands: std::collections::HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
activities: ferese_config::activities::Settings,
|
||||
#[serde(default)]
|
||||
@@ -404,6 +408,11 @@ fn parse_document(
|
||||
theme.bar_radius = theme.material_radius;
|
||||
|
||||
Ok(ShellConfig {
|
||||
terminal: config
|
||||
.commands
|
||||
.get("terminal")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| vec!["foot".into()]),
|
||||
activities: config.activities,
|
||||
notifications: config.notifications,
|
||||
animations: config.animations,
|
||||
|
||||
@@ -85,6 +85,9 @@ pub(crate) enum ControlCommand {
|
||||
Logout(u32, String),
|
||||
CancelLogout(u32),
|
||||
ToggleGuide(String),
|
||||
ToggleLauncher(String),
|
||||
DismissLauncher,
|
||||
Thumbnail(u32, u64, Option<cosmic::widget::image::Handle>),
|
||||
ToggleDisplays(String),
|
||||
MonitorsChanged,
|
||||
}
|
||||
@@ -128,6 +131,9 @@ enum ControlUpdate {
|
||||
Logout(u32, String),
|
||||
LogoutCancelled(u32),
|
||||
ToggleGuide(String),
|
||||
ToggleLauncher(String),
|
||||
DismissLauncher,
|
||||
Thumbnail(u32, u64, Option<cosmic::widget::image::Handle>),
|
||||
ToggleDisplays(String),
|
||||
MonitorsChanged,
|
||||
}
|
||||
@@ -137,7 +143,7 @@ impl ShellControl {
|
||||
let connection = control_connection()?;
|
||||
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
||||
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 (sender, updates) = mpsc::channel();
|
||||
let wake = Wake(Arc::new(tokio::sync::Notify::new()));
|
||||
@@ -181,6 +187,11 @@ impl ShellControl {
|
||||
Ok(ControlUpdate::Logout(serial, output)) => poll.commands.push(ControlCommand::Logout(serial, output)),
|
||||
Ok(ControlUpdate::ToggleDisplays(output)) => poll.commands.push(ControlCommand::ToggleDisplays(output)),
|
||||
Ok(ControlUpdate::MonitorsChanged) => poll.commands.push(ControlCommand::MonitorsChanged),
|
||||
Ok(ControlUpdate::DismissLauncher) => poll.commands.push(ControlCommand::DismissLauncher),
|
||||
Ok(ControlUpdate::Thumbnail(token, window, image)) => {
|
||||
poll.commands.push(ControlCommand::Thumbnail(token, window, image))
|
||||
}
|
||||
Ok(ControlUpdate::ToggleLauncher(output)) => poll.commands.push(ControlCommand::ToggleLauncher(output)),
|
||||
Ok(ControlUpdate::ToggleGuide(output)) => poll.commands.push(ControlCommand::ToggleGuide(output)),
|
||||
Ok(ControlUpdate::LogoutCancelled(serial)) => poll.commands.push(ControlCommand::CancelLogout(serial)),
|
||||
Ok(ControlUpdate::Config(source)) => poll.config = Some(source),
|
||||
@@ -195,6 +206,15 @@ impl ShellControl {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn thumbnail(&self, token: u32, window: u64) {
|
||||
if self.shell.version() < 10 {
|
||||
return;
|
||||
}
|
||||
let (hi, lo) = split_id(window);
|
||||
self.shell.thumbnail(token, hi, lo);
|
||||
let _ = self.connection.flush();
|
||||
}
|
||||
|
||||
pub(crate) fn move_window_to_workspace(&self, window: u64, workspace: u64) {
|
||||
if self.shell.version() < 8 {
|
||||
return;
|
||||
@@ -318,6 +338,47 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
ferese_shell_v1::Event::DismissLauncher => {
|
||||
let _ = state.sender.send(ControlUpdate::DismissLauncher);
|
||||
}
|
||||
ferese_shell_v1::Event::ThumbnailUnavailable {
|
||||
token,
|
||||
window_hi,
|
||||
window_lo,
|
||||
} => {
|
||||
let _ = state
|
||||
.sender
|
||||
.send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), None));
|
||||
}
|
||||
ferese_shell_v1::Event::Thumbnail {
|
||||
token,
|
||||
window_hi,
|
||||
window_lo,
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
} => {
|
||||
use std::io::Read;
|
||||
let image = (|| {
|
||||
if width == 0 || width > 320 || height == 0 || height > 168 {
|
||||
return None;
|
||||
}
|
||||
let mut file = std::fs::File::from(pixels);
|
||||
let size = (width * height * 4) as usize;
|
||||
if file.metadata().ok()?.len() != size as u64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = vec![0; size];
|
||||
file.read_exact(&mut bytes).ok()?;
|
||||
Some(cosmic::widget::image::Handle::from_rgba(width, height, bytes))
|
||||
})();
|
||||
let _ = state
|
||||
.sender
|
||||
.send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), image));
|
||||
}
|
||||
ferese_shell_v1::Event::ToggleLauncher { output_name } => {
|
||||
let _ = state.sender.send(ControlUpdate::ToggleLauncher(output_name));
|
||||
}
|
||||
ferese_shell_v1::Event::ConfigBegin => state.config = Some(String::new()),
|
||||
ferese_shell_v1::Event::ConfigChunk { source } => {
|
||||
append_config_chunk(&mut state.config, &source);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ mod control;
|
||||
mod display_mode;
|
||||
mod ipc_subscription;
|
||||
mod keybinding_guide;
|
||||
mod launcher;
|
||||
mod media;
|
||||
mod motion;
|
||||
mod notification_ui;
|
||||
@@ -170,6 +171,8 @@ struct FereseShell {
|
||||
pending_config_error: Option<String>,
|
||||
menu: Option<status_ui::OpenMenu>,
|
||||
system_modal: Option<system_modal::SystemModal>,
|
||||
launcher: Option<launcher::Launcher>,
|
||||
launcher_files: launcher::files::Files,
|
||||
display_mode: display_mode::Model,
|
||||
guide_shown: bool,
|
||||
guide_load: keybinding_guide::LoadState,
|
||||
@@ -194,6 +197,8 @@ struct OutputSurfaces {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
Launcher(launcher::Message),
|
||||
LauncherEnter(window::Id, bool),
|
||||
NightLightTemperature(u16),
|
||||
CommitNightLightTemperature,
|
||||
HubOpen(window::Id, status_ui::Menu),
|
||||
@@ -335,6 +340,8 @@ impl cosmic::Application for FereseShell {
|
||||
pending_config_error,
|
||||
menu: None,
|
||||
system_modal: None,
|
||||
launcher: None,
|
||||
launcher_files: Default::default(),
|
||||
display_mode: display_mode::Model::default(),
|
||||
guide_shown: false,
|
||||
guide_load: Default::default(),
|
||||
@@ -365,6 +372,7 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
fn subscription(&self) -> Subscription<Self::Message> {
|
||||
Subscription::batch([
|
||||
self.launcher_subscription(),
|
||||
self.control.as_ref().map_or_else(Subscription::none, |control| {
|
||||
control.subscription().map(|_| Message::ControlReady)
|
||||
}),
|
||||
@@ -376,8 +384,11 @@ impl cosmic::Application for FereseShell {
|
||||
event::listen_with(|event, status, id| match &event {
|
||||
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
|
||||
modifiers,
|
||||
..
|
||||
}) if status == cosmic::iced::event::Status::Captured => None,
|
||||
}) if status == cosmic::iced::event::Status::Captured => {
|
||||
Some(Message::LauncherEnter(id, modifiers.alt()))
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING
|
||||
.load(Ordering::Relaxed)
|
||||
.then_some(Message::Event(event, id)),
|
||||
@@ -407,6 +418,14 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
fn update(&mut self, message: Self::Message) -> Task<Self::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::HubClose(id) => self.close_hub(id),
|
||||
Message::HubWorkspaceScroll(id, delta) => self.scroll_hub_workspaces(id, delta),
|
||||
@@ -562,6 +581,7 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Ok((_connection, surface)) => {
|
||||
self.attach_power_material(id, &surface);
|
||||
self.attach_launcher_effects(id, &surface);
|
||||
if let Some(entry) = &mut self.notification_surface
|
||||
&& entry.id == id
|
||||
&& entry.effects.is_none()
|
||||
@@ -857,6 +877,7 @@ impl cosmic::Application for FereseShell {
|
||||
if let Some(snapshot) = poll.snapshot {
|
||||
let mode_was_active = self.snapshot.binding_mode.is_some();
|
||||
self.snapshot = snapshot;
|
||||
reload_task = Task::batch([reload_task, self.refresh_launcher()]);
|
||||
let mode = self.binding_mode_transition(mode_was_active);
|
||||
let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
|
||||
let mut tasks = vec![
|
||||
@@ -919,6 +940,12 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
for command in poll.commands {
|
||||
let task = match command {
|
||||
control::ControlCommand::DismissLauncher => self.destroy_launcher(),
|
||||
control::ControlCommand::Thumbnail(token, window, image) => {
|
||||
self.launcher_thumbnail(token, window, image);
|
||||
Task::none()
|
||||
}
|
||||
control::ControlCommand::ToggleLauncher(output) => self.toggle_launcher(output),
|
||||
control::ControlCommand::ToggleDisplays(output) => self.open_display_modal(Some(&output)),
|
||||
control::ControlCommand::MonitorsChanged => self.load_display_inventory(),
|
||||
control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output),
|
||||
@@ -1177,6 +1204,8 @@ mod tests {
|
||||
pending_config_error: None,
|
||||
menu: None,
|
||||
system_modal: None,
|
||||
launcher: None,
|
||||
launcher_files: Default::default(),
|
||||
display_mode: Default::default(),
|
||||
guide_shown: false,
|
||||
guide_load: Default::default(),
|
||||
|
||||
@@ -348,18 +348,31 @@ pub fn animated_from_edge<'a, M: 'a>(
|
||||
Element::new(Motion {
|
||||
content,
|
||||
progress,
|
||||
regions,
|
||||
regions: Some(regions),
|
||||
radius,
|
||||
scale: 1.,
|
||||
direction: if edge == ferese_panel::Edge::Top { -1. } else { 1. },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn scaled<'a, M: 'a>(content: Element<'a, M>, scale: f32, regions: Regions) -> Element<'a, M> {
|
||||
Element::new(Motion {
|
||||
content,
|
||||
progress: 1.,
|
||||
regions: Some(regions),
|
||||
radius: 28.,
|
||||
direction: 0.,
|
||||
scale,
|
||||
})
|
||||
}
|
||||
|
||||
struct Motion<'a, M> {
|
||||
content: Element<'a, M>,
|
||||
progress: f32,
|
||||
regions: Regions,
|
||||
regions: Option<Regions>,
|
||||
radius: f32,
|
||||
direction: f32,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl<M> Motion<'_, M> {
|
||||
@@ -367,9 +380,11 @@ impl<M> Motion<'_, M> {
|
||||
Vector::new(0.0, self.direction * 4.0 * (1.0 - self.progress))
|
||||
}
|
||||
|
||||
fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor {
|
||||
fn cursor(&self, layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Cursor {
|
||||
cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
|
||||
mouse::Cursor::Available(p - self.translation())
|
||||
mouse::Cursor::Available(
|
||||
layout.bounds().center() + (p - layout.bounds().center() - self.translation()) / self.scale,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -381,13 +396,16 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
let mut nodes = self.content.as_widget().a11y_nodes(layout, tree, self.cursor(cursor));
|
||||
let mut nodes = self
|
||||
.content
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, tree, self.cursor(layout, cursor));
|
||||
let offset = self.translation();
|
||||
for node in nodes.root_mut() {
|
||||
translate_accessible_node(node, offset);
|
||||
transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
|
||||
}
|
||||
for node in nodes.children_mut() {
|
||||
translate_accessible_node(node, offset);
|
||||
transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
|
||||
}
|
||||
nodes
|
||||
}
|
||||
@@ -432,19 +450,27 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
shell: &mut Shell<'_, M>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let mut collector = CollectRegions(Vec::new());
|
||||
if let Some(regions) = &self.regions {
|
||||
let mut collected = regions.lock().unwrap();
|
||||
collected.clear();
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut collector);
|
||||
if collector.0.is_empty() {
|
||||
// A single-content popover (e.g. battery) has one inner card, not
|
||||
// a full-surface rectangle including the outer transparent padding.
|
||||
if let Some(content) = layout.children().next() {
|
||||
collector.0.push(content.bounds());
|
||||
.operate(tree, layout, renderer, &mut ReuseRegions(&mut collected));
|
||||
if collected.is_empty()
|
||||
&& let Some(content) = layout.children().next()
|
||||
{
|
||||
let bounds = content.bounds();
|
||||
collected.push([bounds.x, bounds.y, bounds.width, bounds.height, 0.]);
|
||||
}
|
||||
transform_regions(
|
||||
&mut collected,
|
||||
layout.bounds().center(),
|
||||
self.scale,
|
||||
self.translation(),
|
||||
self.radius,
|
||||
);
|
||||
}
|
||||
*self.regions.lock().unwrap() = collector.into_regions(self.translation(), self.radius);
|
||||
let cursor = self.cursor(cursor);
|
||||
let cursor = self.cursor(layout, cursor);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.update(tree, event, layout, cursor, renderer, clipboard, shell, viewport);
|
||||
@@ -461,16 +487,25 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let v = self.translation();
|
||||
let transform = Transformation::translate(v.x, v.y);
|
||||
let center = layout.bounds().center();
|
||||
let transform = Transformation::translate(center.x + v.x, center.y + v.y)
|
||||
* Transformation::scale(self.scale)
|
||||
* Transformation::translate(-center.x, -center.y);
|
||||
// Layout/region collection still run before the surface starts opening.
|
||||
// No child may paint an opaque control before the presentation begins.
|
||||
if self.progress <= 0.0 {
|
||||
return;
|
||||
}
|
||||
renderer.with_transformation(transform, |renderer| {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(tree, renderer, theme, style, layout, self.cursor(cursor), viewport)
|
||||
self.content.as_widget().draw(
|
||||
tree,
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
self.cursor(layout, cursor),
|
||||
viewport,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -484,7 +519,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, self.cursor(cursor), viewport, renderer)
|
||||
.mouse_interaction(tree, layout, self.cursor(layout, cursor), viewport, renderer)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
@@ -512,56 +547,55 @@ impl<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() {
|
||||
bounds.x0 += f64::from(offset.x);
|
||||
bounds.x1 += f64::from(offset.x);
|
||||
bounds.y0 += f64::from(offset.y);
|
||||
bounds.y1 += f64::from(offset.y);
|
||||
let x = f64::from(center.x);
|
||||
let y = f64::from(center.y);
|
||||
bounds.x0 = x + (bounds.x0 - x) * f64::from(scale) + f64::from(offset.x);
|
||||
bounds.x1 = x + (bounds.x1 - x) * f64::from(scale) + f64::from(offset.x);
|
||||
bounds.y0 = y + (bounds.y0 - y) * f64::from(scale) + f64::from(offset.y);
|
||||
bounds.y1 = y + (bounds.y1 - y) * f64::from(scale) + f64::from(offset.y);
|
||||
node.node_mut().set_bounds(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
struct CollectRegions(Vec<Rectangle>);
|
||||
|
||||
impl CollectRegions {
|
||||
fn into_regions(self, translation: Vector, radius: f32) -> Vec<[f32; 5]> {
|
||||
self.0
|
||||
.into_iter()
|
||||
.take(32)
|
||||
.filter(|r| r.width > 0.0 && r.height > 0.0)
|
||||
.map(|r| {
|
||||
[
|
||||
r.x + translation.x,
|
||||
r.y + translation.y,
|
||||
r.width,
|
||||
r.height,
|
||||
radius.max(0.0),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
fn transform_regions(regions: &mut [[f32; 5]], center: cosmic::iced::Point, scale: f32, offset: Vector, radius: f32) {
|
||||
for region in regions {
|
||||
region[0] = center.x + (region[0] - center.x) * scale + offset.x;
|
||||
region[1] = center.y + (region[1] - center.y) * scale + offset.y;
|
||||
region[2] *= scale;
|
||||
region[3] *= scale;
|
||||
region[4] = radius.max(0.) * scale;
|
||||
}
|
||||
}
|
||||
|
||||
impl widget::Operation for CollectRegions {
|
||||
struct ReuseRegions<'a>(&'a mut Vec<[f32; 5]>);
|
||||
impl widget::Operation for ReuseRegions<'_> {
|
||||
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
children(self);
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&widget::Id::new("ferese-blur-card")) {
|
||||
let contains = |outer: &Rectangle, inner: &Rectangle| {
|
||||
outer.x <= inner.x
|
||||
&& outer.y <= inner.y
|
||||
&& outer.x + outer.width >= inner.x + inner.width
|
||||
&& outer.y + outer.height >= inner.y + inner.height
|
||||
};
|
||||
// Nested controls belong to their enclosing card's single blur pass.
|
||||
if self.0.iter().any(|outer| contains(outer, &bounds)) {
|
||||
if id != Some(&widget::Id::new("ferese-blur-card")) || bounds.width <= 0. || bounds.height <= 0. {
|
||||
return;
|
||||
}
|
||||
self.0.retain(|inner| !contains(&bounds, inner));
|
||||
self.0.push(bounds);
|
||||
let r = [bounds.x, bounds.y, bounds.width, bounds.height, 0.];
|
||||
let contains = |outer: &[f32; 5], inner: &[f32; 5]| {
|
||||
outer[0] <= inner[0]
|
||||
&& outer[1] <= inner[1]
|
||||
&& outer[0] + outer[2] >= inner[0] + inner[2]
|
||||
&& outer[1] + outer[3] >= inner[1] + inner[3]
|
||||
};
|
||||
if self.0.iter().any(|outer| contains(outer, &r)) {
|
||||
return;
|
||||
}
|
||||
self.0.retain(|inner| !contains(&r, inner));
|
||||
if self.0.len() < 32 {
|
||||
self.0.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -728,22 +762,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn material_regions_preserve_fractional_layout_and_translation() {
|
||||
let collector = CollectRegions(vec![Rectangle::new((0.25, 2.5).into(), (100.25, 40.5).into())]);
|
||||
assert_eq!(
|
||||
collector.into_regions(Vector::new(0.125, -0.25), 13.5),
|
||||
vec![[0.375, 2.25, 100.25, 40.5, 13.5]]
|
||||
let mut regions = vec![[0.25, 2.5, 100.25, 40.5, 0.]];
|
||||
transform_regions(
|
||||
&mut regions,
|
||||
cosmic::iced::Point::ORIGIN,
|
||||
1.,
|
||||
Vector::new(0.125, -0.25),
|
||||
13.5,
|
||||
);
|
||||
assert_eq!(regions, vec![[0.375, 2.25, 100.25, 40.5, 13.5]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_modal_material_tracks_surface_position_and_animation() {
|
||||
let mut collector = CollectRegions(Vec::new());
|
||||
let mut regions = Vec::new();
|
||||
let mut collector = ReuseRegions(&mut regions);
|
||||
let card = Rectangle::new((380.0, 420.0).into(), (440.0, 220.0).into());
|
||||
widget::Operation::container(&mut collector, Some(&widget::Id::new("ferese-blur-card")), card);
|
||||
assert_eq!(
|
||||
collector.into_regions(Vector::new(0.0, -2.0), 14.0),
|
||||
vec![[380., 418., 440., 220., 14.]]
|
||||
transform_regions(
|
||||
&mut regions,
|
||||
cosmic::iced::Point::ORIGIN,
|
||||
1.,
|
||||
Vector::new(0.0, -2.0),
|
||||
14.0,
|
||||
);
|
||||
assert_eq!(regions, vec![[380., 418., 440., 220., 14.]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -901,7 +944,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
|
||||
let mut collector = CollectRegions(Vec::new());
|
||||
let mut regions = Vec::new();
|
||||
let mut collector = ReuseRegions(&mut regions);
|
||||
let id = widget::Id::new("ferese-blur-card");
|
||||
widget::Operation::container(
|
||||
&mut collector,
|
||||
@@ -912,12 +956,18 @@ mod tests {
|
||||
let second = Rectangle::new((160.0, 12.0).into(), (140.0, 80.0).into());
|
||||
widget::Operation::container(&mut collector, Some(&id), first);
|
||||
widget::Operation::container(&mut collector, Some(&id), second);
|
||||
assert_eq!(collector.0, vec![first, second]);
|
||||
assert_eq!(
|
||||
*collector.0,
|
||||
vec![[12., 12., 140., 80., 0.], [160., 12., 140., 80., 0.]]
|
||||
);
|
||||
widget::Operation::container(
|
||||
&mut collector,
|
||||
Some(&id),
|
||||
Rectangle::new((20.0, 20.0).into(), (40.0, 40.0).into()),
|
||||
);
|
||||
assert_eq!(collector.0, vec![first, second]);
|
||||
assert_eq!(
|
||||
*collector.0,
|
||||
vec![[12., 12., 140., 80., 0.], [160., 12., 140., 80., 0.]]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,17 @@ impl FereseShell {
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let launcher = if self
|
||||
.launcher
|
||||
.as_ref()
|
||||
.is_some_and(|l| Some(&l.output) == entry.name.as_ref())
|
||||
{
|
||||
self.destroy_launcher()
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let mut tasks = vec![
|
||||
launcher,
|
||||
self.dismiss_workspace_tooltip(),
|
||||
menu.chain(destroy_layer_surface(entry.bar)),
|
||||
self.rebuild_system_modal(),
|
||||
@@ -211,6 +221,7 @@ impl FereseShell {
|
||||
self.outputs
|
||||
.iter()
|
||||
.any(|entry| entry.bar == id || entry.wallpaper == Some(id))
|
||||
|| self.launcher.as_ref().is_some_and(|launcher| launcher.id == id)
|
||||
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|
||||
|| self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|
||||
|| self
|
||||
@@ -221,6 +232,9 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
|
||||
if let Some(task) = self.launcher_event(&event, id) {
|
||||
return task;
|
||||
}
|
||||
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) {
|
||||
return self.hub_hover(id, true);
|
||||
}
|
||||
@@ -736,16 +750,29 @@ impl EffectsBinding {
|
||||
.map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
|
||||
.collect();
|
||||
validate_region_counts(regions.len(), values.len())?;
|
||||
let next = (regions.to_vec(), opacity, values, role);
|
||||
if self.presentation.borrow().as_ref() != Some(&next) {
|
||||
let mut previous = self.presentation.borrow_mut();
|
||||
if previous
|
||||
.as_ref()
|
||||
.is_some_and(|old| old.0 == regions && old.1 == opacity && old.2 == values && old.3 == role)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.surface.set_presentation(
|
||||
role,
|
||||
encode_regions(regions),
|
||||
opacity,
|
||||
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.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(())
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ impl FereseShell {
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut tasks = vec![
|
||||
self.destroy_launcher(),
|
||||
self.destroy_system_modal(true),
|
||||
self.destroy_menu(),
|
||||
self.close_notification_history(),
|
||||
|
||||
Reference in new issue
Block a user