Compare commits

...

10 Commits

21 changed files with 731 additions and 144 deletions

309
Cargo.lock generated
View File

@@ -24,6 +24,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0453232ace82dee0dd0b4c87a59bd90f7b53b314f3e0f61fe2ee7c8a16482289"
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -37,6 +43,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "aligned" name = "aligned"
version = "0.4.3" version = "0.4.3"
@@ -351,7 +366,7 @@ dependencies = [
"anyhow", "anyhow",
"arrayvec", "arrayvec",
"log", "log",
"nom", "nom 8.0.0",
"num-rational", "num-rational",
"v_frame", "v_frame",
] ]
@@ -903,6 +918,15 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "dlv-list"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68df3f2b690c1b86e65ef7830956aededf3cb0a16f898f79b9a6f421a7b6211b"
dependencies = [
"rand 0.8.5",
]
[[package]] [[package]]
name = "document-features" name = "document-features"
version = "0.2.12" version = "0.2.12"
@@ -940,7 +964,7 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0dfe0859f3fb1bc6424c57d41e10e9093fe938f426b691e42272c2f336d915c" checksum = "d0dfe0859f3fb1bc6424c57d41e10e9093fe938f426b691e42272c2f336d915c"
dependencies = [ dependencies = [
"ahash", "ahash 0.8.12",
"bytemuck", "bytemuck",
"document-features", "document-features",
"egui", "egui",
@@ -976,7 +1000,7 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3"
dependencies = [ dependencies = [
"ahash", "ahash 0.8.12",
"bitflags 2.11.0", "bitflags 2.11.0",
"emath", "emath",
"epaint", "epaint",
@@ -991,7 +1015,7 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d319dfef570f699b6e9114e235e862a2ddcf75f0d1a061de9e1328d92146d820" checksum = "d319dfef570f699b6e9114e235e862a2ddcf75f0d1a061de9e1328d92146d820"
dependencies = [ dependencies = [
"ahash", "ahash 0.8.12",
"bytemuck", "bytemuck",
"document-features", "document-features",
"egui", "egui",
@@ -1011,7 +1035,7 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d9dfbb78fe4eb9c3a39ad528b90ee5915c252e77bbab9d4ebc576541ab67e13" checksum = "7d9dfbb78fe4eb9c3a39ad528b90ee5915c252e77bbab9d4ebc576541ab67e13"
dependencies = [ dependencies = [
"ahash", "ahash 0.8.12",
"arboard", "arboard",
"bytemuck", "bytemuck",
"egui", "egui",
@@ -1030,7 +1054,7 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e"
dependencies = [ dependencies = [
"ahash", "ahash 0.8.12",
"bytemuck", "bytemuck",
"egui", "egui",
"glow", "glow",
@@ -1091,7 +1115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"ahash", "ahash 0.8.12",
"bytemuck", "bytemuck",
"ecolor", "ecolor",
"emath", "emath",
@@ -1246,6 +1270,15 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "file-locker"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ae8b5984a4863d8a32109a848d038bd6d914f20f010cc141375f7a183c41cf"
dependencies = [
"nix",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -1306,7 +1339,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [ dependencies = [
"fontconfig-parser", "fontconfig-parser",
"log", "log",
"memmap2", "memmap2 0.9.10",
"slotmap", "slotmap",
"tinyvec", "tinyvec",
"ttf-parser", "ttf-parser",
@@ -1348,6 +1381,16 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "freedesktop_entry_parser"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db9c27b72f19a99a895f8ca89e2d26e4ef31013376e56fdafef697627306c3e4"
dependencies = [
"nom 7.1.3",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -1696,6 +1739,15 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "hashbrown"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04"
dependencies = [
"ahash 0.4.8",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -2227,6 +2279,7 @@ dependencies = [
"plugin-cmd", "plugin-cmd",
"plugin-files", "plugin-files",
"tokio", "tokio",
"tracing-subscriber",
] ]
[[package]] [[package]]
@@ -2339,6 +2392,12 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@@ -2410,6 +2469,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linicon"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ee8c5653188a809616c97296180a0547a61dba205bcdcbdd261dbd022a25fd9"
dependencies = [
"file-locker",
"freedesktop_entry_parser",
"linicon-theme",
"memmap2 0.5.10",
"thiserror 1.0.69",
]
[[package]]
name = "linicon-theme"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f8240c33bb08c5d8b8cdea87b683b05e61037aa76ff26bef40672cc6ecbb80"
dependencies = [
"freedesktop_entry_parser",
"rust-ini",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@@ -2473,6 +2555,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "maybe-rayon" name = "maybe-rayon"
version = "0.1.1" version = "0.1.1"
@@ -2489,6 +2580,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memmap2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "memmap2" name = "memmap2"
version = "0.9.10" version = "0.9.10"
@@ -2537,6 +2637,12 @@ dependencies = [
"paste", "paste",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2692,12 +2798,34 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nohash-hasher" name = "nohash-hasher"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "8.0.0" version = "8.0.0"
@@ -2713,6 +2841,25 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nucleo-matcher"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
dependencies = [
"memchr",
"unicode-segmentation",
]
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@@ -3185,6 +3332,16 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "ordered-multimap"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c672c7ad9ec066e428c00eb917124a06f08db19e2584de982cc34b1f4c12485"
dependencies = [
"dlv-list",
"hashbrown 0.9.1",
]
[[package]] [[package]]
name = "ordered-stream" name = "ordered-stream"
version = "0.2.0" version = "0.2.0"
@@ -3318,9 +3475,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"k-launcher-kernel", "k-launcher-kernel",
"linicon",
"nucleo-matcher",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing",
"xdg", "xdg",
] ]
@@ -3540,14 +3700,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha 0.9.0",
"rand_core", "rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -3557,7 +3738,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
] ]
[[package]] [[package]]
@@ -3608,8 +3798,8 @@ dependencies = [
"num-traits", "num-traits",
"paste", "paste",
"profiling", "profiling",
"rand", "rand 0.9.2",
"rand_chacha", "rand_chacha 0.9.0",
"simd_helpers", "simd_helpers",
"thiserror 2.0.18", "thiserror 2.0.18",
"v_frame", "v_frame",
@@ -3706,6 +3896,23 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "renderdoc-sys" name = "renderdoc-sys"
version = "1.1.0" version = "1.1.0"
@@ -3744,6 +3951,16 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rust-ini"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63471c4aa97a1cf8332a5f97709a79a4234698de6a1f5087faf66f2dae810e22"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@@ -3844,7 +4061,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"log", "log",
"memmap2", "memmap2 0.9.10",
"smithay-client-toolkit 0.19.2", "smithay-client-toolkit 0.19.2",
"tiny-skia", "tiny-skia",
] ]
@@ -3924,6 +4141,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -4029,7 +4255,7 @@ dependencies = [
"cursor-icon", "cursor-icon",
"libc", "libc",
"log", "log",
"memmap2", "memmap2 0.9.10",
"rustix 0.38.44", "rustix 0.38.44",
"thiserror 1.0.69", "thiserror 1.0.69",
"wayland-backend", "wayland-backend",
@@ -4054,7 +4280,7 @@ dependencies = [
"cursor-icon", "cursor-icon",
"libc", "libc",
"log", "log",
"memmap2", "memmap2 0.9.10",
"rustix 1.1.4", "rustix 1.1.4",
"thiserror 2.0.18", "thiserror 2.0.18",
"wayland-backend", "wayland-backend",
@@ -4099,7 +4325,7 @@ dependencies = [
"bytemuck", "bytemuck",
"fastrand", "fastrand",
"js-sys", "js-sys",
"memmap2", "memmap2 0.9.10",
"ndk", "ndk",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
@@ -4292,6 +4518,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tiff" name = "tiff"
version = "0.10.3" version = "0.10.3"
@@ -4477,6 +4712,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -4647,6 +4912,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -5607,7 +5878,7 @@ version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [ dependencies = [
"ahash", "ahash 0.8.12",
"android-activity", "android-activity",
"atomic-waker", "atomic-waker",
"bitflags 2.11.0", "bitflags 2.11.0",
@@ -5622,7 +5893,7 @@ dependencies = [
"dpi", "dpi",
"js-sys", "js-sys",
"libc", "libc",
"memmap2", "memmap2 0.9.10",
"ndk", "ndk",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",

View File

@@ -2,9 +2,7 @@
A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI. A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI.
``` ![k-launcher](docs/screenshot.png)
[screenshot placeholder]
```
## Quick Start ## Quick Start
@@ -24,6 +22,23 @@ cargo build --release
| `Enter` | Launch selected | | `Enter` | Launch selected |
| `Escape` | Close | | `Escape` | Close |
## Compositor Setup
k-launcher uses a normal window; configure your compositor to float it.
**Hyprland** (`~/.config/hypr/hyprland.conf`):
```
windowrule = float, ^(k-launcher)$
windowrule = center, ^(k-launcher)$
bind = SUPER, Space, exec, k-launcher
```
**Sway** (`~/.config/sway/config`):
```
for_window [app_id="k-launcher"] floating enable, move position center
bindsym Mod4+space exec k-launcher
```
## Built-in Plugins ## Built-in Plugins
| Trigger | Plugin | Example | | Trigger | Plugin | Example |

View File

@@ -107,8 +107,7 @@ impl Default for PluginsCfg {
} }
pub fn load() -> Config { pub fn load() -> Config {
let path = dirs::config_dir() let path = dirs::config_dir().map(|d| d.join("k-launcher").join("config.toml"));
.map(|d| d.join("k-launcher").join("config.toml"));
let Some(path) = path else { let Some(path) = path else {
return Config::default(); return Config::default();
}; };

View File

@@ -29,7 +29,9 @@ impl ResultTitle {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] #[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Score(u32); pub struct Score(u32);
impl Score { impl Score {
@@ -104,7 +106,10 @@ pub struct Kernel {
impl Kernel { impl Kernel {
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self { pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
Self { plugins, max_results } Self {
plugins,
max_results,
}
} }
pub async fn search(&self, query: &str) -> Vec<SearchResult> { pub async fn search(&self, query: &str) -> Vec<SearchResult> {

View File

@@ -1,5 +1,5 @@
use std::process::{Command, Stdio};
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use k_launcher_kernel::{AppLauncher, LaunchAction}; use k_launcher_kernel::{AppLauncher, LaunchAction};
@@ -77,21 +77,31 @@ impl AppLauncher for UnixAppLauncher {
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) }) .pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn() .spawn()
}; };
} }
} }
LaunchAction::SpawnInTerminal(cmd) => { LaunchAction::SpawnInTerminal(cmd) => {
let Some((term_bin, term_args)) = resolve_terminal() else { return }; let Some((term_bin, term_args)) = resolve_terminal() else {
return;
};
let _ = unsafe { let _ = unsafe {
Command::new(&term_bin) Command::new(&term_bin)
.args(&term_args) .args(&term_args)
.arg("sh").arg("-c").arg(cmd) .arg("sh")
.arg("-c")
.arg(cmd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) }) .pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn() .spawn()
}; };
} }

View File

@@ -43,7 +43,9 @@ async fn do_search(
io: &mut ProcessIo, io: &mut ProcessIo,
query: &str, query: &str,
) -> Result<Vec<ExternalResult>, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<Vec<ExternalResult>, Box<dyn std::error::Error + Send + Sync>> {
let line = serde_json::to_string(&Query { query: query.to_string() })?; let line = serde_json::to_string(&Query {
query: query.to_string(),
})?;
io.stdin.write_all(line.as_bytes()).await?; io.stdin.write_all(line.as_bytes()).await?;
io.stdin.write_all(b"\n").await?; io.stdin.write_all(b"\n").await?;
io.stdin.flush().await?; io.stdin.flush().await?;
@@ -143,7 +145,9 @@ mod tests {
#[test] #[test]
fn query_serializes_correctly() { fn query_serializes_correctly() {
let q = Query { query: "firefox".to_string() }; let q = Query {
query: "firefox".to_string(),
};
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#); assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
} }
@@ -155,21 +159,27 @@ mod tests {
assert_eq!(results[0].id, "1"); assert_eq!(results[0].id, "1");
assert_eq!(results[0].title, "Firefox"); assert_eq!(results[0].title, "Firefox");
assert_eq!(results[0].score, 80); assert_eq!(results[0].score, 80);
assert!(matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox")); assert!(
matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox")
);
} }
#[test] #[test]
fn result_parses_copy_action() { fn result_parses_copy_action() {
let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#; let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap(); let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4")); assert!(
matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4")
);
} }
#[test] #[test]
fn result_parses_open_path_action() { fn result_parses_open_path_action() {
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#; let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap(); let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user")); assert!(
matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user")
);
} }
#[test] #[test]
@@ -182,7 +192,8 @@ mod tests {
#[test] #[test]
fn result_parses_missing_optional_fields() { fn result_parses_missing_optional_fields() {
let json = r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#; let json =
r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap(); let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(results[0].description.is_none()); assert!(results[0].description.is_none());
assert!(results[0].icon.is_none()); assert!(results[0].icon.is_none());

View File

@@ -127,11 +127,20 @@ impl eframe::App for KLauncherApp {
ui.set_width(ui.available_width()); ui.set_width(ui.available_width());
for (i, result) in self.results.iter().enumerate() { for (i, result) in self.results.iter().enumerate() {
let is_selected = i == self.selected; let is_selected = i == self.selected;
let bg = if is_selected { SELECTED_BG } else { Color32::TRANSPARENT }; let bg = if is_selected {
SELECTED_BG
} else {
Color32::TRANSPARENT
};
let row_frame = egui::Frame::new() let row_frame = egui::Frame::new()
.fill(bg) .fill(bg)
.inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 6 }) .inner_margin(egui::Margin {
left: 8,
right: 8,
top: 6,
bottom: 6,
})
.corner_radius(egui::CornerRadius::same(4)); .corner_radius(egui::CornerRadius::same(4));
row_frame.show(ui, |ui| { row_frame.show(ui, |ui| {

View File

@@ -1,10 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use iced::{ use iced::{
Border, Color, Element, Length, Size, Subscription, Task, Border, Color, Element, Length, Size, Subscription, Task, event,
event,
keyboard::{Event as KeyEvent, Key, key::Named}, keyboard::{Event as KeyEvent, Key, key::Named},
widget::{column, container, image, row, scrollable, svg, text, text_input, Space}, widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
window, window,
}; };
@@ -26,6 +25,8 @@ pub struct KLauncherApp {
results: Arc<Vec<SearchResult>>, results: Arc<Vec<SearchResult>>,
selected: usize, selected: usize,
cfg: AppearanceCfg, cfg: AppearanceCfg,
error: Option<String>,
search_epoch: u64,
} }
impl KLauncherApp { impl KLauncherApp {
@@ -41,6 +42,8 @@ impl KLauncherApp {
results: Arc::new(vec![]), results: Arc::new(vec![]),
selected: 0, selected: 0,
cfg, cfg,
error: None,
search_epoch: 0,
} }
} }
} }
@@ -48,23 +51,31 @@ impl KLauncherApp {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
QueryChanged(String), QueryChanged(String),
ResultsReady(Arc<Vec<SearchResult>>), ResultsReady(u64, Arc<Vec<SearchResult>>),
KeyPressed(KeyEvent), KeyPressed(KeyEvent),
} }
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> { fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
match message { match message {
Message::QueryChanged(q) => { Message::QueryChanged(q) => {
state.error = None;
state.query = q.clone(); state.query = q.clone();
state.selected = 0; state.selected = 0;
state.search_epoch += 1;
let epoch = state.search_epoch;
let engine = state.engine.clone(); let engine = state.engine.clone();
Task::perform( Task::perform(
async move { engine.search(&q).await }, async move {
|results| Message::ResultsReady(Arc::new(results)), tokio::time::sleep(std::time::Duration::from_millis(50)).await;
(epoch, engine.search(&q).await)
},
|(epoch, results)| Message::ResultsReady(epoch, Arc::new(results)),
) )
} }
Message::ResultsReady(results) => { Message::ResultsReady(epoch, results) => {
if epoch == state.search_epoch {
state.results = results; state.results = results;
}
Task::none() Task::none()
} }
Message::KeyPressed(event) => { Message::KeyPressed(event) => {
@@ -114,8 +125,13 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
.padding(12) .padding(12)
.size(cfg.search_font_size) .size(cfg.search_font_size)
.style(|theme, _status| { .style(|theme, _status| {
let mut s = iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active); let mut s =
s.border = Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into() }; iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active);
s.border = Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
};
s s
}); });
@@ -135,26 +151,27 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
Color::from_rgba8(255, 255, 255, 0.07) Color::from_rgba8(255, 255, 255, 0.07)
}; };
let icon_el: Element<'_, Message> = match &result.icon { let icon_el: Element<'_, Message> = match &result.icon {
Some(p) if p.ends_with(".svg") => Some(p) if p.ends_with(".svg") => {
svg(svg::Handle::from_path(p)).width(24).height(24).into(), svg(svg::Handle::from_path(p)).width(24).height(24).into()
Some(p) => }
image(image::Handle::from_path(p)).width(24).height(24).into(), Some(p) => image(image::Handle::from_path(p))
.width(24)
.height(24)
.into(),
None => Space::new().width(24).height(24).into(), None => Space::new().width(24).height(24).into(),
}; };
let title_col: Element<'_, Message> = if let Some(desc) = &result.description { let title_col: Element<'_, Message> = if let Some(desc) = &result.description {
column![ column![
text(result.title.as_str()).size(title_size), text(result.title.as_str()).size(title_size),
text(desc).size(desc_size).color(Color::from_rgba8(210, 215, 230, 1.0)), text(desc)
.size(desc_size)
.color(Color::from_rgba8(210, 215, 230, 1.0)),
] ]
.into() .into()
} else { } else {
text(result.title.as_str()).size(title_size).into() text(result.title.as_str()).size(title_size).into()
}; };
container( container(row![icon_el, title_col].spacing(8).align_y(iced::Center))
row![icon_el, title_col]
.spacing(8)
.align_y(iced::Center),
)
.width(Length::Fill) .width(Length::Fill)
.padding([6, 12]) .padding([6, 12])
.style(move |_theme| container::Style { .style(move |_theme| container::Style {
@@ -186,7 +203,23 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill) scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill)
}; };
let content = column![search_bar, results_list] let maybe_error: Option<Element<'_, Message>> = state.error.as_ref().map(|msg| {
container(
text(msg.as_str())
.size(12.0)
.color(Color::from_rgba8(255, 80, 80, 1.0)),
)
.width(Length::Fill)
.padding([4, 12])
.into()
});
let mut content_children: Vec<Element<'_, Message>> =
vec![search_bar.into(), results_list.into()];
if let Some(err) = maybe_error {
content_children.push(err);
}
let content = column(content_children)
.spacing(8) .spacing(8)
.padding(12) .padding(12)
.width(Length::Fill) .width(Length::Fill)

View File

@@ -35,3 +35,4 @@ plugin-calc = { path = "../plugins/plugin-calc" }
plugin-cmd = { path = "../plugins/plugin-cmd" } plugin-cmd = { path = "../plugins/plugin-cmd" }
plugin-files = { path = "../plugins/plugin-files" } plugin-files = { path = "../plugins/plugin-files" }
tokio = { workspace = true } tokio = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -0,0 +1,10 @@
use std::io::Write;
pub fn send_show() -> Result<(), Box<dyn std::error::Error>> {
let runtime_dir =
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
let socket_path = format!("{runtime_dir}/k-launcher.sock");
let mut stream = std::os::unix::net::UnixStream::connect(&socket_path)?;
stream.write_all(b"show\n")?;
Ok(())
}

View File

@@ -1,29 +1,62 @@
mod client;
use std::sync::Arc; use std::sync::Arc;
use k_launcher_kernel::Kernel; use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher; use k_launcher_os_bridge::UnixAppLauncher;
use k_launcher_plugin_host::ExternalPlugin; use k_launcher_plugin_host::ExternalPlugin;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource; use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin; use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin; use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin; use plugin_files::FilesPlugin;
fn main() -> iced::Result { fn main() {
tracing_subscriber::fmt::init();
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(|s| s.as_str()) == Some("show") {
if let Err(e) = client::send_show() {
eprintln!("error: failed to send show command: {e}");
std::process::exit(1);
}
return;
}
if let Err(e) = run_ui() {
eprintln!("error: UI: {e}");
std::process::exit(1);
}
}
fn run_ui() -> iced::Result {
let cfg = k_launcher_config::load(); let cfg = k_launcher_config::load();
let launcher = Arc::new(UnixAppLauncher::new()); let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load(); let frecency = FrecencyStore::load();
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![]; let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
if cfg.plugins.cmd { plugins.push(Arc::new(CmdPlugin::new())); } if cfg.plugins.cmd {
if cfg.plugins.calc { plugins.push(Arc::new(CalcPlugin::new())); } plugins.push(Arc::new(CmdPlugin::new()));
if cfg.plugins.files { plugins.push(Arc::new(FilesPlugin::new())); } }
if cfg.plugins.calc {
plugins.push(Arc::new(CalcPlugin::new()));
}
if cfg.plugins.files {
plugins.push(Arc::new(FilesPlugin::new()));
}
if cfg.plugins.apps { if cfg.plugins.apps {
plugins.push(Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency))); plugins.push(Arc::new(AppsPlugin::new(
FsDesktopEntrySource::new(),
frecency,
)));
} }
for ext in &cfg.plugins.external { for ext in &cfg.plugins.external {
plugins.push(Arc::new(ExternalPlugin::new(&ext.name, &ext.path, ext.args.clone()))); plugins.push(Arc::new(ExternalPlugin::new(
&ext.name,
&ext.path,
ext.args.clone(),
)));
} }
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =

View File

@@ -2,9 +2,9 @@ use std::sync::Arc;
use k_launcher_kernel::Kernel; use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher; use k_launcher_os_bridge::UnixAppLauncher;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource; use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin; use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin; use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin; use plugin_files::FilesPlugin;
@@ -12,12 +12,15 @@ use plugin_files::FilesPlugin;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let launcher = Arc::new(UnixAppLauncher::new()); let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load(); let frecency = FrecencyStore::load();
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(vec![ let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
vec![
Arc::new(CmdPlugin::new()), Arc::new(CmdPlugin::new()),
Arc::new(CalcPlugin::new()), Arc::new(CalcPlugin::new()),
Arc::new(FilesPlugin::new()), Arc::new(FilesPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)), Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
], 8)); ],
8,
));
k_launcher_ui_egui::run(kernel, launcher)?; k_launcher_ui_egui::run(kernel, launcher)?;
Ok(()) Ok(())
} }

View File

@@ -10,9 +10,12 @@ path = "src/lib.rs"
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" } k-launcher-kernel = { path = "../../k-launcher-kernel" }
nucleo-matcher = "0.3"
serde = { workspace = true } serde = { workspace = true }
serde_json = "1.0" serde_json = "1.0"
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
linicon = "2.3.0"
xdg = "3" xdg = "3"

View File

@@ -24,7 +24,10 @@ impl FrecencyStore {
.ok() .ok()
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(); .unwrap_or_default();
Arc::new(Self { path, data: Mutex::new(data) }) Arc::new(Self {
path,
data: Mutex::new(data),
})
} }
#[cfg(test)] #[cfg(test)]
@@ -36,11 +39,14 @@ impl FrecencyStore {
} }
pub fn load() -> Arc<Self> { pub fn load() -> Arc<Self> {
let path = xdg::BaseDirectories::new() let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else {
.get_data_home() tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)");
.unwrap_or_else(|| PathBuf::from(".")) return Arc::new(Self {
.join("k-launcher") path: PathBuf::from("/dev/null"),
.join("frecency.json"); data: Mutex::new(HashMap::new()),
});
};
let path = data_home.join("k-launcher").join("frecency.json");
Self::new(path) Self::new(path)
} }
@@ -50,7 +56,10 @@ impl FrecencyStore {
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let mut data = self.data.lock().unwrap(); let mut data = self.data.lock().unwrap();
let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 }); let entry = data.entry(id.to_string()).or_insert(Entry {
count: 0,
last_used: 0,
});
entry.count += 1; entry.count += 1;
entry.last_used = now; entry.last_used = now;
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
@@ -69,7 +78,13 @@ impl FrecencyStore {
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let age_secs = now.saturating_sub(entry.last_used); let age_secs = now.saturating_sub(entry.last_used);
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 }; let decay = if age_secs < 3600 {
4
} else if age_secs < 86400 {
2
} else {
1
};
entry.count * decay entry.count * decay
} }
@@ -83,7 +98,13 @@ impl FrecencyStore {
.iter() .iter()
.map(|(id, entry)| { .map(|(id, entry)| {
let age_secs = now.saturating_sub(entry.last_used); let age_secs = now.saturating_sub(entry.last_used);
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 }; let decay = if age_secs < 3600 {
4
} else if age_secs < 86400 {
2
} else {
1
};
(id.clone(), entry.count * decay) (id.clone(), entry.count * decay)
}) })
.collect(); .collect();

