From 71b8e46ae6c11ca008cbb7937ce8ffb9805c34da Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 15 Mar 2026 19:03:30 +0000 Subject: [PATCH] feature/prod-ready (#1) Reviewed-on: https://git.gabrielkaszewski.dev/GKaszewski/k-launcher/pulls/1 --- .github/workflows/ci.yml | 39 +++ .github/workflows/release.yml | 21 ++ Cargo.lock | 309 ++++++++++++++++-- README.md | 21 +- crates/k-launcher-config/src/lib.rs | 3 +- crates/k-launcher-kernel/src/lib.rs | 9 +- .../k-launcher-os-bridge/src/unix_launcher.rs | 20 +- crates/k-launcher-plugin-host/src/lib.rs | 23 +- crates/k-launcher-ui-egui/src/app.rs | 13 +- crates/k-launcher-ui/src/app.rs | 97 ++++-- crates/k-launcher/Cargo.toml | 1 + crates/k-launcher/src/client.rs | 10 + crates/k-launcher/src/main.rs | 49 ++- crates/k-launcher/src/main_egui.rs | 17 +- crates/plugins/plugin-apps/Cargo.toml | 3 + crates/plugins/plugin-apps/src/frecency.rs | 39 ++- crates/plugins/plugin-apps/src/lib.rs | 110 +++++-- crates/plugins/plugin-apps/src/linux.rs | 115 ++++++- crates/plugins/plugin-calc/src/lib.rs | 11 +- crates/plugins/plugin-files/src/lib.rs | 6 +- docs/screenshot.png | 0 packaging/aur/.SRCINFO | 15 + packaging/aur/PKGBUILD | 17 + packaging/systemd/k-launcher.service | 11 + packaging/systemd/k-launcher.socket | 8 + 25 files changed, 823 insertions(+), 144 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 crates/k-launcher/src/client.rs create mode 100644 docs/screenshot.png create mode 100644 packaging/aur/.SRCINFO create mode 100644 packaging/aur/PKGBUILD create mode 100644 packaging/systemd/k-launcher.service create mode 100644 packaging/systemd/k-launcher.socket diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..34bbb2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install system deps + run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config + - run: cargo test --workspace + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Install system deps + run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config + - run: cargo clippy --workspace -- -D warnings + + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2fe81f8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install system deps + run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config + - run: cargo build --release + - uses: actions/upload-artifact@v4 + with: + name: k-launcher + path: target/release/k-launcher diff --git a/Cargo.lock b/Cargo.lock index 3bb909b..3313342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0453232ace82dee0dd0b4c87a59bd90f7b53b314f3e0f61fe2ee7c8a16482289" + [[package]] name = "ahash" version = "0.8.12" @@ -37,6 +43,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "aligned" version = "0.4.3" @@ -351,7 +366,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -903,6 +918,15 @@ dependencies = [ "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]] name = "document-features" version = "0.2.12" @@ -940,7 +964,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0dfe0859f3fb1bc6424c57d41e10e9093fe938f426b691e42272c2f336d915c" dependencies = [ - "ahash", + "ahash 0.8.12", "bytemuck", "document-features", "egui", @@ -976,7 +1000,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" dependencies = [ - "ahash", + "ahash 0.8.12", "bitflags 2.11.0", "emath", "epaint", @@ -991,7 +1015,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d319dfef570f699b6e9114e235e862a2ddcf75f0d1a061de9e1328d92146d820" dependencies = [ - "ahash", + "ahash 0.8.12", "bytemuck", "document-features", "egui", @@ -1011,7 +1035,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d9dfbb78fe4eb9c3a39ad528b90ee5915c252e77bbab9d4ebc576541ab67e13" dependencies = [ - "ahash", + "ahash 0.8.12", "arboard", "bytemuck", "egui", @@ -1030,7 +1054,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" dependencies = [ - "ahash", + "ahash 0.8.12", "bytemuck", "egui", "glow", @@ -1091,7 +1115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" dependencies = [ "ab_glyph", - "ahash", + "ahash 0.8.12", "bytemuck", "ecolor", "emath", @@ -1246,6 +1270,15 @@ dependencies = [ "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]] name = "find-msvc-tools" version = "0.1.9" @@ -1306,7 +1339,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", - "memmap2", + "memmap2 0.9.10", "slotmap", "tinyvec", "ttf-parser", @@ -1348,6 +1381,16 @@ dependencies = [ "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]] name = "futures" version = "0.3.32" @@ -1696,6 +1739,15 @@ dependencies = [ "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]] name = "hashbrown" version = "0.15.5" @@ -2227,6 +2279,7 @@ dependencies = [ "plugin-cmd", "plugin-files", "tokio", + "tracing-subscriber", ] [[package]] @@ -2339,6 +2392,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2410,6 +2469,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "linux-raw-sys" version = "0.4.15" @@ -2473,6 +2555,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -2489,6 +2580,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -2537,6 +2637,12 @@ dependencies = [ "paste", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2692,12 +2798,34 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "nom" version = "8.0.0" @@ -2713,6 +2841,25 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "num-bigint" version = "0.4.6" @@ -3185,6 +3332,16 @@ dependencies = [ "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]] name = "ordered-stream" version = "0.2.0" @@ -3318,9 +3475,12 @@ version = "0.1.0" dependencies = [ "async-trait", "k-launcher-kernel", + "linicon", + "nucleo-matcher", "serde", "serde_json", "tokio", + "tracing", "xdg", ] @@ -3540,14 +3700,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "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]] @@ -3557,7 +3738,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "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]] @@ -3608,8 +3798,8 @@ dependencies = [ "num-traits", "paste", "profiling", - "rand", - "rand_chacha", + "rand 0.9.2", + "rand_chacha 0.9.0", "simd_helpers", "thiserror 2.0.18", "v_frame", @@ -3706,6 +3896,23 @@ dependencies = [ "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]] name = "renderdoc-sys" version = "1.1.0" @@ -3744,6 +3951,16 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rustc-hash" version = "1.1.0" @@ -3844,7 +4061,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "ab_glyph", "log", - "memmap2", + "memmap2 0.9.10", "smithay-client-toolkit 0.19.2", "tiny-skia", ] @@ -3924,6 +4141,15 @@ dependencies = [ "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]] name = "shlex" version = "1.3.0" @@ -4029,7 +4255,7 @@ dependencies = [ "cursor-icon", "libc", "log", - "memmap2", + "memmap2 0.9.10", "rustix 0.38.44", "thiserror 1.0.69", "wayland-backend", @@ -4054,7 +4280,7 @@ dependencies = [ "cursor-icon", "libc", "log", - "memmap2", + "memmap2 0.9.10", "rustix 1.1.4", "thiserror 2.0.18", "wayland-backend", @@ -4099,7 +4325,7 @@ dependencies = [ "bytemuck", "fastrand", "js-sys", - "memmap2", + "memmap2 0.9.10", "ndk", "objc2 0.6.4", "objc2-core-foundation", @@ -4292,6 +4518,15 @@ dependencies = [ "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]] name = "tiff" version = "0.10.3" @@ -4477,6 +4712,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "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]] @@ -4647,6 +4912,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -5607,7 +5878,7 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "ahash", + "ahash 0.8.12", "android-activity", "atomic-waker", "bitflags 2.11.0", @@ -5622,7 +5893,7 @@ dependencies = [ "dpi", "js-sys", "libc", - "memmap2", + "memmap2 0.9.10", "ndk", "objc2 0.5.2", "objc2-app-kit 0.2.2", diff --git a/README.md b/README.md index 3fe2144..e28e8c4 100644 --- a/README.md +++ b/README.md @@ -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. -``` -[screenshot placeholder] -``` +![k-launcher](docs/screenshot.png) ## Quick Start @@ -24,6 +22,23 @@ cargo build --release | `Enter` | Launch selected | | `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 | Trigger | Plugin | Example | diff --git a/crates/k-launcher-config/src/lib.rs b/crates/k-launcher-config/src/lib.rs index 7432300..21b184f 100644 --- a/crates/k-launcher-config/src/lib.rs +++ b/crates/k-launcher-config/src/lib.rs @@ -107,8 +107,7 @@ impl Default for PluginsCfg { } pub fn load() -> Config { - let path = dirs::config_dir() - .map(|d| d.join("k-launcher").join("config.toml")); + let path = dirs::config_dir().map(|d| d.join("k-launcher").join("config.toml")); let Some(path) = path else { return Config::default(); }; diff --git a/crates/k-launcher-kernel/src/lib.rs b/crates/k-launcher-kernel/src/lib.rs index 76b6cde..3558460 100644 --- a/crates/k-launcher-kernel/src/lib.rs +++ b/crates/k-launcher-kernel/src/lib.rs @@ -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); impl Score { @@ -104,7 +106,10 @@ pub struct Kernel { impl Kernel { pub fn new(plugins: Vec>, max_results: usize) -> Self { - Self { plugins, max_results } + Self { + plugins, + max_results, + } } pub async fn search(&self, query: &str) -> Vec { diff --git a/crates/k-launcher-os-bridge/src/unix_launcher.rs b/crates/k-launcher-os-bridge/src/unix_launcher.rs index 9c67ae7..9c8173e 100644 --- a/crates/k-launcher-os-bridge/src/unix_launcher.rs +++ b/crates/k-launcher-os-bridge/src/unix_launcher.rs @@ -1,5 +1,5 @@ -use std::process::{Command, Stdio}; use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; use k_launcher_kernel::{AppLauncher, LaunchAction}; @@ -77,21 +77,31 @@ impl AppLauncher for UnixAppLauncher { .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .pre_exec(|| { libc::setsid(); Ok(()) }) + .pre_exec(|| { + libc::setsid(); + Ok(()) + }) .spawn() }; } } 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 { Command::new(&term_bin) .args(&term_args) - .arg("sh").arg("-c").arg(cmd) + .arg("sh") + .arg("-c") + .arg(cmd) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .pre_exec(|| { libc::setsid(); Ok(()) }) + .pre_exec(|| { + libc::setsid(); + Ok(()) + }) .spawn() }; } diff --git a/crates/k-launcher-plugin-host/src/lib.rs b/crates/k-launcher-plugin-host/src/lib.rs index 7849326..e601d75 100644 --- a/crates/k-launcher-plugin-host/src/lib.rs +++ b/crates/k-launcher-plugin-host/src/lib.rs @@ -43,7 +43,9 @@ async fn do_search( io: &mut ProcessIo, query: &str, ) -> Result, Box> { - 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(b"\n").await?; io.stdin.flush().await?; @@ -143,7 +145,9 @@ mod tests { #[test] 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"}"#); } @@ -155,21 +159,27 @@ mod tests { assert_eq!(results[0].id, "1"); assert_eq!(results[0].title, "Firefox"); 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] fn result_parses_copy_action() { let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#; let results: Vec = 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] fn result_parses_open_path_action() { let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#; let results: Vec = 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] @@ -182,7 +192,8 @@ mod tests { #[test] 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 = serde_json::from_str(json).unwrap(); assert!(results[0].description.is_none()); assert!(results[0].icon.is_none()); diff --git a/crates/k-launcher-ui-egui/src/app.rs b/crates/k-launcher-ui-egui/src/app.rs index 391669f..4f46a3e 100644 --- a/crates/k-launcher-ui-egui/src/app.rs +++ b/crates/k-launcher-ui-egui/src/app.rs @@ -127,11 +127,20 @@ impl eframe::App for KLauncherApp { ui.set_width(ui.available_width()); for (i, result) in self.results.iter().enumerate() { 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() .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)); row_frame.show(ui, |ui| { diff --git a/crates/k-launcher-ui/src/app.rs b/crates/k-launcher-ui/src/app.rs index 8294ebd..cfaedc8 100644 --- a/crates/k-launcher-ui/src/app.rs +++ b/crates/k-launcher-ui/src/app.rs @@ -1,10 +1,9 @@ use std::sync::Arc; use iced::{ - Border, Color, Element, Length, Size, Subscription, Task, - event, + Border, Color, Element, Length, Size, Subscription, Task, event, 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, }; @@ -26,6 +25,8 @@ pub struct KLauncherApp { results: Arc>, selected: usize, cfg: AppearanceCfg, + error: Option, + search_epoch: u64, } impl KLauncherApp { @@ -41,6 +42,8 @@ impl KLauncherApp { results: Arc::new(vec![]), selected: 0, cfg, + error: None, + search_epoch: 0, } } } @@ -48,23 +51,31 @@ impl KLauncherApp { #[derive(Debug, Clone)] pub enum Message { QueryChanged(String), - ResultsReady(Arc>), + ResultsReady(u64, Arc>), KeyPressed(KeyEvent), } fn update(state: &mut KLauncherApp, message: Message) -> Task { match message { Message::QueryChanged(q) => { + state.error = None; state.query = q.clone(); state.selected = 0; + state.search_epoch += 1; + let epoch = state.search_epoch; let engine = state.engine.clone(); Task::perform( - async move { engine.search(&q).await }, - |results| Message::ResultsReady(Arc::new(results)), + async move { + 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) => { - state.results = results; + Message::ResultsReady(epoch, results) => { + if epoch == state.search_epoch { + state.results = results; + } Task::none() } Message::KeyPressed(event) => { @@ -114,8 +125,13 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> { .padding(12) .size(cfg.search_font_size) .style(|theme, _status| { - let mut s = 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() }; + let mut s = + 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 }); @@ -135,26 +151,27 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> { Color::from_rgba8(255, 255, 255, 0.07) }; let icon_el: Element<'_, Message> = match &result.icon { - Some(p) if p.ends_with(".svg") => - 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) if p.ends_with(".svg") => { + svg(svg::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(), }; let title_col: Element<'_, Message> = if let Some(desc) = &result.description { column![ 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() } else { text(result.title.as_str()).size(title_size).into() }; - container( - row![icon_el, title_col] - .spacing(8) - .align_y(iced::Center), - ) + container(row![icon_el, title_col].spacing(8).align_y(iced::Center)) .width(Length::Fill) .padding([6, 12]) .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) }; - let content = column![search_bar, results_list] + let maybe_error: Option> = 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> = + vec![search_bar.into(), results_list.into()]; + if let Some(err) = maybe_error { + content_children.push(err); + } + let content = column(content_children) .spacing(8) .padding(12) .width(Length::Fill) @@ -234,15 +267,15 @@ pub fn run( update, view, ) - .title("K-Launcher") - .subscription(subscription) - .window(window::Settings { - size: Size::new(wc.width, wc.height), - position: window::Position::Centered, - decorations: wc.decorations, - transparent: wc.transparent, - resizable: wc.resizable, - ..Default::default() - }) - .run() + .title("K-Launcher") + .subscription(subscription) + .window(window::Settings { + size: Size::new(wc.width, wc.height), + position: window::Position::Centered, + decorations: wc.decorations, + transparent: wc.transparent, + resizable: wc.resizable, + ..Default::default() + }) + .run() } diff --git a/crates/k-launcher/Cargo.toml b/crates/k-launcher/Cargo.toml index a90976f..bdcc2e2 100644 --- a/crates/k-launcher/Cargo.toml +++ b/crates/k-launcher/Cargo.toml @@ -35,3 +35,4 @@ plugin-calc = { path = "../plugins/plugin-calc" } plugin-cmd = { path = "../plugins/plugin-cmd" } plugin-files = { path = "../plugins/plugin-files" } tokio = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/k-launcher/src/client.rs b/crates/k-launcher/src/client.rs new file mode 100644 index 0000000..658b3d2 --- /dev/null +++ b/crates/k-launcher/src/client.rs @@ -0,0 +1,10 @@ +use std::io::Write; + +pub fn send_show() -> Result<(), Box> { + 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(()) +} diff --git a/crates/k-launcher/src/main.rs b/crates/k-launcher/src/main.rs index ccd38ad..76d0189 100644 --- a/crates/k-launcher/src/main.rs +++ b/crates/k-launcher/src/main.rs @@ -1,29 +1,62 @@ +mod client; + use std::sync::Arc; use k_launcher_kernel::Kernel; use k_launcher_os_bridge::UnixAppLauncher; use k_launcher_plugin_host::ExternalPlugin; -use plugin_apps::{AppsPlugin, frecency::FrecencyStore}; #[cfg(target_os = "linux")] use plugin_apps::linux::FsDesktopEntrySource; +use plugin_apps::{AppsPlugin, frecency::FrecencyStore}; use plugin_calc::CalcPlugin; use plugin_cmd::CmdPlugin; use plugin_files::FilesPlugin; -fn main() -> iced::Result { +fn main() { + tracing_subscriber::fmt::init(); + + let args: Vec = 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 launcher = Arc::new(UnixAppLauncher::new()); let frecency = FrecencyStore::load(); let mut plugins: Vec> = vec![]; - if cfg.plugins.cmd { plugins.push(Arc::new(CmdPlugin::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 { - plugins.push(Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency))); + if cfg.plugins.cmd { + plugins.push(Arc::new(CmdPlugin::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 { + plugins.push(Arc::new(AppsPlugin::new( + FsDesktopEntrySource::new(), + frecency, + ))); } 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 = diff --git a/crates/k-launcher/src/main_egui.rs b/crates/k-launcher/src/main_egui.rs index 05219ad..c72bc21 100644 --- a/crates/k-launcher/src/main_egui.rs +++ b/crates/k-launcher/src/main_egui.rs @@ -2,9 +2,9 @@ use std::sync::Arc; use k_launcher_kernel::Kernel; use k_launcher_os_bridge::UnixAppLauncher; -use plugin_apps::{AppsPlugin, frecency::FrecencyStore}; #[cfg(target_os = "linux")] use plugin_apps::linux::FsDesktopEntrySource; +use plugin_apps::{AppsPlugin, frecency::FrecencyStore}; use plugin_calc::CalcPlugin; use plugin_cmd::CmdPlugin; use plugin_files::FilesPlugin; @@ -12,12 +12,15 @@ use plugin_files::FilesPlugin; fn main() -> Result<(), Box> { let launcher = Arc::new(UnixAppLauncher::new()); let frecency = FrecencyStore::load(); - let kernel: Arc = Arc::new(Kernel::new(vec![ - Arc::new(CmdPlugin::new()), - Arc::new(CalcPlugin::new()), - Arc::new(FilesPlugin::new()), - Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)), - ], 8)); + let kernel: Arc = Arc::new(Kernel::new( + vec![ + Arc::new(CmdPlugin::new()), + Arc::new(CalcPlugin::new()), + Arc::new(FilesPlugin::new()), + Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)), + ], + 8, + )); k_launcher_ui_egui::run(kernel, launcher)?; Ok(()) } diff --git a/crates/plugins/plugin-apps/Cargo.toml b/crates/plugins/plugin-apps/Cargo.toml index 1631674..d7a2531 100644 --- a/crates/plugins/plugin-apps/Cargo.toml +++ b/crates/plugins/plugin-apps/Cargo.toml @@ -10,9 +10,12 @@ path = "src/lib.rs" [dependencies] async-trait = { workspace = true } k-launcher-kernel = { path = "../../k-launcher-kernel" } +nucleo-matcher = "0.3" serde = { workspace = true } serde_json = "1.0" tokio = { workspace = true } +tracing = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] +linicon = "2.3.0" xdg = "3" diff --git a/crates/plugins/plugin-apps/src/frecency.rs b/crates/plugins/plugin-apps/src/frecency.rs index 6f57e9a..fe997fb 100644 --- a/crates/plugins/plugin-apps/src/frecency.rs +++ b/crates/plugins/plugin-apps/src/frecency.rs @@ -24,7 +24,10 @@ impl FrecencyStore { .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); - Arc::new(Self { path, data: Mutex::new(data) }) + Arc::new(Self { + path, + data: Mutex::new(data), + }) } #[cfg(test)] @@ -36,11 +39,14 @@ impl FrecencyStore { } pub fn load() -> Arc { - let path = xdg::BaseDirectories::new() - .get_data_home() - .unwrap_or_else(|| PathBuf::from(".")) - .join("k-launcher") - .join("frecency.json"); + let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else { + tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)"); + return Arc::new(Self { + path: PathBuf::from("/dev/null"), + data: Mutex::new(HashMap::new()), + }); + }; + let path = data_home.join("k-launcher").join("frecency.json"); Self::new(path) } @@ -50,7 +56,10 @@ impl FrecencyStore { .unwrap_or_default() .as_secs(); 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.last_used = now; if let Some(parent) = self.path.parent() { @@ -69,7 +78,13 @@ impl FrecencyStore { .unwrap_or_default() .as_secs(); 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 } @@ -83,7 +98,13 @@ impl FrecencyStore { .iter() .map(|(id, entry)| { 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) }) .collect(); diff --git a/crates/plugins/plugin-apps/src/lib.rs b/crates/plugins/plugin-apps/src/lib.rs index 90e6984..e000a57 100644 --- a/crates/plugins/plugin-apps/src/lib.rs +++ b/crates/plugins/plugin-apps/src/lib.rs @@ -68,7 +68,6 @@ pub trait DesktopEntrySource: Send + Sync { struct CachedEntry { id: String, name: AppName, - name_lc: String, keywords_lc: Vec, category: Option, icon: Option, @@ -90,10 +89,12 @@ impl AppsPlugin { .into_iter() .map(|e| { 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(); #[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"))] let icon: Option = None; let exec = e.exec.as_str().to_string(); @@ -104,7 +105,6 @@ impl AppsPlugin { }); let cached = CachedEntry { id: id.clone(), - name_lc, keywords_lc, category: e.category, icon, @@ -120,15 +120,37 @@ impl AppsPlugin { } 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 { - if name_lc == query_lc { return Some(100); } - if name_lc.starts_with(query_lc) { return Some(80); } - if name_lc.contains(query_lc) { return Some(60); } - if initials(name_lc).starts_with(query_lc) { return Some(70); } - None +fn score_match(name: &str, query: &str) -> Option { + use nucleo_matcher::{ + Config, Matcher, Utf32Str, + pattern::{CaseMatching, Normalization, Pattern}, + }; + + let mut matcher = Matcher::new(Config::DEFAULT); + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + + let mut name_chars: Vec = 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 + } } pub(crate) fn humanize_category(s: &str) -> String { @@ -150,7 +172,9 @@ impl Plugin for AppsPlugin { async fn search(&self, query: &str) -> Vec { if query.is_empty() { - return self.frecency.top_ids(5) + return self + .frecency + .top_ids(5) .iter() .filter_map(|id| { let e = self.entries.get(id)?; @@ -172,8 +196,11 @@ impl Plugin for AppsPlugin { self.entries .values() .filter_map(|e| { - let score = score_match(&e.name_lc, &query_lc).or_else(|| { - e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50) + let score = score_match(e.name.as_str(), query).or_else(|| { + e.keywords_lc + .iter() + .any(|k| k.contains(&query_lc)) + .then_some(50) })?; Some(SearchResult { id: ResultId::new(&e.id), @@ -226,7 +253,14 @@ mod tests { Self { entries: entries .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(), } } @@ -249,35 +283,49 @@ mod tests { #[tokio::test] 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; assert_eq!(results[0].title.as_str(), "Firefox"); } #[tokio::test] 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()); } #[tokio::test] 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()); } #[test] fn score_match_abbreviation() { 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] fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() { - assert_eq!(score_match("firefox", "firefox"), Some(100)); - assert_eq!(score_match("firefox", "fire"), Some(80)); - assert_eq!(score_match("gnu firefox", "gf"), Some(70)); - assert_eq!(score_match("ice firefox", "fire"), Some(60)); + let exact = score_match("firefox", "firefox"); + let prefix = score_match("firefox", "fire"); + let abbrev = score_match("gnu firefox", "gf"); + 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] @@ -289,7 +337,7 @@ mod tests { let results = p.search("vsc").await; assert_eq!(results.len(), 1); 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] @@ -303,6 +351,20 @@ mod tests { 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] fn humanize_category_splits_camel_case() { assert_eq!(humanize_category("TextEditor"), "Text Editor"); diff --git a/crates/plugins/plugin-apps/src/linux.rs b/crates/plugins/plugin-apps/src/linux.rs index dd69c1a..6aa8c07 100644 --- a/crates/plugins/plugin-apps/src/linux.rs +++ b/crates/plugins/plugin-apps/src/linux.rs @@ -1,7 +1,7 @@ use std::path::Path; -use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath}; use crate::humanize_category; +use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath}; 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 = 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::>() + .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 { if name.starts_with('/') && Path::new(name).exists() { 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 = [ format!("/usr/share/pixmaps/{name}.png"), 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()) } @@ -90,13 +154,15 @@ fn parse_desktop_file(path: &Path) -> Option { "Type" if !is_application => is_application = value.trim() == "Application", "NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"), "Categories" if category.is_none() => { - category = value.trim() + category = value + .trim() .split(';') .find(|s| !s.is_empty()) .map(|s| humanize_category(s.trim())); } "Keywords" if keywords.is_empty() => { - keywords = value.trim() + keywords = value + .trim() .split(';') .filter(|s| !s.is_empty()) .map(|s| s.trim().to_string()) @@ -111,16 +177,7 @@ fn parse_desktop_file(path: &Path) -> Option { return None; } - let exec_clean: String = 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 - }); + let exec_clean: String = clean_exec(&exec?); Some(DesktopEntry { name: AppName::new(name?), @@ -130,3 +187,31 @@ fn parse_desktop_file(path: &Path) -> Option { 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"); + } +} diff --git a/crates/plugins/plugin-calc/src/lib.rs b/crates/plugins/plugin-calc/src/lib.rs index 3fd010d..a7bfcf6 100644 --- a/crates/plugins/plugin-calc/src/lib.rs +++ b/crates/plugins/plugin-calc/src/lib.rs @@ -22,8 +22,8 @@ fn strip_numeric_separators(expr: &str) -> String { } const MATH_FNS: &[&str] = &[ - "sqrt", "sin", "cos", "tan", "asin", "acos", "atan", - "ln", "log2", "log10", "exp", "abs", "ceil", "floor", "round", + "sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs", + "ceil", "floor", "round", ]; 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)) } -static MATH_CTX: LazyLock> = - LazyLock::new(|| { +static MATH_CTX: LazyLock> = LazyLock::new( + || { use evalexpr::*; context_map! { "pi" => float std::f64::consts::PI, @@ -59,7 +59,8 @@ static MATH_CTX: LazyLock Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.round()))) } .expect("static math context must be valid") - }); + }, +); #[async_trait] impl Plugin for CalcPlugin { diff --git a/crates/plugins/plugin-files/src/lib.rs b/crates/plugins/plugin-files/src/lib.rs index 447d73b..08aa3dc 100644 --- a/crates/plugins/plugin-files/src/lib.rs +++ b/crates/plugins/plugin-files/src/lib.rs @@ -76,11 +76,7 @@ impl Plugin for FilesPlugin { let full_path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); let is_dir = full_path.is_dir(); - let title = if is_dir { - format!("{name}/") - } else { - name - }; + let title = if is_dir { format!("{name}/") } else { name }; let path_str = full_path.to_string_lossy().to_string(); SearchResult { id: ResultId::new(format!("file-{i}")), diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..e69de29 diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO new file mode 100644 index 0000000..99c48db --- /dev/null +++ b/packaging/aur/.SRCINFO @@ -0,0 +1,15 @@ +pkgbase = k-launcher-bin + pkgdesc = GPU-accelerated command palette launcher for Linux (Wayland/X11) + pkgver = 0.1.0 + pkgrel = 1 + url = https://github.com/GKaszewski/k-launcher + arch = x86_64 + license = MIT + depends = wayland + depends = libxkbcommon + provides = k-launcher + conflicts = k-launcher + source = k-launcher-0.1.0::https://github.com/GKaszewski/k-launcher/releases/download/v0.1.0/k-launcher + sha256sums = SKIP + +pkgname = k-launcher-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 0000000..19c1358 --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,17 @@ +# Maintainer: k-launcher contributors +pkgname=k-launcher-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc="GPU-accelerated command palette launcher for Linux (Wayland/X11)" +arch=('x86_64') +url="https://github.com/GKaszewski/k-launcher" +license=('MIT') +depends=('wayland' 'libxkbcommon') +provides=('k-launcher') +conflicts=('k-launcher') +source=("k-launcher-${pkgver}::https://github.com/GKaszewski/k-launcher/releases/download/v${pkgver}/k-launcher") +sha256sums=('SKIP') + +package() { + install -Dm755 "k-launcher-${pkgver}" "${pkgdir}/usr/bin/k-launcher" +} diff --git a/packaging/systemd/k-launcher.service b/packaging/systemd/k-launcher.service new file mode 100644 index 0000000..2731b69 --- /dev/null +++ b/packaging/systemd/k-launcher.service @@ -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 diff --git a/packaging/systemd/k-launcher.socket b/packaging/systemd/k-launcher.socket new file mode 100644 index 0000000..adace73 --- /dev/null +++ b/packaging/systemd/k-launcher.socket @@ -0,0 +1,8 @@ +[Unit] +Description=k-launcher IPC socket + +[Socket] +ListenStream=%t/k-launcher.sock + +[Install] +WantedBy=sockets.target