commit 2d3efcdd56e4d1af721bf2485f0abe9178f94227 Author: vikingowl Date: Sun Dec 28 14:09:24 2025 +0100 feat: initial owlry application launcher Owl-themed Wayland application launcher with GTK4 and layer-shell. Features: - Provider-based architecture (apps, commands, systemd user services) - Filter tabs and prefix shortcuts (:app, :cmd, :uuctl) - Submenu actions for systemd services (start/stop/restart/status/journal) - Smart terminal detection with fallback chain - CLI options for mode selection (--mode, --providers) - Fuzzy search with configurable max results - Custom owl-inspired dark theme 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..68e0657 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1516 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21be0e1ce6cdb2ee7fff840f922fb04ead349e5cfb1e750b769132d44ce04720" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "freedesktop-desktop-entry" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528df05c8ed0bfd569c7018914ba1995be2a133ba9ead17628ddb0ff94b86331" +dependencies = [ + "bstr", + "gettext-rs", + "log", + "memchr", + "thiserror 2.0.17", + "unicase", + "xdg", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gettext-rs" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf" +dependencies = [ + "gettext-sys", + "locale_config", +] + +[[package]] +name = "gettext-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea859ab0dd7e70ff823032b3e077d03d39c965d68c6c10775add60e999d8ee9" +dependencies = [ + "cc", + "temp-dir", +] + +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e1e1b1516be3d7ca089dfa6a1e688e268c74aef50c0c25fe8c46b1ba8ed1cc" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3057dc117db2d664a9b45f1956568701914e80cf9f2c8cef0a755af4c1c8105" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "locale_config" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934" +dependencies = [ + "lazy_static", + "objc", + "objc-foundation", + "regex", + "winapi", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "owlry" +version = "0.1.0" +dependencies = [ + "clap", + "dirs", + "env_logger", + "freedesktop-desktop-entry", + "fuzzy-matcher", + "gtk4", + "gtk4-layer-shell", + "libc", + "log", + "serde", + "thiserror 2.0.17", + "tokio", + "toml 0.8.23", +] + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.9.10+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "temp-dir" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "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 = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "windows-sys 0.61.2", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dbc5b5b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "owlry" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +description = "A lightweight, owl-themed application launcher for Wayland" +authors = ["Your Name "] +license = "GPL-3.0-or-later" +repository = "https://github.com/yourusername/owlry" +keywords = ["launcher", "wayland", "gtk4", "linux"] +categories = ["gui"] + +[dependencies] +# GTK4 for the UI +gtk4 = { version = "0.9", features = ["v4_12"] } + +# Layer shell support for Wayland overlay behavior +gtk4-layer-shell = "0.4" + +# Async runtime for non-blocking operations +tokio = { version = "1", features = ["rt", "sync", "process", "fs"] } + +# Fuzzy matching for search +fuzzy-matcher = "0.3" + +# XDG desktop entry parsing +freedesktop-desktop-entry = "0.7" + +# Directory utilities +dirs = "5" + +# Low-level syscalls for stdin detection +libc = "0.2" + +# Logging +log = "0.4" +env_logger = "0.11" + +# Error handling +thiserror = "2" + +# Configuration +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +# CLI argument parsing +clap = { version = "4", features = ["derive"] } + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true +opt-level = "z" # Optimize for size + +[profile.dev] +opt-level = 0 +debug = true diff --git a/resources/style.css b/resources/style.css new file mode 100644 index 0000000..9a7a3aa --- /dev/null +++ b/resources/style.css @@ -0,0 +1,246 @@ +/* + * Owlry - Owl-themed Application Launcher + * + * Color Palette (Owl-inspired): + * - Deep night sky: #1a1b26 (background) + * - Twilight: #24283b (secondary bg) + * - Owl feathers: #414868 (borders/muted) + * - Moon glow: #c0caf5 (primary text) + * - Owl eyes (amber): #e0af68 (accent/highlight) + * - Forest shadows: #565f89 (secondary text) + * - Barn owl cream: #f5e0dc (bright accent) + */ + +/* Main window */ +.owlry-window { + background-color: transparent; +} + +.owlry-main { + background-color: rgba(26, 27, 38, 0.95); + border-radius: 16px; + border: 1px solid rgba(65, 72, 104, 0.6); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(224, 175, 104, 0.1); +} + +/* Search entry */ +.owlry-search { + background-color: rgba(36, 40, 59, 0.8); + border: 2px solid rgba(65, 72, 104, 0.5); + border-radius: 12px; + padding: 12px 16px; + font-size: 16px; + color: #c0caf5; + caret-color: #e0af68; + min-height: 24px; +} + +.owlry-search:focus { + border-color: #e0af68; + box-shadow: 0 0 0 2px rgba(224, 175, 104, 0.2); + outline: none; +} + +.owlry-search placeholder { + color: #565f89; +} + +/* Results list */ +.owlry-results { + background-color: transparent; + border-radius: 8px; +} + +/* Individual result row */ +.owlry-result-row { + background-color: transparent; + border-radius: 8px; + margin: 2px 0; + transition: background-color 150ms ease; +} + +.owlry-result-row:hover { + background-color: rgba(36, 40, 59, 0.6); +} + +.owlry-result-row:selected { + background-color: rgba(224, 175, 104, 0.15); + border-left: 3px solid #e0af68; +} + +.owlry-result-row:selected:hover { + background-color: rgba(224, 175, 104, 0.2); +} + +/* Result icon */ +.owlry-result-icon { + color: #c0caf5; + opacity: 0.9; +} + +.owlry-result-row:selected .owlry-result-icon { + color: #e0af68; + opacity: 1; +} + +/* Result name */ +.owlry-result-name { + font-size: 14px; + font-weight: 500; + color: #c0caf5; +} + +.owlry-result-row:selected .owlry-result-name { + color: #f5e0dc; +} + +/* Result description */ +.owlry-result-description { + font-size: 12px; + color: #565f89; + margin-top: 2px; +} + +.owlry-result-row:selected .owlry-result-description { + color: #7aa2f7; +} + +/* Provider badges */ +.owlry-result-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 3px 8px; + border-radius: 6px; + background-color: rgba(65, 72, 104, 0.4); + color: #565f89; +} + +.owlry-badge-app { + background-color: rgba(122, 162, 247, 0.2); + color: #7aa2f7; +} + +.owlry-badge-cmd { + background-color: rgba(187, 154, 247, 0.2); + color: #bb9af7; +} + +.owlry-badge-dmenu { + background-color: rgba(158, 206, 106, 0.2); + color: #9ece6a; +} + +.owlry-badge-uuctl { + background-color: rgba(224, 175, 104, 0.2); + color: #e0af68; +} + +/* Scrollbar styling */ +scrollbar { + background-color: transparent; +} + +scrollbar slider { + background-color: rgba(65, 72, 104, 0.5); + border-radius: 4px; + min-width: 6px; + min-height: 40px; +} + +scrollbar slider:hover { + background-color: rgba(86, 95, 137, 0.7); +} + +scrollbar slider:active { + background-color: #e0af68; +} + +/* Selection highlighting */ +selection { + background-color: rgba(224, 175, 104, 0.3); + color: #f5e0dc; +} + +/* Header bar with mode indicator and filter tabs */ +.owlry-header { + margin-bottom: 4px; +} + +/* Mode indicator */ +.owlry-mode-indicator { + font-size: 12px; + font-weight: 600; + color: #e0af68; + padding: 4px 12px; + background-color: rgba(224, 175, 104, 0.15); + border-radius: 6px; +} + +/* Filter tabs container */ +.owlry-filter-tabs { + /* Container spacing handled by GtkBox */ +} + +/* Filter toggle buttons */ +.owlry-filter-button { + font-size: 11px; + font-weight: 500; + padding: 4px 10px; + border-radius: 6px; + background-color: rgba(65, 72, 104, 0.3); + color: #565f89; + border: 1px solid transparent; + min-height: 20px; + transition: all 150ms ease; +} + +.owlry-filter-button:hover { + background-color: rgba(65, 72, 104, 0.5); + color: #c0caf5; +} + +.owlry-filter-button:checked { + background-color: rgba(224, 175, 104, 0.2); + color: #e0af68; + border-color: rgba(224, 175, 104, 0.4); +} + +/* Provider-specific filter button colors when active */ +.owlry-filter-app:checked { + background-color: rgba(122, 162, 247, 0.2); + color: #7aa2f7; + border-color: rgba(122, 162, 247, 0.4); +} + +.owlry-filter-cmd:checked { + background-color: rgba(187, 154, 247, 0.2); + color: #bb9af7; + border-color: rgba(187, 154, 247, 0.4); +} + +.owlry-filter-uuctl:checked { + background-color: rgba(224, 175, 104, 0.2); + color: #e0af68; + border-color: rgba(224, 175, 104, 0.4); +} + +.owlry-filter-dmenu:checked { + background-color: rgba(158, 206, 106, 0.2); + color: #9ece6a; + border-color: rgba(158, 206, 106, 0.4); +} + +/* Hints bar at bottom */ +.owlry-hints { + padding-top: 8px; + border-top: 1px solid rgba(65, 72, 104, 0.3); +} + +.owlry-hints-label { + font-size: 10px; + color: #565f89; + letter-spacing: 0.5px; +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..55e9b21 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,96 @@ +use crate::cli::CliArgs; +use crate::config::Config; +use crate::filter::ProviderFilter; +use crate::providers::ProviderManager; +use crate::ui::MainWindow; +use gtk4::prelude::*; +use gtk4::{gio, Application}; +use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use log::debug; +use std::cell::RefCell; +use std::rc::Rc; + +const APP_ID: &str = "org.owlry.launcher"; + +pub struct OwlryApp { + app: Application, +} + +impl OwlryApp { + pub fn new(args: CliArgs) -> Self { + let app = Application::builder() + .application_id(APP_ID) + .flags(gio::ApplicationFlags::FLAGS_NONE) + .build(); + + app.connect_activate(move |app| Self::on_activate(app, &args)); + + Self { app } + } + + pub fn run(&self) -> i32 { + self.app.run().into() + } + + fn on_activate(app: &Application, args: &CliArgs) { + debug!("Activating Owlry"); + + let config = Rc::new(RefCell::new(Config::load_or_default())); + let providers = Rc::new(RefCell::new(ProviderManager::new())); + + // Create filter from CLI args and config + let filter = ProviderFilter::new( + args.mode, + args.providers.clone(), + &config.borrow().providers, + ); + let filter = Rc::new(RefCell::new(filter)); + + let window = MainWindow::new(app, config.clone(), providers.clone(), filter.clone()); + + // Set up layer shell for Wayland overlay behavior + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive); + + // Anchor to all edges for centered overlay effect + // We'll use margins to control the actual size + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Bottom, false); + window.set_anchor(Edge::Left, false); + window.set_anchor(Edge::Right, false); + + // Position from top + window.set_margin(Edge::Top, 200); + + // Load CSS styling + Self::load_css(); + + window.present(); + } + + fn load_css() { + let provider = gtk4::CssProvider::new(); + + // Try to load from config directory first, then fall back to embedded + let config_css = dirs::config_dir().map(|p| p.join("owlry").join("style.css")); + + if let Some(css_path) = config_css { + if css_path.exists() { + provider.load_from_path(&css_path); + debug!("Loaded CSS from {:?}", css_path); + } else { + provider.load_from_string(include_str!("../resources/style.css")); + debug!("Loaded embedded CSS"); + } + } else { + provider.load_from_string(include_str!("../resources/style.css")); + } + + gtk4::style_context_add_provider_for_display( + >k4::gdk::Display::default().expect("Could not get default display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..839115e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,29 @@ +use clap::Parser; + +use crate::providers::ProviderType; + +#[derive(Parser, Debug, Clone)] +#[command( + name = "owlry", + about = "An owl-themed application launcher for Wayland", + version +)] +pub struct CliArgs { + /// Start in single-provider mode (app, cmd, uuctl) + #[arg(long, short = 'm', value_parser = parse_provider)] + pub mode: Option, + + /// Comma-separated list of enabled providers (app,cmd,uuctl) + #[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)] + pub providers: Option>, +} + +fn parse_provider(s: &str) -> Result { + s.parse() +} + +impl CliArgs { + pub fn parse_args() -> Self { + Self::parse() + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..8de891c --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,171 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::process::Command; +use log::{info, warn, debug}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub general: GeneralConfig, + pub appearance: AppearanceConfig, + pub providers: ProvidersConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneralConfig { + pub show_icons: bool, + pub max_results: usize, + pub terminal_command: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppearanceConfig { + pub width: i32, + pub height: i32, + pub font_size: u32, + pub border_radius: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProvidersConfig { + pub applications: bool, + pub commands: bool, + pub uuctl: bool, +} + +/// Detect the best available terminal emulator +/// Fallback chain: +/// 1. $TERMINAL env var (user's explicit preference) +/// 2. xdg-terminal-exec (freedesktop standard) +/// 3. Common Wayland-native terminals (kitty, alacritty, wezterm, foot) +/// 4. Common X11/legacy terminals (gnome-terminal, konsole, xfce4-terminal) +/// 5. x-terminal-emulator (Debian alternatives) +/// 6. xterm (ultimate fallback) +fn detect_terminal() -> String { + // 1. Check $TERMINAL env var first + if let Ok(term) = std::env::var("TERMINAL") { + if !term.is_empty() && command_exists(&term) { + debug!("Using $TERMINAL: {}", term); + return term; + } + } + + // 2. Try xdg-terminal-exec (freedesktop standard) + if command_exists("xdg-terminal-exec") { + debug!("Using xdg-terminal-exec"); + return "xdg-terminal-exec".to_string(); + } + + // 3. Common Wayland-native terminals (preferred) + let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"]; + for term in wayland_terminals { + if command_exists(term) { + debug!("Found Wayland terminal: {}", term); + return term.to_string(); + } + } + + // 4. Common X11/legacy terminals + let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "tilix", "terminator"]; + for term in legacy_terminals { + if command_exists(term) { + debug!("Found legacy terminal: {}", term); + return term.to_string(); + } + } + + // 5. Try x-terminal-emulator (Debian alternatives system) + if command_exists("x-terminal-emulator") { + debug!("Using x-terminal-emulator"); + return "x-terminal-emulator".to_string(); + } + + // 6. Ultimate fallback + debug!("Falling back to xterm"); + "xterm".to_string() +} + +/// Check if a command exists in PATH +fn command_exists(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +impl Default for Config { + fn default() -> Self { + let terminal = detect_terminal(); + info!("Detected terminal: {}", terminal); + + Self { + general: GeneralConfig { + show_icons: true, + max_results: 10, + terminal_command: terminal, + }, + appearance: AppearanceConfig { + width: 600, + height: 400, + font_size: 14, + border_radius: 12, + }, + providers: ProvidersConfig { + applications: true, + commands: true, + uuctl: true, + }, + } + } +} + +impl Config { + pub fn config_path() -> Option { + dirs::config_dir().map(|p| p.join("owlry").join("config.toml")) + } + + pub fn load_or_default() -> Self { + Self::load().unwrap_or_else(|e| { + warn!("Failed to load config: {}, using defaults", e); + Self::default() + }) + } + + pub fn load() -> Result> { + let path = Self::config_path().ok_or("Could not determine config path")?; + + if !path.exists() { + info!("Config file not found, using defaults"); + return Ok(Self::default()); + } + + let content = std::fs::read_to_string(&path)?; + let mut config: Config = toml::from_str(&content)?; + info!("Loaded config from {:?}", path); + + // Validate terminal - if configured terminal doesn't exist, auto-detect + if !command_exists(&config.general.terminal_command) { + warn!( + "Configured terminal '{}' not found, auto-detecting", + config.general.terminal_command + ); + config.general.terminal_command = detect_terminal(); + info!("Using detected terminal: {}", config.general.terminal_command); + } + + Ok(config) + } + + pub fn save(&self) -> Result<(), Box> { + let path = Self::config_path().ok_or("Could not determine config path")?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self)?; + std::fs::write(&path, content)?; + info!("Saved config to {:?}", path); + Ok(()) + } +} diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..66733fc --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,237 @@ +use std::collections::HashSet; + +use crate::config::ProvidersConfig; +use crate::providers::ProviderType; + +/// Tracks which providers are enabled and handles prefix-based filtering +#[derive(Debug, Clone)] +pub struct ProviderFilter { + enabled: HashSet, + active_prefix: Option, +} + +/// Result of parsing a query for prefix syntax +#[derive(Debug, Clone)] +pub struct ParsedQuery { + pub prefix: Option, + pub query: String, +} + +impl ProviderFilter { + /// Create filter from CLI args and config + pub fn new( + cli_mode: Option, + cli_providers: Option>, + config_providers: &ProvidersConfig, + ) -> Self { + let enabled = if let Some(mode) = cli_mode { + // --mode overrides everything: single provider + HashSet::from([mode]) + } else if let Some(providers) = cli_providers { + // --providers overrides config + providers.into_iter().collect() + } else { + // Use config file settings, default to apps only + let mut set = HashSet::new(); + if config_providers.applications { + set.insert(ProviderType::Application); + } + if config_providers.commands { + set.insert(ProviderType::Command); + } + if config_providers.uuctl { + set.insert(ProviderType::Uuctl); + } + // Default to apps if nothing enabled + if set.is_empty() { + set.insert(ProviderType::Application); + } + set + }; + + Self { + enabled, + active_prefix: None, + } + } + + /// Default filter: apps only + pub fn apps_only() -> Self { + Self { + enabled: HashSet::from([ProviderType::Application]), + active_prefix: None, + } + } + + /// Toggle a provider on/off + pub fn toggle(&mut self, provider: ProviderType) { + if self.enabled.contains(&provider) { + self.enabled.remove(&provider); + // Ensure at least one provider is always enabled + if self.enabled.is_empty() { + self.enabled.insert(ProviderType::Application); + } + } else { + self.enabled.insert(provider); + } + } + + /// Enable a specific provider + pub fn enable(&mut self, provider: ProviderType) { + self.enabled.insert(provider); + } + + /// Disable a specific provider (ensures at least one remains) + pub fn disable(&mut self, provider: ProviderType) { + self.enabled.remove(&provider); + if self.enabled.is_empty() { + self.enabled.insert(ProviderType::Application); + } + } + + /// Set to single provider mode + pub fn set_single_mode(&mut self, provider: ProviderType) { + self.enabled.clear(); + self.enabled.insert(provider); + } + + /// Set prefix mode (from :app, :cmd, etc.) + pub fn set_prefix(&mut self, prefix: Option) { + self.active_prefix = prefix; + } + + /// Check if a provider should be searched + pub fn is_active(&self, provider: ProviderType) -> bool { + if let Some(prefix) = self.active_prefix { + provider == prefix + } else { + self.enabled.contains(&provider) + } + } + + /// Check if provider is in enabled set (ignoring prefix) + pub fn is_enabled(&self, provider: ProviderType) -> bool { + self.enabled.contains(&provider) + } + + /// Get current active prefix if any + pub fn active_prefix(&self) -> Option { + self.active_prefix + } + + /// Parse query for prefix syntax + pub fn parse_query(query: &str) -> ParsedQuery { + let trimmed = query.trim_start(); + + // Check for prefix patterns (with trailing space) + let prefixes = [ + (":app ", ProviderType::Application), + (":apps ", ProviderType::Application), + (":cmd ", ProviderType::Command), + (":command ", ProviderType::Command), + (":uuctl ", ProviderType::Uuctl), + ]; + + for (prefix_str, provider) in prefixes { + if let Some(rest) = trimmed.strip_prefix(prefix_str) { + return ParsedQuery { + prefix: Some(provider), + query: rest.to_string(), + }; + } + } + + // Handle prefix without trailing space (still typing) + let partial_prefixes = [ + (":app", ProviderType::Application), + (":apps", ProviderType::Application), + (":cmd", ProviderType::Command), + (":command", ProviderType::Command), + (":uuctl", ProviderType::Uuctl), + ]; + + for (prefix_str, provider) in partial_prefixes { + if trimmed == prefix_str { + return ParsedQuery { + prefix: Some(provider), + query: String::new(), + }; + } + } + + ParsedQuery { + prefix: None, + query: query.to_string(), + } + } + + /// Get enabled providers for UI display (sorted) + pub fn enabled_providers(&self) -> Vec { + let mut providers: Vec<_> = self.enabled.iter().copied().collect(); + providers.sort_by_key(|p| match p { + ProviderType::Application => 0, + ProviderType::Command => 1, + ProviderType::Uuctl => 2, + ProviderType::Dmenu => 3, + }); + providers + } + + /// Get display name for current mode + pub fn mode_display_name(&self) -> &'static str { + if let Some(prefix) = self.active_prefix { + return match prefix { + ProviderType::Application => "Apps", + ProviderType::Command => "Commands", + ProviderType::Uuctl => "uuctl", + ProviderType::Dmenu => "dmenu", + }; + } + + let enabled: Vec<_> = self.enabled_providers(); + if enabled.len() == 1 { + match enabled[0] { + ProviderType::Application => "Apps", + ProviderType::Command => "Commands", + ProviderType::Uuctl => "uuctl", + ProviderType::Dmenu => "dmenu", + } + } else { + "All" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_query_with_prefix() { + let result = ProviderFilter::parse_query(":app firefox"); + assert_eq!(result.prefix, Some(ProviderType::Application)); + assert_eq!(result.query, "firefox"); + } + + #[test] + fn test_parse_query_without_prefix() { + let result = ProviderFilter::parse_query("firefox"); + assert_eq!(result.prefix, None); + assert_eq!(result.query, "firefox"); + } + + #[test] + fn test_parse_query_partial_prefix() { + let result = ProviderFilter::parse_query(":cmd"); + assert_eq!(result.prefix, Some(ProviderType::Command)); + assert_eq!(result.query, ""); + } + + #[test] + fn test_toggle_ensures_one_enabled() { + let mut filter = ProviderFilter::apps_only(); + filter.toggle(ProviderType::Application); + // Should still have apps enabled as fallback + assert!(filter.is_enabled(ProviderType::Application)); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b273cbe --- /dev/null +++ b/src/main.rs @@ -0,0 +1,21 @@ +mod app; +mod cli; +mod config; +mod filter; +mod providers; +mod ui; + +use app::OwlryApp; +use cli::CliArgs; +use log::info; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let args = CliArgs::parse_args(); + + info!("Starting Owlry launcher"); + + let app = OwlryApp::new(args); + std::process::exit(app.run()); +} diff --git a/src/providers/application.rs b/src/providers/application.rs new file mode 100644 index 0000000..76fc4f7 --- /dev/null +++ b/src/providers/application.rs @@ -0,0 +1,120 @@ +use super::{LaunchItem, Provider, ProviderType}; +use freedesktop_desktop_entry::{DesktopEntry, Iter}; +use log::{debug, warn}; +use std::path::PathBuf; + +pub struct ApplicationProvider { + items: Vec, +} + +impl ApplicationProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn get_application_dirs() -> Vec { + let mut dirs = Vec::new(); + + // User applications + if let Some(data_home) = dirs::data_dir() { + dirs.push(data_home.join("applications")); + } + + // System applications + dirs.push(PathBuf::from("/usr/share/applications")); + dirs.push(PathBuf::from("/usr/local/share/applications")); + + // Flatpak applications + if let Some(data_home) = dirs::data_dir() { + dirs.push(data_home.join("flatpak/exports/share/applications")); + } + dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications")); + + dirs + } +} + +impl Provider for ApplicationProvider { + fn name(&self) -> &str { + "Applications" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Application + } + + fn refresh(&mut self) { + self.items.clear(); + + let dirs = Self::get_application_dirs(); + debug!("Scanning application directories: {:?}", dirs); + + // Empty locale list for default locale + let locales: &[&str] = &[]; + + for path in Iter::new(dirs.into_iter()) { + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + warn!("Failed to read {:?}: {}", path, e); + continue; + } + }; + + let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse {:?}: {}", path, e); + continue; + } + }; + + // Skip entries marked as hidden or no-display + if desktop_entry.no_display() || desktop_entry.hidden() { + continue; + } + + // Only include Application type entries + if desktop_entry.type_() != Some("Application") { + continue; + } + + let name = match desktop_entry.name(locales) { + Some(n) => n.to_string(), + None => continue, + }; + + let run_cmd = match desktop_entry.exec() { + Some(e) => { + // Clean up run command (remove %u, %U, %f, %F, etc.) + e.split_whitespace() + .filter(|s| !s.starts_with('%')) + .collect::>() + .join(" ") + } + None => continue, + }; + + let item = LaunchItem { + id: path.to_string_lossy().to_string(), + name, + description: desktop_entry.comment(locales).map(|s| s.to_string()), + icon: desktop_entry.icon().map(|s| s.to_string()), + provider: ProviderType::Application, + command: run_cmd, + terminal: desktop_entry.terminal(), + }; + + self.items.push(item); + } + + debug!("Found {} applications", self.items.len()); + + // Sort alphabetically by name + self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} diff --git a/src/providers/command.rs b/src/providers/command.rs new file mode 100644 index 0000000..abad066 --- /dev/null +++ b/src/providers/command.rs @@ -0,0 +1,105 @@ +use super::{LaunchItem, Provider, ProviderType}; +use log::debug; +use std::collections::HashSet; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +pub struct CommandProvider { + items: Vec, +} + +impl CommandProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn get_path_dirs() -> Vec { + std::env::var("PATH") + .unwrap_or_default() + .split(':') + .map(PathBuf::from) + .filter(|p| p.exists()) + .collect() + } + + fn is_executable(path: &std::path::Path) -> bool { + if let Ok(metadata) = path.metadata() { + let permissions = metadata.permissions(); + permissions.mode() & 0o111 != 0 + } else { + false + } + } +} + +impl Provider for CommandProvider { + fn name(&self) -> &str { + "Commands" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Command + } + + fn refresh(&mut self) { + self.items.clear(); + + let dirs = Self::get_path_dirs(); + let mut seen_names: HashSet = HashSet::new(); + + debug!("Scanning PATH directories for commands"); + + for dir in dirs { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + + // Skip directories and non-executable files + if path.is_dir() || !Self::is_executable(&path) { + continue; + } + + let name = match path.file_name() { + Some(n) => n.to_string_lossy().to_string(), + None => continue, + }; + + // Skip duplicates (first one in PATH wins) + if seen_names.contains(&name) { + continue; + } + seen_names.insert(name.clone()); + + // Skip hidden files + if name.starts_with('.') { + continue; + } + + let item = LaunchItem { + id: path.to_string_lossy().to_string(), + name: name.clone(), + description: Some(format!("Run {}", path.display())), + icon: Some("utilities-terminal".to_string()), + provider: ProviderType::Command, + command: name, + terminal: false, + }; + + self.items.push(item); + } + } + + debug!("Found {} commands in PATH", self.items.len()); + + // Sort alphabetically + self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} diff --git a/src/providers/dmenu.rs b/src/providers/dmenu.rs new file mode 100644 index 0000000..d31abde --- /dev/null +++ b/src/providers/dmenu.rs @@ -0,0 +1,94 @@ +use super::{LaunchItem, Provider, ProviderType}; +use log::debug; +use std::io::{self, BufRead}; + +/// Provider for dmenu-style input from stdin +pub struct DmenuProvider { + items: Vec, + enabled: bool, +} + +impl DmenuProvider { + pub fn new() -> Self { + Self { + items: Vec::new(), + enabled: false, + } + } + + /// Check if stdin has data (non-blocking check) + pub fn has_stdin_data() -> bool { + use std::os::unix::io::AsRawFd; + + let stdin_fd = io::stdin().as_raw_fd(); + let mut poll_fd = libc::pollfd { + fd: stdin_fd, + events: libc::POLLIN, + revents: 0, + }; + + // Non-blocking poll with 0 timeout + let result = unsafe { libc::poll(&mut poll_fd, 1, 0) }; + result > 0 && (poll_fd.revents & libc::POLLIN) != 0 + } + + /// Enable dmenu mode (called when stdin has data) + pub fn enable(&mut self) { + self.enabled = true; + } + + pub fn is_enabled(&self) -> bool { + self.enabled + } +} + +impl Provider for DmenuProvider { + fn name(&self) -> &str { + "dmenu" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Dmenu + } + + fn refresh(&mut self) { + self.items.clear(); + + if !self.enabled { + return; + } + + debug!("Reading dmenu items from stdin"); + + let stdin = io::stdin(); + for (idx, line) in stdin.lock().lines().enumerate() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + let line = line.trim(); + if line.is_empty() { + continue; + } + + let item = LaunchItem { + id: format!("dmenu:{}", idx), + name: line.to_string(), + description: None, + icon: None, + provider: ProviderType::Dmenu, + command: line.to_string(), + terminal: false, + }; + + self.items.push(item); + } + + debug!("Read {} items from stdin", self.items.len()); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..6c418ef --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,203 @@ +mod application; +mod command; +mod dmenu; +mod uuctl; + +pub use application::ApplicationProvider; +pub use command::CommandProvider; +pub use dmenu::DmenuProvider; +pub use uuctl::UuctlProvider; + +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; + +/// Represents a single searchable/launchable item +#[derive(Debug, Clone)] +pub struct LaunchItem { + pub id: String, + pub name: String, + pub description: Option, + pub icon: Option, + pub provider: ProviderType, + pub command: String, + pub terminal: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProviderType { + Application, + Command, + Dmenu, + Uuctl, +} + +impl std::str::FromStr for ProviderType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "app" | "apps" | "application" | "applications" => Ok(ProviderType::Application), + "cmd" | "command" | "commands" => Ok(ProviderType::Command), + "uuctl" => Ok(ProviderType::Uuctl), + "dmenu" => Ok(ProviderType::Dmenu), + _ => Err(format!("Unknown provider: '{}'. Valid: app, cmd, uuctl", s)), + } + } +} + +impl std::fmt::Display for ProviderType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderType::Application => write!(f, "app"), + ProviderType::Command => write!(f, "cmd"), + ProviderType::Dmenu => write!(f, "dmenu"), + ProviderType::Uuctl => write!(f, "uuctl"), + } + } +} + +/// Trait for all search providers +pub trait Provider: Send { + fn name(&self) -> &str; + fn provider_type(&self) -> ProviderType; + fn refresh(&mut self); + fn items(&self) -> &[LaunchItem]; +} + +/// Manages all providers and handles searching +pub struct ProviderManager { + providers: Vec>, + matcher: SkimMatcherV2, +} + +impl ProviderManager { + pub fn new() -> Self { + let mut manager = Self { + providers: Vec::new(), + matcher: SkimMatcherV2::default(), + }; + + // Check if running in dmenu mode (stdin has data) + let dmenu_mode = DmenuProvider::has_stdin_data(); + + if dmenu_mode { + // In dmenu mode, only use dmenu provider + let mut dmenu = DmenuProvider::new(); + dmenu.enable(); + manager.providers.push(Box::new(dmenu)); + } else { + // Normal mode: use all standard providers + manager.providers.push(Box::new(ApplicationProvider::new())); + manager.providers.push(Box::new(CommandProvider::new())); + manager.providers.push(Box::new(UuctlProvider::new())); + } + + // Initial refresh + manager.refresh_all(); + + manager + } + + pub fn is_dmenu_mode(&self) -> bool { + self.providers + .iter() + .any(|p| p.provider_type() == ProviderType::Dmenu) + } + + pub fn refresh_all(&mut self) { + for provider in &mut self.providers { + provider.refresh(); + } + } + + pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> { + if query.is_empty() { + // Return recent/popular items when query is empty + return self.providers + .iter() + .flat_map(|p| p.items().iter().cloned()) + .take(max_results) + .map(|item| (item, 0)) + .collect(); + } + + let mut results: Vec<(LaunchItem, i64)> = self.providers + .iter() + .flat_map(|provider| { + provider.items().iter().filter_map(|item| { + // Match against name and description + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item.description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + + let score = match (name_score, desc_score) { + (Some(n), Some(d)) => Some(n.max(d)), + (Some(n), None) => Some(n), + (None, Some(d)) => Some(d / 2), // Lower weight for description matches + (None, None) => None, + }; + + score.map(|s| (item.clone(), s)) + }) + }) + .collect(); + + // Sort by score (descending) + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + results + } + + /// Search with provider filtering + pub fn search_filtered( + &self, + query: &str, + max_results: usize, + filter: &crate::filter::ProviderFilter, + ) -> Vec<(LaunchItem, i64)> { + if query.is_empty() { + return self + .providers + .iter() + .filter(|p| filter.is_active(p.provider_type())) + .flat_map(|p| p.items().iter().cloned()) + .take(max_results) + .map(|item| (item, 0)) + .collect(); + } + + let mut results: Vec<(LaunchItem, i64)> = self + .providers + .iter() + .filter(|provider| filter.is_active(provider.provider_type())) + .flat_map(|provider| { + provider.items().iter().filter_map(|item| { + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item + .description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + + let score = match (name_score, desc_score) { + (Some(n), Some(d)) => Some(n.max(d)), + (Some(n), None) => Some(n), + (None, Some(d)) => Some(d / 2), + (None, None) => None, + }; + + score.map(|s| (item.clone(), s)) + }) + }) + .collect(); + + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + results + } + + /// Get all available provider types (for UI tabs) + pub fn available_providers(&self) -> Vec { + self.providers.iter().map(|p| p.provider_type()).collect() + } +} diff --git a/src/providers/uuctl.rs b/src/providers/uuctl.rs new file mode 100644 index 0000000..7d1a241 --- /dev/null +++ b/src/providers/uuctl.rs @@ -0,0 +1,265 @@ +use super::{LaunchItem, Provider, ProviderType}; +use log::{debug, warn}; +use std::process::Command; + +/// Provider for systemd user services +/// Uses systemctl --user to list and control user-level services +pub struct UuctlProvider { + items: Vec, +} + +/// Represents the state of a systemd service +#[derive(Debug, Clone)] +pub struct ServiceState { + pub unit_name: String, + pub display_name: String, + pub description: String, + pub active: bool, + pub sub_state: String, +} + +impl UuctlProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn systemctl_available() -> bool { + Command::new("systemctl") + .arg("--user") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// Generate submenu actions for a given service + pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec { + let mut actions = Vec::new(); + + if is_active { + actions.push(LaunchItem { + id: format!("systemd:restart:{}", unit_name), + name: "↻ Restart".to_string(), + description: Some(format!("Restart {}", display_name)), + icon: Some("view-refresh".to_string()), + provider: ProviderType::Uuctl, + command: format!("systemctl --user restart {}", unit_name), + terminal: false, + }); + + actions.push(LaunchItem { + id: format!("systemd:stop:{}", unit_name), + name: "■ Stop".to_string(), + description: Some(format!("Stop {}", display_name)), + icon: Some("process-stop".to_string()), + provider: ProviderType::Uuctl, + command: format!("systemctl --user stop {}", unit_name), + terminal: false, + }); + + actions.push(LaunchItem { + id: format!("systemd:reload:{}", unit_name), + name: "⟳ Reload".to_string(), + description: Some(format!("Reload {} configuration", display_name)), + icon: Some("view-refresh".to_string()), + provider: ProviderType::Uuctl, + command: format!("systemctl --user reload {}", unit_name), + terminal: false, + }); + + actions.push(LaunchItem { + id: format!("systemd:kill:{}", unit_name), + name: "✗ Kill".to_string(), + description: Some(format!("Force kill {}", display_name)), + icon: Some("edit-delete".to_string()), + provider: ProviderType::Uuctl, + command: format!("systemctl --user kill {}", unit_name), + terminal: false, + }); + } else { + actions.push(LaunchItem { + id: format!("systemd:start:{}", unit_name), + name: "▶ Start".to_string(), + description: Some(format!("Start {}", display_name)), + icon: Some("media-playback-start".to_string()), + provider: ProviderType::Uuctl, + command: format!("systemctl --user start {}", unit_name), + terminal: false, + }); + } + + // Always available actions + actions.push(LaunchItem { + id: format!("systemd:status:{}", unit_name), + name: "ℹ Status".to_string(), + description: Some(format!("Show {} status", display_name)), + icon: Some("dialog-information".to_string()), + provider: ProviderType::Uuctl, + command: format!("systemctl --user status {}", unit_name), + terminal: true, + }); + + actions.push(LaunchItem { + id: format!("systemd:journal:{}", unit_name), + name: "📋 Journal".to_string(), + description: Some(format!("Show {} logs", display_name)), + icon: Some("utilities-system-monitor".to_string()), + provider: ProviderType::Uuctl, + command: format!("journalctl --user -u {} -f", unit_name), + terminal: true, + }); + + actions.push(LaunchItem { + id: format!("systemd:enable:{}", unit_name), + name: "⊕ Enable".to_string(), + description: Some(format!("Enable {} on startup", display_name)), + icon: Some("emblem-default".to_string()), + provider: ProviderType::Uuctl, + command: format!("systemctl --user enable {}", unit_name), + terminal: false, + }); + + actions.push(LaunchItem { + id: format!("systemd:disable:{}", unit_name), + name: "⊖ Disable".to_string(), + description: Some(format!("Disable {} on startup", display_name)), + icon: Some("emblem-unreadable".to_string()), + provider: ProviderType::Uuctl, + command: format!("systemctl --user disable {}", unit_name), + terminal: false, + }); + + actions + } + + fn parse_systemctl_output(output: &str) -> Vec { + let mut items = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse systemctl output - handle variable whitespace + // Format: UNIT LOAD ACTIVE SUB DESCRIPTION... + let mut parts = line.split_whitespace(); + + let unit_name = match parts.next() { + Some(u) => u, + None => continue, + }; + + // Skip if not a proper service name + if !unit_name.ends_with(".service") { + continue; + } + + let _load_state = parts.next().unwrap_or(""); + let active_state = parts.next().unwrap_or(""); + let sub_state = parts.next().unwrap_or(""); + let description: String = parts.collect::>().join(" "); + + // Create a clean display name + let display_name = unit_name + .trim_end_matches(".service") + .replace("app-", "") + .replace("@autostart", "") + .replace("\\x2d", "-"); + + let is_active = active_state == "active"; + let status_icon = if is_active { "●" } else { "○" }; + + let status_desc = if description.is_empty() { + format!("{} {} ({})", status_icon, sub_state, active_state) + } else { + format!("{} {} ({})", status_icon, description, sub_state) + }; + + // Store service info in the command field as encoded data + // Format: SUBMENU:unit_name:is_active + let submenu_data = format!("SUBMENU:{}:{}", unit_name, is_active); + + items.push(LaunchItem { + id: format!("systemd:service:{}", unit_name), + name: display_name, + description: Some(status_desc), + icon: Some(if is_active { "emblem-ok-symbolic" } else { "emblem-pause-symbolic" }.to_string()), + provider: ProviderType::Uuctl, + command: submenu_data, // Special marker for submenu + terminal: false, + }); + } + + items + } + + /// Check if an item is a submenu trigger (service, not action) + pub fn is_submenu_item(item: &LaunchItem) -> bool { + item.provider == ProviderType::Uuctl && item.command.starts_with("SUBMENU:") + } + + /// Parse submenu data from item command + pub fn parse_submenu_data(item: &LaunchItem) -> Option<(String, String, bool)> { + if !Self::is_submenu_item(item) { + return None; + } + + let parts: Vec<&str> = item.command.splitn(3, ':').collect(); + if parts.len() >= 3 { + let unit_name = parts[1].to_string(); + let is_active = parts[2] == "true"; + Some((unit_name, item.name.clone(), is_active)) + } else { + None + } + } +} + +impl Provider for UuctlProvider { + fn name(&self) -> &str { + "systemd-user" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Uuctl + } + + fn refresh(&mut self) { + self.items.clear(); + + if !Self::systemctl_available() { + debug!("systemctl --user not available, skipping"); + return; + } + + // List all user services (both running and available) + let output = match Command::new("systemctl") + .args(["--user", "list-units", "--type=service", "--all", "--no-legend", "--no-pager"]) + .output() + { + Ok(o) => o, + Err(e) => { + warn!("Failed to run systemctl --user: {}", e); + return; + } + }; + + if !output.status.success() { + warn!("systemctl --user failed with status: {}", output.status); + return; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + self.items = Self::parse_systemctl_output(&stdout); + + // Sort by name + self.items.sort_by(|a, b| a.name.cmp(&b.name)); + + debug!("Found {} systemd user services", self.items.len()); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs new file mode 100644 index 0000000..2fc0a30 --- /dev/null +++ b/src/ui/main_window.rs @@ -0,0 +1,792 @@ +use crate::config::Config; +use crate::filter::ProviderFilter; +use crate::providers::{LaunchItem, ProviderManager, ProviderType, UuctlProvider}; +use crate::ui::ResultRow; +use gtk4::gdk::Key; +use gtk4::prelude::*; +use gtk4::{ + Application, ApplicationWindow, Box as GtkBox, Entry, EventControllerKey, Label, ListBox, + ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton, +}; +use log::info; +use std::cell::RefCell; +use std::collections::HashMap; +use std::process::Command; +use std::rc::Rc; + +/// Tracks submenu state for services that have action submenus +#[derive(Debug, Clone, Default)] +struct SubmenuState { + /// Whether we're currently in a submenu + active: bool, + /// The service name being viewed + service_name: String, + /// Display name for the header + display_name: String, + /// The submenu items (actions) + items: Vec, + /// Saved search text to restore on exit + saved_search: String, +} + +pub struct MainWindow { + window: ApplicationWindow, + search_entry: Entry, + results_list: ListBox, + scrolled: ScrolledWindow, + config: Rc>, + providers: Rc>, + current_results: Rc>>, + filter: Rc>, + mode_label: Label, + hints_label: Label, + filter_buttons: Rc>>, + submenu_state: Rc>, +} + +impl MainWindow { + pub fn new( + app: &Application, + config: Rc>, + providers: Rc>, + filter: Rc>, + ) -> Self { + let cfg = config.borrow(); + + let window = ApplicationWindow::builder() + .application(app) + .title("Owlry") + .default_width(cfg.appearance.width) + .default_height(cfg.appearance.height) + .resizable(false) + .decorated(false) + .build(); + + window.add_css_class("owlry-window"); + + // Main container + let main_box = GtkBox::builder() + .orientation(Orientation::Vertical) + .spacing(8) + .margin_top(16) + .margin_bottom(16) + .margin_start(16) + .margin_end(16) + .build(); + + main_box.add_css_class("owlry-main"); + + // Header with mode indicator and filter tabs + let header_box = GtkBox::builder() + .orientation(Orientation::Horizontal) + .spacing(12) + .margin_bottom(8) + .build(); + header_box.add_css_class("owlry-header"); + + // Mode indicator label + let mode_label = Label::builder() + .label(filter.borrow().mode_display_name()) + .halign(gtk4::Align::Start) + .hexpand(false) + .build(); + mode_label.add_css_class("owlry-mode-indicator"); + + // Filter tabs container + let filter_tabs = GtkBox::builder() + .orientation(Orientation::Horizontal) + .spacing(4) + .halign(gtk4::Align::End) + .hexpand(true) + .build(); + filter_tabs.add_css_class("owlry-filter-tabs"); + + // Create toggle buttons for each provider + let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter); + let filter_buttons = Rc::new(RefCell::new(filter_buttons)); + + header_box.append(&mode_label); + header_box.append(&filter_tabs); + + // Search entry with dynamic placeholder + let placeholder = Self::build_placeholder(&filter.borrow()); + let search_entry = Entry::builder() + .placeholder_text(&placeholder) + .hexpand(true) + .build(); + + search_entry.add_css_class("owlry-search"); + + // Results list in scrolled window + let results_list = ListBox::builder() + .selection_mode(SelectionMode::Single) + .vexpand(true) + .build(); + + results_list.add_css_class("owlry-results"); + + let scrolled = ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .vscrollbar_policy(gtk4::PolicyType::Automatic) + .vexpand(true) + .child(&results_list) + .build(); + + // Hints bar at bottom + let hints_box = GtkBox::builder() + .orientation(Orientation::Horizontal) + .margin_top(8) + .build(); + hints_box.add_css_class("owlry-hints"); + + let hints_label = Label::builder() + .label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close :app :cmd :uuctl") + .halign(gtk4::Align::Center) + .hexpand(true) + .build(); + hints_label.add_css_class("owlry-hints-label"); + hints_box.append(&hints_label); + + // Assemble layout + main_box.append(&header_box); + main_box.append(&search_entry); + main_box.append(&scrolled.clone()); + main_box.append(&hints_box); + window.set_child(Some(&main_box)); + + drop(cfg); + + let main_window = Self { + window, + search_entry, + results_list, + scrolled, + config, + providers, + current_results: Rc::new(RefCell::new(Vec::new())), + filter, + mode_label, + hints_label, + filter_buttons, + submenu_state: Rc::new(RefCell::new(SubmenuState::default())), + }; + + main_window.setup_signals(); + main_window.update_results(""); + + // Ensure search entry has focus when window is shown + main_window.search_entry.grab_focus(); + + main_window + } + + fn create_filter_buttons( + container: &GtkBox, + filter: &Rc>, + ) -> HashMap { + let providers = [ + (ProviderType::Application, "Apps", "Ctrl+1"), + (ProviderType::Command, "Cmds", "Ctrl+2"), + (ProviderType::Uuctl, "uuctl", "Ctrl+3"), + ]; + + let mut buttons = HashMap::new(); + + for (provider_type, label, shortcut) in providers { + let button = ToggleButton::builder() + .label(label) + .tooltip_text(shortcut) + .active(filter.borrow().is_enabled(provider_type)) + .build(); + + button.add_css_class("owlry-filter-button"); + let css_class = match provider_type { + ProviderType::Application => "owlry-filter-app", + ProviderType::Command => "owlry-filter-cmd", + ProviderType::Uuctl => "owlry-filter-uuctl", + ProviderType::Dmenu => "owlry-filter-dmenu", + }; + button.add_css_class(css_class); + + container.append(&button); + buttons.insert(provider_type, button); + } + + buttons + } + + fn build_placeholder(filter: &ProviderFilter) -> String { + let active: Vec<&str> = filter + .enabled_providers() + .iter() + .map(|p| match p { + ProviderType::Application => "applications", + ProviderType::Command => "commands", + ProviderType::Uuctl => "uuctl units", + ProviderType::Dmenu => "options", + }) + .collect(); + + format!("Search {}...", active.join(", ")) + } + + /// Scroll the given row into view within the scrolled window + fn scroll_to_row(scrolled: &ScrolledWindow, results_list: &ListBox, row: &ListBoxRow) { + let vadj = scrolled.vadjustment(); + + let row_index = row.index(); + if row_index < 0 { + return; + } + + let visible_height = vadj.page_size(); + let current_scroll = vadj.value(); + + let list_height = results_list.height() as f64; + let row_count = { + let mut count = 0; + let mut child = results_list.first_child(); + while child.is_some() { + count += 1; + child = child.and_then(|c| c.next_sibling()); + } + count.max(1) as f64 + }; + + let row_height = list_height / row_count; + let row_top = row_index as f64 * row_height; + let row_bottom = row_top + row_height; + + if row_top < current_scroll { + vadj.set_value(row_top); + } else if row_bottom > current_scroll + visible_height { + vadj.set_value(row_bottom - visible_height); + } + } + + /// Enter submenu mode for a service + fn enter_submenu( + submenu_state: &Rc>, + results_list: &ListBox, + current_results: &Rc>>, + mode_label: &Label, + hints_label: &Label, + search_entry: &Entry, + unit_name: &str, + display_name: &str, + is_active: bool, + ) { + let actions = UuctlProvider::actions_for_service(unit_name, display_name, is_active); + + // Save current state + { + let mut state = submenu_state.borrow_mut(); + state.active = true; + state.service_name = unit_name.to_string(); + state.display_name = display_name.to_string(); + state.items = actions.clone(); + state.saved_search = search_entry.text().to_string(); + } + + // Update UI + mode_label.set_label(&format!("← {}", display_name)); + hints_label.set_label("Enter: execute Esc/Backspace: back ↑↓: navigate"); + search_entry.set_text(""); + search_entry.set_placeholder_text(Some(&format!("Filter {} actions...", display_name))); + + // Display actions + while let Some(child) = results_list.first_child() { + results_list.remove(&child); + } + + for item in &actions { + let row = ResultRow::new(item); + results_list.append(&row); + } + + if let Some(first_row) = results_list.row_at_index(0) { + results_list.select_row(Some(&first_row)); + } + + *current_results.borrow_mut() = actions; + } + + /// Exit submenu mode + fn exit_submenu( + submenu_state: &Rc>, + mode_label: &Label, + hints_label: &Label, + search_entry: &Entry, + filter: &Rc>, + ) { + let saved_search = { + let mut state = submenu_state.borrow_mut(); + state.active = false; + state.items.clear(); + state.saved_search.clone() + }; + + // Restore UI + mode_label.set_label(filter.borrow().mode_display_name()); + hints_label.set_label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close :app :cmd :uuctl"); + search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); + search_entry.set_text(&saved_search); + + // Trigger refresh by emitting changed signal + search_entry.emit_by_name::<()>("changed", &[]); + } + + fn setup_signals(&self) { + // Search input handling with prefix detection + let providers = self.providers.clone(); + let results_list = self.results_list.clone(); + let config = self.config.clone(); + let current_results = self.current_results.clone(); + let filter = self.filter.clone(); + let mode_label = self.mode_label.clone(); + let search_entry_for_change = self.search_entry.clone(); + let submenu_state = self.submenu_state.clone(); + + self.search_entry.connect_changed(move |entry| { + let raw_query = entry.text(); + + // If in submenu, filter the submenu items + if submenu_state.borrow().active { + let state = submenu_state.borrow(); + let query = raw_query.to_lowercase(); + + let filtered: Vec = state + .items + .iter() + .filter(|item| { + query.is_empty() + || item.name.to_lowercase().contains(&query) + || item + .description + .as_ref() + .map(|d| d.to_lowercase().contains(&query)) + .unwrap_or(false) + }) + .cloned() + .collect(); + + // Clear and repopulate + while let Some(child) = results_list.first_child() { + results_list.remove(&child); + } + + for item in &filtered { + let row = ResultRow::new(item); + results_list.append(&row); + } + + if let Some(first_row) = results_list.row_at_index(0) { + results_list.select_row(Some(&first_row)); + } + + *current_results.borrow_mut() = filtered; + return; + } + + // Normal mode: parse prefix and search + let parsed = ProviderFilter::parse_query(&raw_query); + + { + let mut f = filter.borrow_mut(); + f.set_prefix(parsed.prefix); + } + + mode_label.set_label(filter.borrow().mode_display_name()); + + if parsed.prefix.is_some() { + let prefix_name = match parsed.prefix.unwrap() { + ProviderType::Application => "applications", + ProviderType::Command => "commands", + ProviderType::Uuctl => "uuctl units", + ProviderType::Dmenu => "options", + }; + search_entry_for_change + .set_placeholder_text(Some(&format!("Search {}...", prefix_name))); + } + + let max_results = config.borrow().general.max_results; + let results: Vec = providers + .borrow() + .search_filtered(&parsed.query, max_results, &filter.borrow()) + .into_iter() + .map(|(item, _)| item) + .collect(); + + while let Some(child) = results_list.first_child() { + results_list.remove(&child); + } + + for item in &results { + let row = ResultRow::new(item); + results_list.append(&row); + } + + if let Some(first_row) = results_list.row_at_index(0) { + results_list.select_row(Some(&first_row)); + } + + *current_results.borrow_mut() = results; + }); + + // Entry activate signal (Enter key in search entry) + let results_list_for_activate = self.results_list.clone(); + let current_results_for_activate = self.current_results.clone(); + let config_for_activate = self.config.clone(); + let window_for_activate = self.window.clone(); + let submenu_state_for_activate = self.submenu_state.clone(); + let mode_label_for_activate = self.mode_label.clone(); + let hints_label_for_activate = self.hints_label.clone(); + let search_entry_for_activate = self.search_entry.clone(); + + self.search_entry.connect_activate(move |_| { + let selected = results_list_for_activate + .selected_row() + .or_else(|| results_list_for_activate.row_at_index(0)); + + if let Some(row) = selected { + let index = row.index() as usize; + let results = current_results_for_activate.borrow(); + if let Some(item) = results.get(index) { + // Check if this is a submenu item + if let Some((unit_name, display_name, is_active)) = + UuctlProvider::parse_submenu_data(item) + { + drop(results); // Release borrow before calling enter_submenu + Self::enter_submenu( + &submenu_state_for_activate, + &results_list_for_activate, + ¤t_results_for_activate, + &mode_label_for_activate, + &hints_label_for_activate, + &search_entry_for_activate, + &unit_name, + &display_name, + is_active, + ); + } else { + // Execute the command + Self::launch_item(item, &config_for_activate.borrow()); + window_for_activate.close(); + } + } + } + }); + + // Filter button signals + for (provider_type, button) in self.filter_buttons.borrow().iter() { + let filter = self.filter.clone(); + let search_entry = self.search_entry.clone(); + let mode_label = self.mode_label.clone(); + let ptype = *provider_type; + + button.connect_toggled(move |btn| { + { + let mut f = filter.borrow_mut(); + if btn.is_active() { + f.enable(ptype); + } else { + f.disable(ptype); + } + } + mode_label.set_label(filter.borrow().mode_display_name()); + search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); + search_entry.emit_by_name::<()>("changed", &[]); + }); + } + + // Keyboard navigation + let key_controller = EventControllerKey::new(); + let window = self.window.clone(); + let results_list = self.results_list.clone(); + let scrolled = self.scrolled.clone(); + let search_entry = self.search_entry.clone(); + let current_results = self.current_results.clone(); + let config = self.config.clone(); + let filter = self.filter.clone(); + let filter_buttons = self.filter_buttons.clone(); + let mode_label = self.mode_label.clone(); + let hints_label = self.hints_label.clone(); + let submenu_state = self.submenu_state.clone(); + + key_controller.connect_key_pressed(move |_, key, _, modifiers| { + let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK); + let shift = modifiers.contains(gtk4::gdk::ModifierType::SHIFT_MASK); + + match key { + Key::Escape => { + // If in submenu, exit submenu first + if submenu_state.borrow().active { + Self::exit_submenu( + &submenu_state, + &mode_label, + &hints_label, + &search_entry, + &filter, + ); + gtk4::glib::Propagation::Stop + } else { + window.close(); + gtk4::glib::Propagation::Stop + } + } + Key::BackSpace if search_entry.text().is_empty() => { + // If in submenu with empty search, exit submenu + if submenu_state.borrow().active { + Self::exit_submenu( + &submenu_state, + &mode_label, + &hints_label, + &search_entry, + &filter, + ); + gtk4::glib::Propagation::Stop + } else { + window.close(); + gtk4::glib::Propagation::Stop + } + } + Key::Down => { + let selected = results_list + .selected_row() + .or_else(|| results_list.row_at_index(0)); + + if let Some(current) = selected { + let next_index = current.index() + 1; + if let Some(next_row) = results_list.row_at_index(next_index) { + results_list.select_row(Some(&next_row)); + Self::scroll_to_row(&scrolled, &results_list, &next_row); + } + } + gtk4::glib::Propagation::Stop + } + Key::Up => { + if let Some(selected) = results_list.selected_row() { + let prev_index = selected.index() - 1; + if prev_index >= 0 { + if let Some(prev_row) = results_list.row_at_index(prev_index) { + results_list.select_row(Some(&prev_row)); + Self::scroll_to_row(&scrolled, &results_list, &prev_row); + } + } + } + gtk4::glib::Propagation::Stop + } + // Tab cycles through filter modes (only when not in submenu) + Key::Tab if !ctrl => { + if !submenu_state.borrow().active { + Self::cycle_filter_mode( + &filter, + &filter_buttons, + &search_entry, + &mode_label, + !shift, + ); + } + gtk4::glib::Propagation::Stop + } + Key::ISO_Left_Tab => { + if !submenu_state.borrow().active { + Self::cycle_filter_mode( + &filter, + &filter_buttons, + &search_entry, + &mode_label, + false, + ); + } + gtk4::glib::Propagation::Stop + } + // Ctrl+1/2/3 toggle specific providers (only when not in submenu) + Key::_1 if ctrl => { + if !submenu_state.borrow().active { + Self::toggle_provider_button( + ProviderType::Application, + &filter, + &filter_buttons, + &search_entry, + &mode_label, + ); + } + gtk4::glib::Propagation::Stop + } + Key::_2 if ctrl => { + if !submenu_state.borrow().active { + Self::toggle_provider_button( + ProviderType::Command, + &filter, + &filter_buttons, + &search_entry, + &mode_label, + ); + } + gtk4::glib::Propagation::Stop + } + Key::_3 if ctrl => { + if !submenu_state.borrow().active { + Self::toggle_provider_button( + ProviderType::Uuctl, + &filter, + &filter_buttons, + &search_entry, + &mode_label, + ); + } + gtk4::glib::Propagation::Stop + } + _ => gtk4::glib::Propagation::Proceed, + } + }); + + self.window.add_controller(key_controller); + + // Double-click to launch + let current_results = self.current_results.clone(); + let config = self.config.clone(); + let window = self.window.clone(); + let submenu_state = self.submenu_state.clone(); + let results_list_for_click = self.results_list.clone(); + let mode_label = self.mode_label.clone(); + let hints_label = self.hints_label.clone(); + let search_entry = self.search_entry.clone(); + + self.results_list.connect_row_activated(move |_list, row| { + let index = row.index() as usize; + let results = current_results.borrow(); + if let Some(item) = results.get(index) { + // Check if this is a submenu item + if let Some((unit_name, display_name, is_active)) = + UuctlProvider::parse_submenu_data(item) + { + drop(results); + Self::enter_submenu( + &submenu_state, + &results_list_for_click, + ¤t_results, + &mode_label, + &hints_label, + &search_entry, + &unit_name, + &display_name, + is_active, + ); + } else { + Self::launch_item(item, &config.borrow()); + window.close(); + } + } + }); + } + + fn cycle_filter_mode( + filter: &Rc>, + buttons: &Rc>>, + entry: &Entry, + mode_label: &Label, + forward: bool, + ) { + let order = [ + ProviderType::Application, + ProviderType::Command, + ProviderType::Uuctl, + ]; + let current = filter.borrow().enabled_providers(); + + let next = if current.len() == 1 { + let idx = order.iter().position(|p| p == ¤t[0]).unwrap_or(0); + if forward { + order[(idx + 1) % order.len()] + } else { + order[(idx + order.len() - 1) % order.len()] + } + } else { + ProviderType::Application + }; + + { + let mut f = filter.borrow_mut(); + f.set_single_mode(next); + } + + for (ptype, button) in buttons.borrow().iter() { + button.set_active(*ptype == next); + } + + mode_label.set_label(filter.borrow().mode_display_name()); + entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); + entry.emit_by_name::<()>("changed", &[]); + } + + fn toggle_provider_button( + provider: ProviderType, + filter: &Rc>, + buttons: &Rc>>, + entry: &Entry, + mode_label: &Label, + ) { + { + let mut f = filter.borrow_mut(); + f.toggle(provider); + } + + if let Some(button) = buttons.borrow().get(&provider) { + button.set_active(filter.borrow().is_enabled(provider)); + } + + mode_label.set_label(filter.borrow().mode_display_name()); + entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow()))); + entry.emit_by_name::<()>("changed", &[]); + } + + fn update_results(&self, query: &str) { + let max_results = self.config.borrow().general.max_results; + let results: Vec = self + .providers + .borrow() + .search_filtered(query, max_results, &self.filter.borrow()) + .into_iter() + .map(|(item, _)| item) + .collect(); + + while let Some(child) = self.results_list.first_child() { + self.results_list.remove(&child); + } + + for item in &results { + let row = ResultRow::new(item); + self.results_list.append(&row); + } + + if let Some(first_row) = self.results_list.row_at_index(0) { + self.results_list.select_row(Some(&first_row)); + } + + *self.current_results.borrow_mut() = results; + } + + fn launch_item(item: &LaunchItem, config: &Config) { + info!("Launching: {} ({})", item.name, item.command); + + let cmd = if item.terminal { + format!("{} -e {}", config.general.terminal_command, item.command) + } else { + item.command.clone() + }; + + if let Err(e) = Command::new("sh").arg("-c").arg(&cmd).spawn() { + log::error!("Failed to launch '{}': {}", item.name, e); + } + } +} + +impl std::ops::Deref for MainWindow { + type Target = ApplicationWindow; + + fn deref(&self) -> &Self::Target { + &self.window + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..d6bac57 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,5 @@ +mod main_window; +mod result_row; + +pub use main_window::MainWindow; +pub use result_row::ResultRow; diff --git a/src/ui/result_row.rs b/src/ui/result_row.rs new file mode 100644 index 0000000..61fa7ec --- /dev/null +++ b/src/ui/result_row.rs @@ -0,0 +1,93 @@ +use crate::providers::LaunchItem; +use gtk4::prelude::*; +use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation}; + +pub struct ResultRow { + row: ListBoxRow, +} + +impl ResultRow { + pub fn new(item: &LaunchItem) -> ListBoxRow { + let row = ListBoxRow::builder() + .selectable(true) + .activatable(true) + .build(); + + row.add_css_class("owlry-result-row"); + + let hbox = GtkBox::builder() + .orientation(Orientation::Horizontal) + .spacing(12) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build(); + + // Icon + let icon = if let Some(icon_name) = &item.icon { + Image::from_icon_name(icon_name) + } else { + // Default icon based on provider type + let default_icon = match item.provider { + crate::providers::ProviderType::Application => "application-x-executable", + crate::providers::ProviderType::Command => "utilities-terminal", + crate::providers::ProviderType::Dmenu => "view-list-symbolic", + crate::providers::ProviderType::Uuctl => "system-run", + }; + Image::from_icon_name(default_icon) + }; + + icon.set_pixel_size(32); + icon.add_css_class("owlry-result-icon"); + + // Text container + let text_box = GtkBox::builder() + .orientation(Orientation::Vertical) + .hexpand(true) + .valign(gtk4::Align::Center) + .build(); + + // Name label + let name_label = Label::builder() + .label(&item.name) + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + + name_label.add_css_class("owlry-result-name"); + + // Description label + if let Some(desc) = &item.description { + let desc_label = Label::builder() + .label(desc) + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + + desc_label.add_css_class("owlry-result-description"); + text_box.append(&name_label); + text_box.append(&desc_label); + } else { + text_box.append(&name_label); + } + + // Provider badge + let badge = Label::builder() + .label(&item.provider.to_string()) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Center) + .build(); + + badge.add_css_class("owlry-result-badge"); + badge.add_css_class(&format!("owlry-badge-{}", item.provider)); + + hbox.append(&icon); + hbox.append(&text_box); + hbox.append(&badge); + + row.set_child(Some(&hbox)); + + row + } +}