View File

@@ -68,7 +68,6 @@ pub trait DesktopEntrySource: Send + Sync {
struct CachedEntry { struct CachedEntry {
id: String, id: String,
name: AppName, name: AppName,
name_lc: String,
keywords_lc: Vec<String>, keywords_lc: Vec<String>,
category: Option<String>, category: Option<String>,
icon: Option<String>, icon: Option<String>,
@@ -90,10 +89,12 @@ impl AppsPlugin {
.into_iter() .into_iter()
.map(|e| { .map(|e| {
let id = format!("app-{}", e.name.as_str()); let id = format!("app-{}", e.name.as_str());
let name_lc = e.name.as_str().to_lowercase();
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect(); let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str())); let icon = e
.icon
.as_ref()
.and_then(|p| linux::resolve_icon_path(p.as_str()));
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
let icon: Option<String> = None; let icon: Option<String> = None;
let exec = e.exec.as_str().to_string(); let exec = e.exec.as_str().to_string();
@@ -104,7 +105,6 @@ impl AppsPlugin {
}); });
let cached = CachedEntry { let cached = CachedEntry {
id: id.clone(), id: id.clone(),
name_lc,
keywords_lc, keywords_lc,
category: e.category, category: e.category,
icon, icon,
@@ -120,15 +120,37 @@ impl AppsPlugin {
} }
fn initials(name_lc: &str) -> String { fn initials(name_lc: &str) -> String {
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect() name_lc
.split_whitespace()
.filter_map(|w| w.chars().next())
.collect()
} }
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> { fn score_match(name: &str, query: &str) -> Option<u32> {
if name_lc == query_lc { return Some(100); } use nucleo_matcher::{
if name_lc.starts_with(query_lc) { return Some(80); } Config, Matcher, Utf32Str,
if name_lc.contains(query_lc) { return Some(60); } pattern::{CaseMatching, Normalization, Pattern},
if initials(name_lc).starts_with(query_lc) { return Some(70); } };
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut name_chars: Vec<char> = name.chars().collect();
let haystack = Utf32Str::new(name, &mut name_chars);
let score = pattern.score(haystack, &mut matcher);
if let Some(s) = score {
let name_lc = name.to_lowercase();
let query_lc = query.to_lowercase();
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
20
} else {
0
};
Some(s.saturating_add(bonus))
} else {
None None
}
} }
pub(crate) fn humanize_category(s: &str) -> String { pub(crate) fn humanize_category(s: &str) -> String {
@@ -150,7 +172,9 @@ impl Plugin for AppsPlugin {
async fn search(&self, query: &str) -> Vec<SearchResult> { async fn search(&self, query: &str) -> Vec<SearchResult> {
if query.is_empty() { if query.is_empty() {
return self.frecency.top_ids(5) return self
.frecency
.top_ids(5)
.iter() .iter()
.filter_map(|id| { .filter_map(|id| {
let e = self.entries.get(id)?; let e = self.entries.get(id)?;
@@ -172,8 +196,11 @@ impl Plugin for AppsPlugin {
self.entries self.entries
.values() .values()
.filter_map(|e| { .filter_map(|e| {
let score = score_match(&e.name_lc, &query_lc).or_else(|| { let score = score_match(e.name.as_str(), query).or_else(|| {
e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50) e.keywords_lc
.iter()
.any(|k| k.contains(&query_lc))
.then_some(50)
})?; })?;
Some(SearchResult { Some(SearchResult {
id: ResultId::new(&e.id), id: ResultId::new(&e.id),
@@ -226,7 +253,14 @@ mod tests {
Self { Self {
entries: entries entries: entries
.into_iter() .into_iter()
.map(|(n, e, kw)| (n.to_string(), e.to_string(), None, kw.into_iter().map(|s| s.to_string()).collect())) .map(|(n, e, kw)| {
(
n.to_string(),
e.to_string(),
None,
kw.into_iter().map(|s| s.to_string()).collect(),
)
})
.collect(), .collect(),
} }
} }
@@ -249,35 +283,49 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_prefix_match() { async fn apps_prefix_match() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency()); let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("fire").await; let results = p.search("fire").await;
assert_eq!(results[0].title.as_str(), "Firefox"); assert_eq!(results[0].title.as_str(), "Firefox");
} }
#[tokio::test] #[tokio::test]
async fn apps_no_match_returns_empty() { async fn apps_no_match_returns_empty() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency()); let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("zz").await.is_empty()); assert!(p.search("zz").await.is_empty());
} }
#[tokio::test] #[tokio::test]
async fn apps_empty_query_no_frecency_returns_empty() { async fn apps_empty_query_no_frecency_returns_empty() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency()); let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("").await.is_empty()); assert!(p.search("").await.is_empty());
} }
#[test] #[test]
fn score_match_abbreviation() { fn score_match_abbreviation() {
assert_eq!(initials("visual studio code"), "vsc"); assert_eq!(initials("visual studio code"), "vsc");
assert_eq!(score_match("visual studio code", "vsc"), Some(70)); assert!(score_match("visual studio code", "vsc").is_some());
} }
#[test] #[test]
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() { fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
assert_eq!(score_match("firefox", "firefox"), Some(100)); let exact = score_match("firefox", "firefox");
assert_eq!(score_match("firefox", "fire"), Some(80)); let prefix = score_match("firefox", "fire");
assert_eq!(score_match("gnu firefox", "gf"), Some(70)); let abbrev = score_match("gnu firefox", "gf");
assert_eq!(score_match("ice firefox", "fire"), Some(60)); let substr = score_match("ice firefox", "fire");
assert!(exact.is_some());
assert!(prefix.is_some());
assert!(abbrev.is_some());
assert!(substr.is_some());
assert!(exact.unwrap() > prefix.unwrap());
} }
#[tokio::test] #[tokio::test]
@@ -289,7 +337,7 @@ mod tests {
let results = p.search("vsc").await; let results = p.search("vsc").await;
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "Visual Studio Code"); assert_eq!(results[0].title.as_str(), "Visual Studio Code");
assert_eq!(results[0].score.value(), 70); assert!(results[0].score.value() > 0);
} }
#[tokio::test] #[tokio::test]
@@ -303,6 +351,20 @@ mod tests {
assert_eq!(results[0].score.value(), 50); assert_eq!(results[0].score.value(), 50);
} }
#[tokio::test]
async fn apps_fuzzy_typo_match() {
let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("frefox").await;
assert!(
!results.is_empty(),
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
);
assert!(results[0].score.value() > 0);
}
#[test] #[test]
fn humanize_category_splits_camel_case() { fn humanize_category_splits_camel_case() {
assert_eq!(humanize_category("TextEditor"), "Text Editor"); assert_eq!(humanize_category("TextEditor"), "Text Editor");

View File

@@ -1,7 +1,7 @@
use std::path::Path; use std::path::Path;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
use crate::humanize_category; use crate::humanize_category;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
pub struct FsDesktopEntrySource; pub struct FsDesktopEntrySource;
@@ -45,15 +45,79 @@ impl DesktopEntrySource for FsDesktopEntrySource {
} }
} }
pub(crate) fn clean_exec(exec: &str) -> String {
// Tokenize respecting double-quoted strings, then filter field codes.
let mut tokens: Vec<String> = Vec::new();
let mut chars = exec.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
if ch == '"' {
// Consume opening quote
chars.next();
let mut token = String::from('"');
while let Some(&c) = chars.peek() {
chars.next();
if c == '"' {
token.push('"');
break;
}
token.push(c);
}
// Strip embedded field codes like %f inside the quoted string
// (between the quotes, before re-assembling)
let inner = &token[1..token.len().saturating_sub(1)];
let cleaned_inner: String = inner
.split_whitespace()
.filter(|s| !is_field_code(s))
.collect::<Vec<_>>()
.join(" ");
tokens.push(format!("\"{cleaned_inner}\""));
} else {
let mut token = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
break;
}
chars.next();
token.push(c);
}
if !is_field_code(&token) {
tokens.push(token);
}
}
}
tokens.join(" ")
}
fn is_field_code(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
}
pub fn resolve_icon_path(name: &str) -> Option<String> { pub fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() { if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string()); return Some(name.to_string());
} }
// Try linicon freedesktop theme traversal
let themes = ["hicolor", "Adwaita", "breeze", "Papirus"];
for theme in &themes {
if let Some(icon_path) = linicon::lookup_icon(name)
.from_theme(theme)
.with_size(48)
.find_map(|r| r.ok())
{
return Some(icon_path.path.to_string_lossy().into_owned());
}
}
// Fallback to pixmaps
let candidates = [ let candidates = [
format!("/usr/share/pixmaps/{name}.png"), format!("/usr/share/pixmaps/{name}.png"),
format!("/usr/share/pixmaps/{name}.svg"), format!("/usr/share/pixmaps/{name}.svg"),
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
]; ];
candidates.into_iter().find(|p| Path::new(p).exists()) candidates.into_iter().find(|p| Path::new(p).exists())
} }
@@ -90,13 +154,15 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
"Type" if !is_application => is_application = value.trim() == "Application", "Type" if !is_application => is_application = value.trim() == "Application",
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"), "NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
"Categories" if category.is_none() => { "Categories" if category.is_none() => {
category = value.trim() category = value
.trim()
.split(';') .split(';')
.find(|s| !s.is_empty()) .find(|s| !s.is_empty())
.map(|s| humanize_category(s.trim())); .map(|s| humanize_category(s.trim()));
} }
"Keywords" if keywords.is_empty() => { "Keywords" if keywords.is_empty() => {
keywords = value.trim() keywords = value
.trim()
.split(';') .split(';')
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
@@ -111,16 +177,7 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
return None; return None;
} }
let exec_clean: String = exec? let exec_clean: String = clean_exec(&exec?);
.split_whitespace()
.filter(|s| !s.starts_with('%'))
.fold(String::new(), |mut acc, s| {
if !acc.is_empty() {
acc.push(' ');
}
acc.push_str(s);
acc
});
Some(DesktopEntry { Some(DesktopEntry {
name: AppName::new(name?), name: AppName::new(name?),
@@ -130,3 +187,31 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
keywords, keywords,
}) })
} }
#[cfg(test)]
mod exec_tests {
use super::clean_exec;
#[test]
fn strips_bare_field_code() {
assert_eq!(clean_exec("app --file %f"), "app --file");
}
#[test]
fn strips_multiple_field_codes() {
assert_eq!(clean_exec("app %U --flag"), "app --flag");
}
#[test]
fn preserves_quoted_value() {
assert_eq!(
clean_exec(r#"app --arg="value" %U"#),
r#"app --arg="value""#
);
}
#[test]
fn handles_plain_exec() {
assert_eq!(clean_exec("firefox"), "firefox");
}
}

View File

@@ -22,8 +22,8 @@ fn strip_numeric_separators(expr: &str) -> String {
} }
const MATH_FNS: &[&str] = &[ const MATH_FNS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
"ln", "log2", "log10", "exp", "abs", "ceil", "floor", "round", "ceil", "floor", "round",
]; ];
fn should_eval(query: &str) -> bool { fn should_eval(query: &str) -> bool {
@@ -36,8 +36,8 @@ fn should_eval(query: &str) -> bool {
|| MATH_FNS.iter().any(|f| q.starts_with(f)) || MATH_FNS.iter().any(|f| q.starts_with(f))
} }
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> = static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> = LazyLock::new(
LazyLock::new(|| { || {
use evalexpr::*; use evalexpr::*;
context_map! { context_map! {
"pi" => float std::f64::consts::PI, "pi" => float std::f64::consts::PI,
@@ -59,7 +59,8 @@ static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round()))) "round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
} }
.expect("static math context must be valid") .expect("static math context must be valid")
}); },
);
#[async_trait] #[async_trait]
impl Plugin for CalcPlugin { impl Plugin for CalcPlugin {

View File

@@ -76,11 +76,7 @@ impl Plugin for FilesPlugin {
let full_path = entry.path(); let full_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
let is_dir = full_path.is_dir(); let is_dir = full_path.is_dir();
let title = if is_dir { let title = if is_dir { format!("{name}/") } else { name };
format!("{name}/")
} else {
name
};
let path_str = full_path.to_string_lossy().to_string(); let path_str = full_path.to_string_lossy().to_string();
SearchResult { SearchResult {
id: ResultId::new(format!("file-{i}")), id: ResultId::new(format!("file-{i}")),

0
docs/screenshot.png Normal file
View File

View File

@@ -0,0 +1,11 @@
[Unit]
Description=k-launcher command palette daemon
After=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/k-launcher
Restart=on-failure
[Install]
WantedBy=graphical-session.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=k-launcher IPC socket
[Socket]
ListenStream=%t/k-launcher.sock
[Install]
WantedBy=sockets.target