34 Commits

Author SHA1 Message Date
5519381d8c chore: bump version to 0.3.6 2025-12-29 17:39:46 +01:00
38025279f9 docs: reorganize and update README
- Add new features: tags, configurable tabs, tag filtering
- Add File Locations section with XDG paths
- Fix scripts path (~/.local/share instead of ~/.config)
- Add Tags section explaining tag-based filtering
- Add Tab Configuration section
- Consolidate providers into a table
- Streamline theming section
- Remove redundant examples and verbose explanations
- More concise overall (~140 lines shorter)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:39:01 +01:00
405b598b9b docs: add example files and reorganize config
- Add data/style.example.css with CSS customization guide
- Add data/scripts/example.sh as script template
- Reorganize config.example.toml with clear sections:
  - File locations box at top
  - Separate sections: General, Appearance, Providers
  - Group trigger providers vs prefix providers
  - Add inline comments for all options
- List all built-in themes in appearance section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:34:10 +01:00
d086995399 docs: update config.example.toml with accurate XDG paths
- Add file locations header documenting all XDG paths
- Fix scripts path: $XDG_DATA_HOME not $XDG_CONFIG_HOME
- Document theme location for custom themes
- Document style.css for custom CSS overrides
- Document frecency.json storage location

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:32:05 +01:00
7ca8a1f443 feat: add tags, configurable tabs, and tag-based filtering
- Add `tags` field to LaunchItem for categorization
- Extract .desktop Categories as tags for applications
- Add semantic tags to providers (systemd, ssh, script, etc.)
- Display tag badges in result rows (max 3 tags)
- Add `tabs` config option for customizable header tabs
- Dynamic Ctrl+1-9 shortcuts based on tab config
- Add `:tag:XXX` prefix for tag-based filtering
- Include tags in fuzzy search with lower weight
- Update config.example.toml with tabs documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:30:47 +01:00
2a2a22f72c refactor: restructure project directories to follow FHS
- Move themes/ → data/themes/ (installable to /usr/share/owlry/themes/)
- Move resources/ → src/resources/ (embedded into binary)
- Move config.example.toml → data/ (installable to /usr/share/doc/owlry/)
- Update include_str! paths in app.rs

New structure follows Filesystem Hierarchy Standard:
- data/         → files installed to /usr/share/
- src/resources → embedded resources compiled into binary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:56:49 +01:00
0eccdc5883 refactor: centralize path handling with XDG Base Directory compliance
- Add src/paths.rs module for all XDG path lookups
- Move scripts from ~/.config to ~/.local/share (XDG data)
- Use $XDG_CONFIG_HOME for browser bookmark paths
- Add dev-logging feature flag for verbose debug output
- Add dev-install profile for testable release builds
- Remove CLAUDE.md from version control

BREAKING: Scripts directory moved from
~/.config/owlry/scripts/ to ~/.local/share/owlry/scripts/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:46:14 +01:00
3f7a8950eb chore: bump version to 0.3.5 2025-12-28 19:33:25 +01:00
b38bf082e1 fix: update emoji test to check description field 2025-12-28 19:33:20 +01:00
617dbbce3e chore: bump version to 0.3.4 2025-12-28 19:28:17 +01:00
4ff054afe0 chore: bump version to 0.3.3 2025-12-28 19:21:51 +01:00
8547dfa951 chore: bump version to 0.3.2 2025-12-28 19:15:53 +01:00
e1a6650b7d fix: remove duplicate emoji from emoji picker display
Moved emoji character to description instead of name to avoid
showing it twice (once as icon, once in text).

Now displays:
- Name: "grinning face"
- Description: "😀 smile happy"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 19:15:45 +01:00
6ae8c734d2 chore: bump version to 0.3.1 2025-12-28 19:05:43 +01:00
cf48d53c57 fix: enable new providers in filter by default
Added config options for all new providers (system, ssh, clipboard,
bookmarks, emoji, scripts, files) with default=true via serde.

Updated filter to add these providers to enabled set based on config.

Also updated README with comprehensive documentation for all providers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 19:05:35 +01:00
41cd212264 chore: bump version to 0.3.0 2025-12-28 18:55:32 +01:00
7cdb97d743 feat: add 7 new providers (system, ssh, clipboard, files, bookmarks, emoji, scripts)
New providers:
- System: shutdown, reboot, suspend, hibernate, lock, logout, reboot into BIOS
- SSH: parse ~/.ssh/config for quick host connections
- Clipboard: integrate with cliphist for clipboard history
- Files: search files using fd or locate (/ or find prefix)
- Bookmarks: read Chrome/Chromium/Brave/Edge browser bookmarks
- Emoji: searchable emoji picker with wl-copy integration
- Scripts: run user scripts from ~/.config/owlry/scripts/

Filter prefixes: :sys, :ssh, :clip, :file, :bm, :emoji, :script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 18:55:27 +01:00
98ac769b29 chore: bump version to 0.2.1 2025-12-28 18:33:42 +01:00
e73793dd6e fix: web search not working in :web filter mode
Added evaluate_raw() method to WebSearchProvider and handler in
search_with_frecency() to support raw queries when using :web prefix.

Same pattern as calculator fix - trigger prefixes (?, web) call
evaluate() while filter mode (:web) calls evaluate_raw().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 18:33:34 +01:00
e680032d0e feat: add web search provider and fix calculator
Web Search Provider:
- Type "? query" or "web query" to search the web
- Configurable search engine (duckduckgo, google, bing, startpage, etc.)
- Custom URL templates with {query} placeholder supported
- Opens browser via xdg-open

Calculator Fixes:
- Support "=5+3" without space (previously required "= 5+3")
- :calc mode now evaluates raw expressions directly
- Added looks_like_expression() for better detection

New config options:
- providers.websearch = true
- providers.search_engine = "duckduckgo"

UI updates:
- Added :web and :search prefixes
- Web badge with teal styling
- Updated hints bar to show "? web" syntax

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 18:22:57 +01:00
738fecc6da feat: add calculator provider and frecency tracking
Calculator:
- Type "= expression" or "calc expression" for instant math evaluation
- Supports standard math functions (sqrt, sin, cos, etc.) and constants (pi, e)
- Copies result to clipboard on Enter via wl-copy

Frecency:
- Firefox-style algorithm: score = launch_count * recency_weight
- Boosts frequently and recently launched items in search results
- Persists to ~/.local/share/owlry/frecency.json
- Configurable via providers.frecency and providers.frecency_weight

New config options:
- providers.calculator = true
- providers.frecency = true
- providers.frecency_weight = 0.3

UI updates:
- Added :calc prefix support
- Calculator badge with yellow styling
- Updated hints to show "= calc" syntax

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 17:17:46 +01:00
a1351f05e9 chore: bump version to 0.1.8 2025-12-28 16:42:11 +01:00
7118498773 feat: add popular color scheme themes
Added themes:
- rose-pine
- dracula
- gruvbox-dark
- tokyo-night
- solarized-dark
- one-dark

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:42:10 +01:00
3d05e560b1 chore: bump version to 0.1.7 2025-12-28 16:31:19 +01:00
604b902261 feat: add example themes (owl, catppuccin-mocha, nord)
Themes are installed to /usr/share/owlry/themes/ and can be
copied to ~/.config/owlry/themes/ for customization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:31:18 +01:00
bb0b0dfa87 chore: bump version to 0.1.6 2025-12-28 16:22:33 +01:00
fc4dde32eb docs: add example config location to README
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:22:32 +01:00
cc1ad7bbb7 chore: bump version to 0.1.5 2025-12-28 16:16:40 +01:00
16ba5b642a docs: add example config and CLAUDE.md
- config.example.toml with all available options documented
- CLAUDE.md with release workflow instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:16:31 +01:00
1608582cbd fix: detect dmenu mode correctly using fstat
Previously, poll() on /dev/null returned "readable" (EOF),
causing dmenu mode to trigger when launched from keybinds.

Now uses fstat() to check if stdin is a pipe or regular file
before checking for data. Character devices (TTY, /dev/null)
no longer trigger dmenu mode.

Fixes items not showing when launched from window manager keybinds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:07:59 +01:00
34145d5fbe feat: add startup diagnostics for environment issues
- Log HOME, PATH, XDG_DATA_HOME at startup
- Warn when critical env vars are missing
- Log item count per provider after refresh

This helps diagnose why items may not load when launched
from window manager keybinds vs terminal.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 15:52:08 +01:00
e94eb2050c chore: bump version to 0.1.2 2025-12-28 15:35:35 +01:00
254af3f0b2 feat: add uwsm/hyprland launch wrapper and fix CLI args
- Add launch_wrapper config option with auto-detection for uwsm and
  hyprland sessions, ensuring apps launch with proper session management
- Fix CLI argument parsing by preventing GTK from intercepting
  clap-parsed args (--mode, --providers)
- Improve desktop file Exec field parsing to properly handle quoted
  arguments and FreeDesktop field codes (%u, %F, etc.)
- Add unit tests for Exec field parsing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 15:35:29 +01:00
884f871d7f docs: add AUR installation instructions
- Add AUR badge to shields
- Add Arch Linux (AUR) as recommended install method
- Reorganize build-from-source section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 15:05:30 +01:00
42 changed files with 5259 additions and 244 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target
CLAUDE.md

240
Cargo.lock generated
View File

@@ -11,6 +11,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
@@ -90,6 +99,12 @@ dependencies = [
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytes"
version = "1.11.0"
@@ -145,6 +160,20 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.5.53"
@@ -191,6 +220,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "dirs"
version = "5.0.1"
@@ -267,6 +302,12 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "freedesktop-desktop-entry"
version = "0.7.19"
@@ -694,6 +735,30 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.12.1"
@@ -710,6 +775,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jiff"
version = "0.2.17"
@@ -734,6 +805,16 @@ dependencies = [
"syn",
]
[[package]]
name = "js-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "khronos_api"
version = "3.1.0"
@@ -805,6 +886,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "meval"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9"
dependencies = [
"fnv",
"nom",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -816,6 +907,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nom"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "objc"
version = "0.2.7"
@@ -845,6 +951,12 @@ dependencies = [
"objc",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
@@ -859,8 +971,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "owlry"
version = "0.1.1"
version = "0.3.6"
dependencies = [
"chrono",
"clap",
"dirs",
"env_logger",
@@ -870,7 +983,9 @@ dependencies = [
"gtk4-layer-shell",
"libc",
"log",
"meval",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"toml 0.8.23",
@@ -1009,6 +1124,12 @@ dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "semver"
version = "1.0.27"
@@ -1045,6 +1166,19 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
@@ -1318,6 +1452,51 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -1340,12 +1519,65 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -1514,3 +1746,9 @@ name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "zmij"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167"

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "0.1.1"
version = "0.3.6"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
@@ -46,6 +46,20 @@ toml = "0.8"
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
# Math expression evaluation for calculator
meval = "0.2"
# JSON serialization for data persistence
serde_json = "1"
# Date/time for frecency calculations
chrono = { version = "0.4", features = ["serde"] }
[features]
default = []
# Enable verbose debug logging (for development/testing builds)
dev-logging = []
[profile.release]
lto = true
codegen-units = 1
@@ -56,3 +70,9 @@ opt-level = "z" # Optimize for size
[profile.dev]
opt-level = 0
debug = true
# For installing a testable build: cargo install --path . --profile dev-install --features dev-logging
[profile.dev-install]
inherits = "release"
strip = false
debug = 1 # Basic debug info for stack traces

290
README.md
View File

@@ -1,5 +1,6 @@
# Owlry
[![AUR](https://img.shields.io/aur/version/owlry?logo=archlinux&label=AUR)](https://aur.archlinux.org/packages/owlry)
[![Rust](https://img.shields.io/badge/rust-1.90%2B-orange.svg)](https://www.rust-lang.org/)
[![License](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
[![GTK4](https://img.shields.io/badge/GTK-4.12-green.svg)](https://gtk.org/)
@@ -9,18 +10,30 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
## Features
- **Provider-based architecture** - Search applications, PATH commands, and systemd user services
- **Fuzzy search** - Fast, typo-tolerant matching across all providers
- **Filter tabs & prefixes** - Scope searches with UI tabs or `:app`, `:cmd`, `:uuctl` prefixes
- **Systemd integration** - Manage user services with submenu actions (start/stop/restart/status/journal)
- **GTK4 theming** - Respects system theme by default, with optional custom themes
- **CSS variables** - Full customization via config or custom stylesheets
- **Wayland native** - Uses Layer Shell for proper overlay behavior
- **Provider-based architecture** Search applications, commands, system actions, SSH hosts, clipboard history, bookmarks, emoji, and more
- **Fuzzy search with tags** Fast matching across names, descriptions, and category tags
- **Configurable tabs** — Customize header tabs and keyboard shortcuts
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
- **Calculator** — Quick math with `= 5+3` or `calc sin(pi/2)`
- **Web search** — Search the web with `? query`
- **File search** — Find files with `/ filename` (requires `fd` or `locate`)
- **Frecency ranking** — Frequently/recently used items rank higher
- **GTK4 theming** — System theme by default, with 9 built-in themes
- **Wayland native** — Uses Layer Shell for proper overlay behavior
## Installation
### Dependencies
### Arch Linux (AUR)
```bash
yay -S owlry
# or
paru -S owlry
```
### Build from Source
**Dependencies:**
```bash
# Arch Linux
sudo pacman -S gtk4 gtk4-layer-shell
@@ -32,32 +45,31 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
sudo dnf install gtk4-devel gtk4-layer-shell-devel
```
### Build from source
**Optional dependencies:**
```bash
# Clipboard history
sudo pacman -S cliphist wl-clipboard
Requires Rust 1.90 or later.
# File search (choose one)
sudo pacman -S fd # recommended
sudo pacman -S mlocate # alternative
```
**Build (requires Rust 1.90+):**
```bash
git clone https://somegit.dev/Owlibou/owlry.git
cd owlry
cargo build --release
# Binary: target/release/owlry
```
The binary will be at `target/release/owlry`.
## Usage
```bash
# Launch with default settings (GTK theme, all providers)
owlry
# Launch with only applications
owlry --mode app
# Launch with specific providers
owlry --providers app,cmd
# Show help
owlry --help
owlry # Launch with defaults
owlry --mode app # Applications only
owlry --providers app,cmd # Specific providers
owlry --help # Show all options
```
### Keyboard Shortcuts
@@ -67,81 +79,177 @@ owlry --help
| `Enter` | Launch selected item |
| `Escape` | Close launcher / exit submenu |
| `↑` / `↓` | Navigate results |
| `Tab` | Cycle filter modes |
| `Shift+Tab` | Cycle filter modes (reverse) |
| `Ctrl+1` | Toggle Applications filter |
| `Ctrl+2` | Toggle Commands filter |
| `Ctrl+3` | Toggle systemd filter |
| `Tab` | Cycle filter tabs |
| `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position |
### Search Prefixes
| Prefix | Scope |
|--------|-------|
| `:app ` | Applications only |
| `:cmd ` | PATH commands only |
| `:uuctl ` | systemd user services only |
| Prefix | Provider | Example |
|--------|----------|---------|
| `:app` | Applications | `:app firefox` |
| `:cmd` | PATH commands | `:cmd git` |
| `:sys` | System actions | `:sys shutdown` |
| `:ssh` | SSH hosts | `:ssh server` |
| `:clip` | Clipboard | `:clip password` |
| `:bm` | Bookmarks | `:bm github` |
| `:emoji` | Emoji | `:emoji heart` |
| `:script` | Scripts | `:script backup` |
| `:file` | Files | `:file config` |
| `:calc` | Calculator | `:calc sqrt(16)` |
| `:web` | Web search | `:web rust docs` |
| `:uuctl` | systemd | `:uuctl docker` |
| `:tag:X` | Filter by tag | `:tag:development` |
Example: `:cmd git` searches only PATH commands for "git"
### Trigger Prefixes
| Trigger | Provider | Example |
|---------|----------|---------|
| `=` | Calculator | `= 5+3` |
| `calc ` | Calculator | `calc sqrt(16)` |
| `?` | Web search | `? rust programming` |
| `web ` | Web search | `web linux tips` |
| `/` | File search | `/ .bashrc` |
| `find ` | File search | `find config` |
## File Locations
Owlry follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/):
| Path | Purpose |
|------|---------|
| `~/.config/owlry/config.toml` | Main configuration |
| `~/.config/owlry/themes/*.css` | Custom themes |
| `~/.config/owlry/style.css` | CSS overrides |
| `~/.local/share/owlry/scripts/` | User scripts |
| `~/.local/share/owlry/frecency.json` | Usage history |
## Configuration
Configuration file: `~/.config/owlry/config.toml`
Copy the example config:
```bash
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
```
### Example Configuration
```toml
[general]
show_icons = true
max_results = 10
# terminal_command = "kitty" # Auto-detected if not set
tabs = ["app", "cmd", "uuctl"] # Header tabs (Ctrl+1, Ctrl+2, etc.)
# terminal_command = "kitty" # Auto-detected
# launch_wrapper = "uwsm app --" # Auto-detected
[appearance]
width = 600
height = 400
font_size = 14
border_radius = 12
# theme = "owl" # Optional: "owl" or custom theme name
# [appearance.colors]
# Override individual colors (optional)
# accent = "#e0af68"
# background = "#1a1b26"
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
[providers]
applications = true
commands = true
uuctl = true
calculator = true
websearch = true
search_engine = "duckduckgo"
system = true
ssh = true
clipboard = true
bookmarks = true
emoji = true
scripts = true
files = true
frecency = true
frecency_weight = 0.3
```
### Default Values
### Tab Configuration
| Setting | Default |
|---------|---------|
| `show_icons` | `true` |
| `max_results` | `10` |
| `terminal_command` | Auto-detected ($TERMINAL → xdg-terminal-exec → kitty/alacritty/etc) |
| `width` | `600` |
| `height` | `400` |
| `font_size` | `14` |
| `border_radius` | `12` |
| `theme` | None (GTK default) |
Customize which providers appear as header tabs:
```toml
[general]
# Available: app, cmd, uuctl, bookmark, calc, clip, dmenu,
# emoji, file, script, ssh, sys, web
tabs = ["app", "cmd", "ssh", "sys"]
```
Keyboard shortcuts `Ctrl+1` through `Ctrl+9` map to tab positions.
## Providers
| Provider | Description | Trigger |
|----------|-------------|---------|
| **Applications** | `.desktop` files from XDG directories | `:app` |
| **Commands** | Executables in `$PATH` | `:cmd` |
| **System** | Shutdown, reboot, suspend, lock, BIOS | `:sys` |
| **SSH** | Hosts from `~/.ssh/config` | `:ssh` |
| **Clipboard** | History via cliphist | `:clip` |
| **Bookmarks** | Chrome, Brave, Edge, Vivaldi | `:bm` |
| **Emoji** | 300+ searchable emoji | `:emoji` |
| **Scripts** | User scripts | `:script` |
| **Calculator** | Math expressions | `=` or `:calc` |
| **Web Search** | Configurable engine | `?` or `:web` |
| **Files** | fd/locate search | `/` or `:file` |
| **systemd** | User services with actions | `:uuctl` |
### Tags
Items are tagged for better search:
- **Applications**: Categories from `.desktop` files (development, utility, etc.)
- **System**: `power`, `system`
- **SSH**: `ssh`
- **Scripts**: `script`
- **systemd**: `systemd`, `service`
Filter by tag with `:tag:tagname`:
```
:tag:development # Show development apps
:tag:utility vim # Search utilities for "vim"
```
### Scripts
Create executable scripts in `~/.local/share/owlry/scripts/`:
```bash
mkdir -p ~/.local/share/owlry/scripts
cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF'
#!/bin/bash
rsync -av ~/Documents /backup/
notify-send "Backup complete"
EOF
chmod +x ~/.local/share/owlry/scripts/backup.sh
```
## Theming
### GTK Theme (Default)
### Built-in Themes
By default, Owlry inherits colors from your system GTK4 theme (Adwaita, Breeze, etc.).
### Owl Theme
Enable the built-in owl-inspired dark theme:
| Theme | Description |
|-------|-------------|
| `owl` | Dark theme with amber accents |
| `catppuccin-mocha` | Soothing pastel |
| `nord` | Arctic blue palette |
| `rose-pine` | Natural pine vibes |
| `dracula` | Dark vampire theme |
| `gruvbox-dark` | Retro groove |
| `tokyo-night` | Tokyo city lights |
| `solarized-dark` | Precision colors |
| `one-dark` | Atom's One Dark |
```toml
[appearance]
theme = "owl"
theme = "catppuccin-mocha"
```
### Custom Theme
Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
Create `~/.config/owlry/themes/mytheme.css`:
```css
:root {
@@ -155,14 +263,24 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
}
```
Then reference it in config:
### CSS Overrides
```toml
[appearance]
theme = "mytheme"
For tweaks without a full theme, create `~/.config/owlry/style.css`:
```css
/* Larger search input */
.owlry-search {
font-size: 18px;
padding: 12px 16px;
}
/* Hide tag badges */
.owlry-tag-badge {
display: none;
}
```
### CSS Variables Reference
### CSS Variables
| Variable | Description |
|----------|-------------|
@@ -171,45 +289,17 @@ theme = "mytheme"
| `--owlry-border` | Border color |
| `--owlry-text` | Primary text |
| `--owlry-text-secondary` | Muted text |
| `--owlry-accent` | Accent/highlight color |
| `--owlry-accent` | Accent color |
| `--owlry-accent-bright` | Bright accent |
| `--owlry-font-size` | Base font size |
| `--owlry-border-radius` | Border radius |
| `--owlry-badge-app` | Application badge color |
| `--owlry-badge-cmd` | Command badge color |
| `--owlry-badge-dmenu` | Dmenu badge color |
| `--owlry-badge-uuctl` | systemd badge color |
### Custom Stylesheet
For full control, create `~/.config/owlry/style.css` with any GTK4 CSS.
## Providers
### Applications
Searches `.desktop` files from standard XDG directories.
### Commands
Searches executable files in `$PATH`.
### systemd User Services
Lists and controls user-level systemd services. Select a service to access actions:
- Start / Stop / Restart / Reload
- Kill (force stop)
- Status (opens in terminal)
- Journal (live logs in terminal)
- Enable / Disable (autostart)
| `--owlry-border-radius` | Corner radius |
## License
This project is licensed under the GNU General Public License v3.0 or later - see [LICENSE](LICENSE) for details.
GNU General Public License v3.0 see [LICENSE](LICENSE).
## Acknowledgments
- [GTK4](https://gtk.org/) - UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) - Wayland Layer Shell bindings
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) - Fuzzy search algorithm
- [GTK4](https://gtk.org/) UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) Wayland Layer Shell
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) Fuzzy search

115
data/config.example.toml Normal file
View File

@@ -0,0 +1,115 @@
# Owlry Configuration
# Copy to: ~/.config/owlry/config.toml
#
# File Locations (XDG Base Directory compliant):
# ┌─────────────────────────────────────────────────────────────────────┐
# │ Config: ~/.config/owlry/config.toml Main configuration │
# │ Themes: ~/.config/owlry/themes/*.css Custom theme files │
# │ Style: ~/.config/owlry/style.css CSS overrides │
# │ Scripts: ~/.local/share/owlry/scripts/ Executable scripts │
# │ Data: ~/.local/share/owlry/frecency.json Usage history │
# └─────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════
# GENERAL
# ═══════════════════════════════════════════════════════════════════════
[general]
show_icons = true
max_results = 10
# Terminal emulator (auto-detected if not set)
terminal_command = "kitty"
# Launch wrapper for app execution (auto-detected for uwsm/Hyprland)
# Examples: "uwsm app --", "hyprctl dispatch exec --", ""
# launch_wrapper = "uwsm app --"
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
tabs = ["app", "cmd", "uuctl"]
# ═══════════════════════════════════════════════════════════════════════
# APPEARANCE
# ═══════════════════════════════════════════════════════════════════════
[appearance]
width = 600
height = 400
font_size = 14
border_radius = 12
# Theme name - loads ~/.config/owlry/themes/{name}.css
# Built-in: owl, catppuccin-mocha, dracula, gruvbox-dark, nord,
# one-dark, rose-pine, solarized-dark, tokyo-night
# Or leave unset for GTK default
# theme = "owl"
# Color overrides (applied on top of theme)
# [appearance.colors]
# background = "#1a1b26"
# background_secondary = "#24283b"
# border = "#414868"
# text = "#c0caf5"
# text_secondary = "#565f89"
# accent = "#7aa2f7"
# accent_bright = "#89b4fa"
# badge_app = "#9ece6a"
# badge_calc = "#e0af68"
# badge_cmd = "#7aa2f7"
# badge_dmenu = "#bb9af7"
# badge_uuctl = "#f7768e"
# ═══════════════════════════════════════════════════════════════════════
# PROVIDERS
# ═══════════════════════════════════════════════════════════════════════
[providers]
# Core providers (appear in main search)
applications = true # .desktop applications
commands = true # Executables from $PATH
uuctl = true # systemd --user units
# Frecency - boost frequently/recently used items
# Data: ~/.local/share/owlry/frecency.json
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# ───────────────────────────────────────────────────────────────────────
# Trigger Providers (activated by prefix)
# ───────────────────────────────────────────────────────────────────────
# Calculator: "= 5+3" or "calc 5+3" or ":calc"
calculator = true
# Web search: "? query" or "web query" or ":web"
websearch = true
search_engine = "duckduckgo"
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Custom: "https://search.example.com/?q={query}"
# File search: "/ pattern" or "find pattern" or ":file"
# Requires: fd or locate
files = true
# ───────────────────────────────────────────────────────────────────────
# Prefix Providers (use :prefix to search)
# ───────────────────────────────────────────────────────────────────────
# System: :sys or :power - shutdown, reboot, lock, suspend, hibernate, logout
system = true
# SSH: :ssh - connections from ~/.ssh/config
ssh = true
# Clipboard: :clip - history (requires cliphist)
clipboard = true
# Bookmarks: :bm - browser bookmarks (Chrome, Chromium, Brave, Edge, Vivaldi)
bookmarks = true
# Emoji: :emoji - picker (copies to clipboard)
emoji = true
# Scripts: :script - executables from ~/.local/share/owlry/scripts/
scripts = true

24
data/scripts/example.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Example Owlry Script
# Copy to: ~/.local/share/owlry/scripts/
#
# Scripts in the scripts directory appear in Owlry search results.
# They are executed when selected.
#
# Naming convention:
# The filename (without extension) becomes the display name.
# Example: "system-update.sh" shows as "Script: system-update"
#
# Tips:
# - Make scripts executable: chmod +x script.sh
# - Use descriptive names for easy searching
# - Scripts can launch GUI apps, run terminal commands, etc.
# Example: Show a notification
notify-send "Owlry" "Hello from example script!"
# Example: Open a URL
# xdg-open "https://example.com"
# Example: Run a terminal command (set terminal: true in owlry if needed)
# echo "Script executed at $(date)"

73
data/style.example.css Normal file
View File

@@ -0,0 +1,73 @@
/*
* Owlry Custom Style Overrides
* Copy to: ~/.config/owlry/style.css
*
* This file is loaded AFTER themes, allowing you to override
* specific styles without creating a full theme.
*
* Available CSS classes:
* .owlry-window - Main window container
* .owlry-main - Main content area
* .owlry-header - Header with mode and tabs
* .owlry-search - Search input field
* .owlry-results - Results list container
* .owlry-result-row - Individual result row
* .owlry-result-name - Result item name
* .owlry-result-description - Result description text
* .owlry-result-icon - Result icon
* .owlry-tag-badge - Tag badges on results
* .owlry-badge-* - Provider badges (app, cmd, uuctl, etc.)
* .owlry-filter-button - Tab filter buttons
* .owlry-filter-* - Provider-specific filter buttons
* .owlry-mode-indicator - Current mode label
* .owlry-hints - Bottom hints bar
*
* CSS Variables (set in themes or override here):
* --owlry-bg - Main background color
* --owlry-bg-secondary - Secondary background
* --owlry-border - Border color
* --owlry-text - Primary text color
* --owlry-text-secondary - Secondary text color
* --owlry-accent - Accent/highlight color
* --owlry-accent-bright - Bright accent color
* --owlry-font-size - Base font size (default: 14px)
* --owlry-border-radius - Border radius (default: 12px)
*/
/* Example: Make the window slightly larger */
/*
.owlry-main {
padding: 20px;
}
*/
/* Example: Custom search field styling */
/*
.owlry-search {
font-size: 18px;
padding: 12px 16px;
}
*/
/* Example: Highlight selected row differently */
/*
.owlry-result-row:selected {
background-color: rgba(255, 255, 255, 0.1);
border-left: 4px solid var(--owlry-accent);
}
*/
/* Example: Hide tag badges */
/*
.owlry-tag-badge {
display: none;
}
*/
/* Example: Custom scrollbar */
/*
scrollbar slider {
background-color: rgba(128, 128, 128, 0.3);
border-radius: 4px;
}
*/

View File

@@ -0,0 +1,114 @@
/*
* Owlry - Catppuccin Mocha Theme
* A soothing pastel theme based on Catppuccin Mocha palette
* https://catppuccin.com/
*
* Usage: Copy to ~/.config/owlry/themes/catppuccin-mocha.css
* Set theme = "catppuccin-mocha" in config.toml
*/
:root {
--owlry-bg: #1e1e2e;
--owlry-bg-secondary: #313244;
--owlry-border: #45475a;
--owlry-text: #cdd6f4;
--owlry-text-secondary: #a6adc8;
--owlry-accent: #cba6f7;
--owlry-accent-bright: #f5c2e7;
--owlry-badge-app: #a6e3a1;
--owlry-badge-cmd: #89b4fa;
--owlry-badge-dmenu: #f9e2af;
--owlry-badge-uuctl: #fab387;
}
.owlry-main {
background-color: rgba(30, 30, 46, 0.95);
border: 1px solid rgba(69, 71, 90, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(203, 166, 247, 0.1);
}
.owlry-search {
background-color: rgba(49, 50, 68, 0.8);
border: 2px solid rgba(69, 71, 90, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(203, 166, 247, 0.2);
}
.owlry-result-row:hover {
background-color: rgba(49, 50, 68, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(203, 166, 247, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(166, 227, 161, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(137, 180, 250, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(249, 226, 175, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(250, 179, 135, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(203, 166, 247, 0.2);
color: var(--owlry-accent);
border-color: rgba(203, 166, 247, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(166, 227, 161, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(166, 227, 161, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(137, 180, 250, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(137, 180, 250, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(250, 179, 135, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(250, 179, 135, 0.4);
}
scrollbar slider {
background-color: rgba(69, 71, 90, 0.5);
}
scrollbar slider:hover {
background-color: rgba(88, 91, 112, 0.7);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

114
data/themes/dracula.css Normal file
View File

@@ -0,0 +1,114 @@
/*
* Owlry - Dracula Theme
* A dark theme for vampires
* https://draculatheme.com/
*
* Usage: Copy to ~/.config/owlry/themes/dracula.css
* Set theme = "dracula" in config.toml
*/
:root {
--owlry-bg: #282a36;
--owlry-bg-secondary: #44475a;
--owlry-border: #6272a4;
--owlry-text: #f8f8f2;
--owlry-text-secondary: #6272a4;
--owlry-accent: #bd93f9;
--owlry-accent-bright: #ff79c6;
--owlry-badge-app: #50fa7b;
--owlry-badge-cmd: #8be9fd;
--owlry-badge-dmenu: #f1fa8c;
--owlry-badge-uuctl: #ffb86c;
}
.owlry-main {
background-color: rgba(40, 42, 54, 0.95);
border: 1px solid rgba(98, 114, 164, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(189, 147, 249, 0.1);
}
.owlry-search {
background-color: rgba(68, 71, 90, 0.8);
border: 2px solid rgba(98, 114, 164, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(189, 147, 249, 0.2);
}
.owlry-result-row:hover {
background-color: rgba(68, 71, 90, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(189, 147, 249, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(80, 250, 123, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(139, 233, 253, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(241, 250, 140, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(255, 184, 108, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(189, 147, 249, 0.2);
color: var(--owlry-accent);
border-color: rgba(189, 147, 249, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(80, 250, 123, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(80, 250, 123, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(139, 233, 253, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(139, 233, 253, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(255, 184, 108, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(255, 184, 108, 0.4);
}
scrollbar slider {
background-color: rgba(98, 114, 164, 0.5);
}
scrollbar slider:hover {
background-color: rgba(98, 114, 164, 0.7);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

View File

@@ -0,0 +1,114 @@
/*
* Owlry - Gruvbox Dark Theme
* Retro groove color scheme
* https://github.com/morhetz/gruvbox
*
* Usage: Copy to ~/.config/owlry/themes/gruvbox-dark.css
* Set theme = "gruvbox-dark" in config.toml
*/
:root {
--owlry-bg: #282828;
--owlry-bg-secondary: #3c3836;
--owlry-border: #504945;
--owlry-text: #ebdbb2;
--owlry-text-secondary: #a89984;
--owlry-accent: #fe8019;
--owlry-accent-bright: #fabd2f;
--owlry-badge-app: #b8bb26;
--owlry-badge-cmd: #83a598;
--owlry-badge-dmenu: #fabd2f;
--owlry-badge-uuctl: #fb4934;
}
.owlry-main {
background-color: rgba(40, 40, 40, 0.95);
border: 1px solid rgba(80, 73, 69, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(254, 128, 25, 0.1);
}
.owlry-search {
background-color: rgba(60, 56, 54, 0.8);
border: 2px solid rgba(80, 73, 69, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(254, 128, 25, 0.2);
}
.owlry-result-row:hover {
background-color: rgba(60, 56, 54, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(254, 128, 25, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(184, 187, 38, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(131, 165, 152, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(250, 189, 47, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(251, 73, 52, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(254, 128, 25, 0.2);
color: var(--owlry-accent);
border-color: rgba(254, 128, 25, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(184, 187, 38, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(184, 187, 38, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(131, 165, 152, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(131, 165, 152, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(251, 73, 52, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(251, 73, 52, 0.4);
}
scrollbar slider {
background-color: rgba(80, 73, 69, 0.5);
}
scrollbar slider:hover {
background-color: rgba(102, 92, 84, 0.7);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

114
data/themes/nord.css Normal file
View File

@@ -0,0 +1,114 @@
/*
* Owlry - Nord Theme
* An arctic, north-bluish color palette
* https://nordtheme.com/
*
* Usage: Copy to ~/.config/owlry/themes/nord.css
* Set theme = "nord" in config.toml
*/
:root {
--owlry-bg: #2e3440;
--owlry-bg-secondary: #3b4252;
--owlry-border: #4c566a;
--owlry-text: #eceff4;
--owlry-text-secondary: #d8dee9;
--owlry-accent: #88c0d0;
--owlry-accent-bright: #8fbcbb;
--owlry-badge-app: #a3be8c;
--owlry-badge-cmd: #81a1c1;
--owlry-badge-dmenu: #ebcb8b;
--owlry-badge-uuctl: #bf616a;
}
.owlry-main {
background-color: rgba(46, 52, 64, 0.95);
border: 1px solid rgba(76, 86, 106, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(136, 192, 208, 0.1);
}
.owlry-search {
background-color: rgba(59, 66, 82, 0.8);
border: 2px solid rgba(76, 86, 106, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(136, 192, 208, 0.2);
}
.owlry-result-row:hover {
background-color: rgba(59, 66, 82, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(136, 192, 208, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(163, 190, 140, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(129, 161, 193, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(235, 203, 139, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(191, 97, 106, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(136, 192, 208, 0.2);
color: var(--owlry-accent);
border-color: rgba(136, 192, 208, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(163, 190, 140, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(163, 190, 140, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(129, 161, 193, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(129, 161, 193, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(191, 97, 106, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(191, 97, 106, 0.4);
}
scrollbar slider {
background-color: rgba(76, 86, 106, 0.5);
}
scrollbar slider:hover {
background-color: rgba(76, 86, 106, 0.7);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

114
data/themes/one-dark.css Normal file
View File

@@ -0,0 +1,114 @@
/*
* Owlry - One Dark Theme
* Atom's iconic One Dark theme
* https://github.com/atom/atom/tree/master/packages/one-dark-syntax
*
* Usage: Copy to ~/.config/owlry/themes/one-dark.css
* Set theme = "one-dark" in config.toml
*/
:root {
--owlry-bg: #282c34;
--owlry-bg-secondary: #21252b;
--owlry-border: #181a1f;
--owlry-text: #abb2bf;
--owlry-text-secondary: #5c6370;
--owlry-accent: #61afef;
--owlry-accent-bright: #c678dd;
--owlry-badge-app: #98c379;
--owlry-badge-cmd: #61afef;
--owlry-badge-dmenu: #e5c07b;
--owlry-badge-uuctl: #e06c75;
}
.owlry-main {
background-color: rgba(40, 44, 52, 0.95);
border: 1px solid rgba(24, 26, 31, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(97, 175, 239, 0.1);
}
.owlry-search {
background-color: rgba(33, 37, 43, 0.8);
border: 2px solid rgba(24, 26, 31, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(97, 175, 239, 0.2);
}
.owlry-result-row:hover {
background-color: rgba(33, 37, 43, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(97, 175, 239, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(152, 195, 121, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(97, 175, 239, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(229, 192, 123, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(224, 108, 117, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(97, 175, 239, 0.2);
color: var(--owlry-accent);
border-color: rgba(97, 175, 239, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(152, 195, 121, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(152, 195, 121, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(97, 175, 239, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(97, 175, 239, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(224, 108, 117, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(224, 108, 117, 0.4);
}
scrollbar slider {
background-color: rgba(92, 99, 112, 0.5);
}
scrollbar slider:hover {
background-color: rgba(92, 99, 112, 0.7);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

123
data/themes/owl.css Normal file
View File

@@ -0,0 +1,123 @@
/*
* Owlry - Owl Theme
* An owl-inspired dark theme with amber accents
*
* Color Palette:
* - 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)
*
* Usage: Copy to ~/.config/owlry/themes/owl.css
* Set theme = "owl" in config.toml
* (Note: "owl" is also built-in, so this file is optional)
*/
:root {
--owlry-bg: #1a1b26;
--owlry-bg-secondary: #24283b;
--owlry-border: #414868;
--owlry-text: #c0caf5;
--owlry-text-secondary: #565f89;
--owlry-accent: #e0af68;
--owlry-accent-bright: #f5e0dc;
--owlry-badge-app: #7aa2f7;
--owlry-badge-cmd: #bb9af7;
--owlry-badge-dmenu: #9ece6a;
--owlry-badge-uuctl: #e0af68;
}
.owlry-main {
background-color: rgba(26, 27, 38, 0.95);
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);
}
.owlry-search {
background-color: rgba(36, 40, 59, 0.8);
border: 2px solid rgba(65, 72, 104, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(224, 175, 104, 0.2);
}
.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 var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(122, 162, 247, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(187, 154, 247, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(158, 206, 106, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(224, 175, 104, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(224, 175, 104, 0.2);
color: var(--owlry-accent);
border-color: rgba(224, 175, 104, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(122, 162, 247, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(122, 162, 247, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(187, 154, 247, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(187, 154, 247, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(224, 175, 104, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(224, 175, 104, 0.4);
}
scrollbar slider {
background-color: rgba(65, 72, 104, 0.5);
}
scrollbar slider:hover {
background-color: rgba(86, 95, 137, 0.7);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

114
data/themes/rose-pine.css Normal file
View File

@@ -0,0 +1,114 @@
/*
* Owlry - Rosé Pine Theme
* All natural pine, faux fur and a bit of soho vibes
* https://rosepinetheme.com/
*
* Usage: Copy to ~/.config/owlry/themes/rose-pine.css
* Set theme = "rose-pine" in config.toml
*/
:root {
--owlry-bg: #191724;
--owlry-bg-secondary: #1f1d2e;
--owlry-border: #26233a;
--owlry-text: #e0def4;
--owlry-text-secondary: #908caa;
--owlry-accent: #c4a7e7;
--owlry-accent-bright: #ebbcba;
--owlry-badge-app: #9ccfd8;
--owlry-badge-cmd: #c4a7e7;
--owlry-badge-dmenu: #f6c177;
--owlry-badge-uuctl: #eb6f92;
}
.owlry-main {
background-color: rgba(25, 23, 36, 0.95);
border: 1px solid rgba(38, 35, 58, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(196, 167, 231, 0.1);
}
.owlry-search {
background-color: rgba(31, 29, 46, 0.8);
border: 2px solid rgba(38, 35, 58, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(196, 167, 231, 0.2);
}
.owlry-result-row:hover {
background-color: rgba(31, 29, 46, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(196, 167, 231, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(156, 207, 216, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(196, 167, 231, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(246, 193, 119, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(235, 111, 146, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(196, 167, 231, 0.2);
color: var(--owlry-accent);
border-color: rgba(196, 167, 231, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(156, 207, 216, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(156, 207, 216, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(196, 167, 231, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(196, 167, 231, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(235, 111, 146, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(235, 111, 146, 0.4);
}
scrollbar slider {
background-color: rgba(38, 35, 58, 0.5);
}
scrollbar slider:hover {
background-color: rgba(144, 140, 170, 0.5);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

View File

@@ -0,0 +1,114 @@
/*
* Owlry - Solarized Dark Theme
* Precision colors for machines and people
* https://ethanschoonover.com/solarized/
*
* Usage: Copy to ~/.config/owlry/themes/solarized-dark.css
* Set theme = "solarized-dark" in config.toml
*/
:root {
--owlry-bg: #002b36;
--owlry-bg-secondary: #073642;
--owlry-border: #586e75;
--owlry-text: #839496;
--owlry-text-secondary: #657b83;
--owlry-accent: #268bd2;
--owlry-accent-bright: #2aa198;
--owlry-badge-app: #859900;
--owlry-badge-cmd: #268bd2;
--owlry-badge-dmenu: #b58900;
--owlry-badge-uuctl: #dc322f;
}
.owlry-main {
background-color: rgba(0, 43, 54, 0.95);
border: 1px solid rgba(88, 110, 117, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(38, 139, 210, 0.1);
}
.owlry-search {
background-color: rgba(7, 54, 66, 0.8);
border: 2px solid rgba(88, 110, 117, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(38, 139, 210, 0.2);
}
.owlry-result-row:hover {
background-color: rgba(7, 54, 66, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(38, 139, 210, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(133, 153, 0, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(38, 139, 210, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(181, 137, 0, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(220, 50, 47, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(38, 139, 210, 0.2);
color: var(--owlry-accent);
border-color: rgba(38, 139, 210, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(133, 153, 0, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(133, 153, 0, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(38, 139, 210, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(38, 139, 210, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(220, 50, 47, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(220, 50, 47, 0.4);
}
scrollbar slider {
background-color: rgba(88, 110, 117, 0.5);
}
scrollbar slider:hover {
background-color: rgba(101, 123, 131, 0.7);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

114
data/themes/tokyo-night.css Normal file
View File

@@ -0,0 +1,114 @@
/*
* Owlry - Tokyo Night Theme
* A clean, dark theme that celebrates the lights of Tokyo at night
* https://github.com/enkia/tokyo-night-vscode-theme
*
* Usage: Copy to ~/.config/owlry/themes/tokyo-night.css
* Set theme = "tokyo-night" in config.toml
*/
:root {
--owlry-bg: #1a1b26;
--owlry-bg-secondary: #24283b;
--owlry-border: #414868;
--owlry-text: #c0caf5;
--owlry-text-secondary: #565f89;
--owlry-accent: #7aa2f7;
--owlry-accent-bright: #bb9af7;
--owlry-badge-app: #9ece6a;
--owlry-badge-cmd: #7dcfff;
--owlry-badge-dmenu: #e0af68;
--owlry-badge-uuctl: #f7768e;
}
.owlry-main {
background-color: rgba(26, 27, 38, 0.95);
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(122, 162, 247, 0.1);
}
.owlry-search {
background-color: rgba(36, 40, 59, 0.8);
border: 2px solid rgba(65, 72, 104, 0.5);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.2);
}
.owlry-result-row:hover {
background-color: rgba(36, 40, 59, 0.6);
}
.owlry-result-row:selected {
background-color: rgba(122, 162, 247, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
.owlry-badge-app {
background-color: rgba(158, 206, 106, 0.2);
color: var(--owlry-badge-app);
}
.owlry-badge-cmd {
background-color: rgba(125, 207, 255, 0.2);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(224, 175, 104, 0.2);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-uuctl {
background-color: rgba(247, 118, 142, 0.2);
color: var(--owlry-badge-uuctl);
}
.owlry-filter-button:checked {
background-color: rgba(122, 162, 247, 0.2);
color: var(--owlry-accent);
border-color: rgba(122, 162, 247, 0.4);
}
.owlry-filter-app:checked {
background-color: rgba(158, 206, 106, 0.2);
color: var(--owlry-badge-app);
border-color: rgba(158, 206, 106, 0.4);
}
.owlry-filter-cmd:checked {
background-color: rgba(125, 207, 255, 0.2);
color: var(--owlry-badge-cmd);
border-color: rgba(125, 207, 255, 0.4);
}
.owlry-filter-uuctl:checked {
background-color: rgba(247, 118, 142, 0.2);
color: var(--owlry-badge-uuctl);
border-color: rgba(247, 118, 142, 0.4);
}
scrollbar slider {
background-color: rgba(65, 72, 104, 0.5);
}
scrollbar slider:hover {
background-color: rgba(86, 95, 137, 0.7);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}

View File

@@ -1,6 +1,8 @@
use crate::cli::CliArgs;
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::paths;
use crate::providers::ProviderManager;
use crate::theme;
use crate::ui::MainWindow;
@@ -30,14 +32,19 @@ impl OwlryApp {
}
pub fn run(&self) -> i32 {
self.app.run().into()
// Use empty args since clap already parsed our CLI arguments.
// This prevents GTK from trying to parse --mode, --providers, etc.
self.app.run_with_args(&[] as &[&str]).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()));
let search_engine = config.borrow().providers.search_engine.clone();
let terminal = config.borrow().general.terminal_command.clone();
let providers = Rc::new(RefCell::new(ProviderManager::with_config(&search_engine, &terminal)));
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
// Create filter from CLI args and config
let filter = ProviderFilter::new(
@@ -47,7 +54,7 @@ impl OwlryApp {
);
let filter = Rc::new(RefCell::new(filter));
let window = MainWindow::new(app, config.clone(), providers.clone(), filter.clone());
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone());
// Set up layer shell for Wayland overlay behavior
window.init_layer_shell();
@@ -75,7 +82,7 @@ impl OwlryApp {
// 1. Load base structural CSS (always applied)
let base_provider = CssProvider::new();
base_provider.load_from_string(include_str!("../resources/base.css"));
base_provider.load_from_string(include_str!("resources/base.css"));
gtk4::style_context_add_provider_for_display(
&display,
&base_provider,
@@ -88,14 +95,12 @@ impl OwlryApp {
let theme_provider = CssProvider::new();
match theme_name.as_str() {
"owl" => {
theme_provider.load_from_string(include_str!("../resources/owl-theme.css"));
theme_provider.load_from_string(include_str!("resources/owl-theme.css"));
debug!("Loaded built-in owl theme");
}
_ => {
// Check for custom theme in ~/.config/owlry/themes/{name}.css
if let Some(theme_path) = dirs::config_dir()
.map(|p| p.join("owlry").join("themes").join(format!("{}.css", theme_name)))
{
// Check for custom theme in $XDG_CONFIG_HOME/owlry/themes/{name}.css
if let Some(theme_path) = paths::theme_file(theme_name) {
if theme_path.exists() {
theme_provider.load_from_path(&theme_path);
debug!("Loaded custom theme from {:?}", theme_path);
@@ -113,7 +118,7 @@ impl OwlryApp {
}
// 3. Load user's custom stylesheet if exists
if let Some(custom_path) = dirs::config_dir().map(|p| p.join("owlry").join("style.css")) {
if let Some(custom_path) = paths::custom_style_file() {
if custom_path.exists() {
let custom_provider = CssProvider::new();
custom_provider.load_from_path(&custom_path);

View File

@@ -1,7 +1,9 @@
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Command;
use log::{info, warn, debug};
use crate::paths;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
@@ -15,6 +17,23 @@ pub struct GeneralConfig {
pub show_icons: bool,
pub max_results: usize,
pub terminal_command: String,
/// Launch wrapper command for app execution.
/// Examples: "uwsm app --", "hyprctl dispatch exec --", "systemd-run --user --"
/// If None or empty, launches directly via sh -c
#[serde(default)]
pub launch_wrapper: Option<String>,
/// Provider tabs shown in the header bar.
/// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
#[serde(default = "default_tabs")]
pub tabs: Vec<String>,
}
fn default_tabs() -> Vec<String> {
vec![
"app".to_string(),
"cmd".to_string(),
"uuctl".to_string(),
]
}
/// User-customizable theme colors
@@ -30,9 +49,18 @@ pub struct ThemeColors {
pub accent_bright: Option<String>,
// Provider badge colors
pub badge_app: Option<String>,
pub badge_bookmark: Option<String>,
pub badge_calc: Option<String>,
pub badge_clip: Option<String>,
pub badge_cmd: Option<String>,
pub badge_dmenu: Option<String>,
pub badge_emoji: Option<String>,
pub badge_file: Option<String>,
pub badge_script: Option<String>,
pub badge_ssh: Option<String>,
pub badge_sys: Option<String>,
pub badge_uuctl: Option<String>,
pub badge_web: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -54,6 +82,82 @@ pub struct ProvidersConfig {
pub applications: bool,
pub commands: bool,
pub uuctl: bool,
/// Enable calculator provider (= expression or calc expression)
#[serde(default = "default_true")]
pub calculator: bool,
/// Enable frecency-based result ranking
#[serde(default = "default_true")]
pub frecency: bool,
/// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost)
#[serde(default = "default_frecency_weight")]
pub frecency_weight: f64,
/// Enable web search provider (? query or web query)
#[serde(default = "default_true")]
pub websearch: bool,
/// Search engine for web search
/// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
/// Or custom URL with {query} placeholder
#[serde(default = "default_search_engine")]
pub search_engine: String,
/// Enable system commands (shutdown, reboot, etc.)
#[serde(default = "default_true")]
pub system: bool,
/// Enable SSH connections from ~/.ssh/config
#[serde(default = "default_true")]
pub ssh: bool,
/// Enable clipboard history (requires cliphist)
#[serde(default = "default_true")]
pub clipboard: bool,
/// Enable browser bookmarks
#[serde(default = "default_true")]
pub bookmarks: bool,
/// Enable emoji picker
#[serde(default = "default_true")]
pub emoji: bool,
/// Enable custom scripts from ~/.config/owlry/scripts/
#[serde(default = "default_true")]
pub scripts: bool,
/// Enable file search (requires fd or locate)
#[serde(default = "default_true")]
pub files: bool,
}
fn default_search_engine() -> String {
"duckduckgo".to_string()
}
fn default_true() -> bool {
true
}
fn default_frecency_weight() -> f64 {
0.3
}
/// Detect the best launch wrapper for the current session
/// Checks for uwsm (Universal Wayland Session Manager) and hyprland
fn detect_launch_wrapper() -> Option<String> {
// Check if running under uwsm (has UWSM_FINALIZE_VARNAMES or similar uwsm env vars)
if std::env::var("UWSM_FINALIZE_VARNAMES").is_ok()
|| std::env::var("__UWSM_SELECT_TAG").is_ok()
{
if command_exists("uwsm") {
debug!("Detected uwsm session, using 'uwsm app --' wrapper");
return Some("uwsm app --".to_string());
}
}
// Check if running under Hyprland
if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() {
if command_exists("hyprctl") {
debug!("Detected Hyprland session, using 'hyprctl dispatch exec --' wrapper");
return Some("hyprctl dispatch exec --".to_string());
}
}
// No wrapper needed for other environments
debug!("No launch wrapper detected, using direct execution");
None
}
/// Detect the best available terminal emulator
@@ -127,6 +231,8 @@ impl Default for Config {
show_icons: true,
max_results: 10,
terminal_command: terminal,
launch_wrapper: detect_launch_wrapper(),
tabs: default_tabs(),
},
appearance: AppearanceConfig {
width: 600,
@@ -140,6 +246,18 @@ impl Default for Config {
applications: true,
commands: true,
uuctl: true,
calculator: true,
frecency: true,
frecency_weight: 0.3,
websearch: true,
search_engine: "duckduckgo".to_string(),
system: true,
ssh: true,
clipboard: true,
bookmarks: true,
emoji: true,
scripts: true,
files: true,
},
}
}
@@ -147,7 +265,7 @@ impl Default for Config {
impl Config {
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("owlry").join("config.toml"))
paths::config_file()
}
pub fn load_or_default() -> Self {
@@ -186,9 +304,7 @@ impl Config {
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
paths::ensure_parent_dir(&path)?;
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;

219
src/data/frecency.rs Normal file
View File

@@ -0,0 +1,219 @@
use chrono::{DateTime, Utc};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::paths;
/// A single frecency entry tracking launch count and recency
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyEntry {
pub launch_count: u32,
pub last_launch: DateTime<Utc>,
}
/// Persistent frecency data store
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyData {
pub version: u32,
pub entries: HashMap<String, FrecencyEntry>,
}
impl Default for FrecencyData {
fn default() -> Self {
Self {
version: 1,
entries: HashMap::new(),
}
}
}
/// Frecency store for tracking and boosting recently/frequently used items
pub struct FrecencyStore {
data: FrecencyData,
path: PathBuf,
dirty: bool,
}
impl FrecencyStore {
/// Create a new frecency store, loading existing data if available
pub fn new() -> Self {
let path = Self::data_path();
let data = Self::load_from_path(&path).unwrap_or_default();
info!("Frecency store loaded with {} entries", data.entries.len());
Self {
data,
path,
dirty: false,
}
}
/// Alias for new() - loads from disk or creates default
pub fn load_or_default() -> Self {
Self::new()
}
/// Get the path to the frecency data file
fn data_path() -> PathBuf {
paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json"))
}
/// Load frecency data from a file
fn load_from_path(path: &PathBuf) -> Option<FrecencyData> {
if !path.exists() {
debug!("Frecency file not found at {:?}", path);
return None;
}
let content = std::fs::read_to_string(path).ok()?;
match serde_json::from_str(&content) {
Ok(data) => Some(data),
Err(e) => {
warn!("Failed to parse frecency data: {}", e);
None
}
}
}
/// Save frecency data to disk
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if !self.dirty {
return Ok(());
}
paths::ensure_parent_dir(&self.path)?;
let content = serde_json::to_string_pretty(&self.data)?;
std::fs::write(&self.path, content)?;
self.dirty = false;
debug!("Frecency data saved to {:?}", self.path);
Ok(())
}
/// Record a launch event for an item
pub fn record_launch(&mut self, item_id: &str) {
let now = Utc::now();
let entry = self
.data
.entries
.entry(item_id.to_string())
.or_insert(FrecencyEntry {
launch_count: 0,
last_launch: now,
});
entry.launch_count += 1;
entry.last_launch = now;
self.dirty = true;
debug!(
"Recorded launch for '{}': count={}, last={}",
item_id, entry.launch_count, entry.last_launch
);
// Auto-save after recording
if let Err(e) = self.save() {
warn!("Failed to save frecency data: {}", e);
}
}
/// Calculate frecency score for an item
/// Uses Firefox-style algorithm: score = launch_count * recency_weight
pub fn get_score(&self, item_id: &str) -> f64 {
match self.data.entries.get(item_id) {
Some(entry) => Self::calculate_frecency(entry.launch_count, entry.last_launch),
None => 0.0,
}
}
/// Calculate frecency using Firefox-style algorithm
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
let now = Utc::now();
let age = now.signed_duration_since(last_launch);
let age_days = age.num_hours() as f64 / 24.0;
// Recency weight based on how recently the item was used
let recency_weight = if age_days < 1.0 {
100.0 // Today
} else if age_days < 7.0 {
70.0 // This week
} else if age_days < 30.0 {
50.0 // This month
} else if age_days < 90.0 {
30.0 // This quarter
} else {
10.0 // Older
};
launch_count as f64 * recency_weight
}
/// Get all entries (for debugging/display)
#[allow(dead_code)]
pub fn entries(&self) -> &HashMap<String, FrecencyEntry> {
&self.data.entries
}
/// Clear all frecency data
#[allow(dead_code)]
pub fn clear(&mut self) {
self.data.entries.clear();
self.dirty = true;
}
}
impl Default for FrecencyStore {
fn default() -> Self {
Self::new()
}
}
impl Drop for FrecencyStore {
fn drop(&mut self) {
// Attempt to save on drop
if let Err(e) = self.save() {
warn!("Failed to save frecency data on drop: {}", e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frecency_calculation() {
let now = Utc::now();
// Recent launch should have high score
let score_today = FrecencyStore::calculate_frecency(10, now);
assert!(score_today > 900.0); // 10 * 100
// Older launch should have lower score
let week_ago = now - chrono::Duration::days(5);
let score_week = FrecencyStore::calculate_frecency(10, week_ago);
assert!(score_week < score_today);
assert!(score_week > 600.0); // 10 * 70
// Much older launch
let month_ago = now - chrono::Duration::days(45);
let score_month = FrecencyStore::calculate_frecency(10, month_ago);
assert!(score_month < score_week);
}
#[test]
fn test_launch_count_matters() {
let now = Utc::now();
let score_few = FrecencyStore::calculate_frecency(2, now);
let score_many = FrecencyStore::calculate_frecency(20, now);
assert!(score_many > score_few);
assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x
}
}

3
src/data/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod frecency;
pub use frecency::FrecencyStore;

View File

@@ -1,5 +1,8 @@
use std::collections::HashSet;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::config::ProvidersConfig;
use crate::providers::ProviderType;
@@ -14,6 +17,7 @@ pub struct ProviderFilter {
#[derive(Debug, Clone)]
pub struct ParsedQuery {
pub prefix: Option<ProviderType>,
pub tag_filter: Option<String>,
pub query: String,
}
@@ -42,6 +46,26 @@ impl ProviderFilter {
if config_providers.uuctl {
set.insert(ProviderType::Uuctl);
}
if config_providers.system {
set.insert(ProviderType::System);
}
if config_providers.ssh {
set.insert(ProviderType::Ssh);
}
if config_providers.clipboard {
set.insert(ProviderType::Clipboard);
}
if config_providers.bookmarks {
set.insert(ProviderType::Bookmarks);
}
if config_providers.emoji {
set.insert(ProviderType::Emoji);
}
if config_providers.scripts {
set.insert(ProviderType::Scripts);
}
// Note: Files, Calculator, WebSearch are dynamic providers
// that don't need to be in the filter set - they're triggered by prefix
// Default to apps if nothing enabled
if set.is_empty() {
set.insert(ProviderType::Application);
@@ -49,10 +73,15 @@ impl ProviderFilter {
set
};
Self {
let filter = Self {
enabled,
active_prefix: None,
}
};
#[cfg(feature = "dev-logging")]
debug!("[Filter] Created with enabled providers: {:?}", filter.enabled);
filter
}
/// Default filter: apps only
@@ -72,8 +101,12 @@ impl ProviderFilter {
if self.enabled.is_empty() {
self.enabled.insert(ProviderType::Application);
}
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled);
} else {
self.enabled.insert(provider);
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled ON {:?}, enabled: {:?}", provider, self.enabled);
}
}
@@ -98,6 +131,10 @@ impl ProviderFilter {
/// Set prefix mode (from :app, :cmd, etc.)
pub fn set_prefix(&mut self, prefix: Option<ProviderType>) {
#[cfg(feature = "dev-logging")]
if self.active_prefix != prefix {
debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix);
}
self.active_prefix = prefix;
}
@@ -125,19 +162,66 @@ impl ProviderFilter {
pub fn parse_query(query: &str) -> ParsedQuery {
let trimmed = query.trim_start();
// Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX"
if let Some(rest) = trimmed.strip_prefix(":tag:") {
// Find the end of the tag (space or end of string)
if let Some(space_idx) = rest.find(' ') {
let tag = rest[..space_idx].to_lowercase();
let query_part = rest[space_idx + 1..].to_string();
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part);
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: query_part,
};
} else {
// Just the tag, no query yet
let tag = rest.to_lowercase();
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: String::new(),
};
}
}
// Check for prefix patterns (with trailing space)
let prefixes = [
(":app ", ProviderType::Application),
(":apps ", ProviderType::Application),
(":bm ", ProviderType::Bookmarks),
(":bookmark ", ProviderType::Bookmarks),
(":bookmarks ", ProviderType::Bookmarks),
(":calc ", ProviderType::Calculator),
(":calculator ", ProviderType::Calculator),
(":clip ", ProviderType::Clipboard),
(":clipboard ", ProviderType::Clipboard),
(":cmd ", ProviderType::Command),
(":command ", ProviderType::Command),
(":emoji ", ProviderType::Emoji),
(":emojis ", ProviderType::Emoji),
(":file ", ProviderType::Files),
(":files ", ProviderType::Files),
(":find ", ProviderType::Files),
(":script ", ProviderType::Scripts),
(":scripts ", ProviderType::Scripts),
(":ssh ", ProviderType::Ssh),
(":sys ", ProviderType::System),
(":system ", ProviderType::System),
(":power ", ProviderType::System),
(":uuctl ", ProviderType::Uuctl),
(":web ", ProviderType::WebSearch),
(":search ", ProviderType::WebSearch),
];
for (prefix_str, provider) in prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
@@ -147,24 +231,53 @@ impl ProviderFilter {
let partial_prefixes = [
(":app", ProviderType::Application),
(":apps", ProviderType::Application),
(":bm", ProviderType::Bookmarks),
(":bookmark", ProviderType::Bookmarks),
(":bookmarks", ProviderType::Bookmarks),
(":calc", ProviderType::Calculator),
(":calculator", ProviderType::Calculator),
(":clip", ProviderType::Clipboard),
(":clipboard", ProviderType::Clipboard),
(":cmd", ProviderType::Command),
(":command", ProviderType::Command),
(":emoji", ProviderType::Emoji),
(":emojis", ProviderType::Emoji),
(":file", ProviderType::Files),
(":files", ProviderType::Files),
(":find", ProviderType::Files),
(":script", ProviderType::Scripts),
(":scripts", ProviderType::Scripts),
(":ssh", ProviderType::Ssh),
(":sys", ProviderType::System),
(":system", ProviderType::System),
(":power", ProviderType::System),
(":uuctl", ProviderType::Uuctl),
(":web", ProviderType::WebSearch),
(":search", ProviderType::WebSearch),
];
for (prefix_str, provider) in partial_prefixes {
if trimmed == prefix_str {
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: String::new(),
};
}
}
ParsedQuery {
let result = ParsedQuery {
prefix: None,
tag_filter: None,
query: query.to_string(),
}
};
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query);
result
}
/// Get enabled providers for UI display (sorted)
@@ -172,9 +285,18 @@ impl ProviderFilter {
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,
ProviderType::Bookmarks => 1,
ProviderType::Calculator => 2,
ProviderType::Clipboard => 3,
ProviderType::Command => 4,
ProviderType::Dmenu => 5,
ProviderType::Emoji => 6,
ProviderType::Files => 7,
ProviderType::Scripts => 8,
ProviderType::Ssh => 9,
ProviderType::System => 10,
ProviderType::Uuctl => 11,
ProviderType::WebSearch => 12,
});
providers
}
@@ -184,9 +306,18 @@ impl ProviderFilter {
if let Some(prefix) = self.active_prefix {
return match prefix {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands",
ProviderType::Uuctl => "uuctl",
ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
};
}
@@ -194,9 +325,18 @@ impl ProviderFilter {
if enabled.len() == 1 {
match enabled[0] {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands",
ProviderType::Uuctl => "uuctl",
ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
}
} else {
"All"

View File

@@ -1,22 +1,51 @@
mod app;
mod cli;
mod config;
mod data;
mod filter;
mod paths;
mod providers;
mod theme;
mod ui;
use app::OwlryApp;
use cli::CliArgs;
use log::info;
use log::{info, warn};
#[cfg(feature = "dev-logging")]
use log::debug;
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let default_level = if cfg!(feature = "dev-logging") { "debug" } else { "info" };
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level))
.format_timestamp_millis()
.init();
let args = CliArgs::parse_args();
#[cfg(feature = "dev-logging")]
{
debug!("┌─────────────────────────────────────────┐");
debug!("│ DEV-LOGGING: Verbose output enabled │");
debug!("└─────────────────────────────────────────┘");
debug!("CLI args: {:?}", args);
}
info!("Starting Owlry launcher");
// Diagnostic: log critical environment variables
let home = std::env::var("HOME").unwrap_or_else(|_| "<not set>".to_string());
let path = std::env::var("PATH").unwrap_or_else(|_| "<not set>".to_string());
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| "<not set>".to_string());
info!("HOME={}", home);
info!("PATH={}", path);
info!("XDG_DATA_HOME={}", xdg_data);
if home == "<not set>" || path == "<not set>" {
warn!("Critical environment variables missing! Items may not load correctly.");
}
let app = OwlryApp::new(args);
std::process::exit(app.run());
}

214
src/paths.rs Normal file
View File

@@ -0,0 +1,214 @@
//! Centralized path handling following XDG Base Directory Specification.
//!
//! XDG directories used:
//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css)
//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json)
//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use)
//!
//! See: https://specifications.freedesktop.org/basedir-spec/latest/
use std::path::PathBuf;
/// Application name used in XDG paths
const APP_NAME: &str = "owlry";
// =============================================================================
// XDG Base Directories
// =============================================================================
/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config`
pub fn config_home() -> Option<PathBuf> {
dirs::config_dir()
}
/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share`
pub fn data_home() -> Option<PathBuf> {
dirs::data_dir()
}
/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache`
#[allow(dead_code)]
pub fn cache_home() -> Option<PathBuf> {
dirs::cache_dir()
}
/// Get user home directory
pub fn home() -> Option<PathBuf> {
dirs::home_dir()
}
// =============================================================================
// Owlry-specific directories
// =============================================================================
/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/`
pub fn owlry_config_dir() -> Option<PathBuf> {
config_home().map(|p| p.join(APP_NAME))
}
/// Owlry data directory: `$XDG_DATA_HOME/owlry/`
pub fn owlry_data_dir() -> Option<PathBuf> {
data_home().map(|p| p.join(APP_NAME))
}
/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/`
#[allow(dead_code)]
pub fn owlry_cache_dir() -> Option<PathBuf> {
cache_home().map(|p| p.join(APP_NAME))
}
// =============================================================================
// Config files
// =============================================================================
/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml`
pub fn config_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("config.toml"))
}
/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css`
pub fn custom_style_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("style.css"))
}
/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/`
pub fn themes_dir() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("themes"))
}
/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css`
pub fn theme_file(name: &str) -> Option<PathBuf> {
themes_dir().map(|p| p.join(format!("{}.css", name)))
}
// =============================================================================
// Data files
// =============================================================================
/// User scripts directory: `$XDG_DATA_HOME/owlry/scripts/`
pub fn scripts_dir() -> Option<PathBuf> {
owlry_data_dir().map(|p| p.join("scripts"))
}
/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json`
pub fn frecency_file() -> Option<PathBuf> {
owlry_data_dir().map(|p| p.join("frecency.json"))
}
// =============================================================================
// System directories
// =============================================================================
/// System data directories for applications (XDG_DATA_DIRS)
pub fn system_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
// User data directory first
if let Some(data) = data_home() {
dirs.push(data.join("applications"));
}
// System directories
dirs.push(PathBuf::from("/usr/share/applications"));
dirs.push(PathBuf::from("/usr/local/share/applications"));
// Flatpak directories
if let Some(data) = data_home() {
dirs.push(data.join("flatpak/exports/share/applications"));
}
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
dirs
}
// =============================================================================
// External application paths
// =============================================================================
/// SSH config file: `~/.ssh/config`
pub fn ssh_config() -> Option<PathBuf> {
home().map(|p| p.join(".ssh").join("config"))
}
/// Firefox profile directory: `~/.mozilla/firefox/`
pub fn firefox_dir() -> Option<PathBuf> {
home().map(|p| p.join(".mozilla").join("firefox"))
}
/// Chromium-based browser bookmark paths (using XDG config where browsers support it)
pub fn chromium_bookmark_paths() -> Vec<PathBuf> {
let config = match config_home() {
Some(c) => c,
None => return Vec::new(),
};
vec![
// Google Chrome
config.join("google-chrome/Default/Bookmarks"),
// Chromium
config.join("chromium/Default/Bookmarks"),
// Brave
config.join("BraveSoftware/Brave-Browser/Default/Bookmarks"),
// Microsoft Edge
config.join("microsoft-edge/Default/Bookmarks"),
// Vivaldi
config.join("vivaldi/Default/Bookmarks"),
]
}
// =============================================================================
// Helper functions
// =============================================================================
/// Ensure a directory exists, creating it if necessary
pub fn ensure_dir(path: &PathBuf) -> std::io::Result<()> {
if !path.exists() {
std::fs::create_dir_all(path)?;
}
Ok(())
}
/// Ensure parent directory of a file exists
pub fn ensure_parent_dir(path: &PathBuf) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paths_are_consistent() {
// All owlry paths should be under XDG directories
if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) {
assert!(config.ends_with("owlry"));
assert!(data.ends_with("owlry"));
}
}
#[test]
fn test_config_file_path() {
if let Some(path) = config_file() {
assert!(path.ends_with("config.toml"));
assert!(path.to_string_lossy().contains("owlry"));
}
}
#[test]
fn test_frecency_in_data_dir() {
if let Some(path) = frecency_file() {
assert!(path.ends_with("frecency.json"));
// Should be in data dir, not config dir
let path_str = path.to_string_lossy();
assert!(
path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"),
"frecency should be in data directory"
);
}
}
}

View File

@@ -1,7 +1,70 @@
use super::{LaunchItem, Provider, ProviderType};
use crate::paths;
use freedesktop_desktop_entry::{DesktopEntry, Iter};
use log::{debug, warn};
use std::path::PathBuf;
/// Clean desktop file field codes from command string.
/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes
/// while preserving quoted arguments and %% (literal percent).
/// See: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html
fn clean_desktop_exec_field(cmd: &str) -> String {
let mut result = String::with_capacity(cmd.len());
let mut chars = cmd.chars().peekable();
let mut in_single_quote = false;
let mut in_double_quote = false;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
result.push(c);
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
result.push(c);
}
'%' if !in_single_quote => {
// Check the next character for field code
if let Some(&next) = chars.peek() {
match next {
// Standard field codes to remove (with following space if present)
'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v'
| 'm' => {
chars.next(); // consume the field code letter
// Skip trailing whitespace after the field code
while chars.peek() == Some(&' ') {
chars.next();
}
}
// %% is escaped percent, output single %
'%' => {
chars.next();
result.push('%');
}
// Unknown % sequence, keep as-is
_ => {
result.push('%');
}
}
} else {
// % at end of string, keep it
result.push('%');
}
}
_ => {
result.push(c);
}
}
}
// Clean up any double spaces that may have resulted from removing field codes
let mut cleaned = result.trim().to_string();
while cleaned.contains(" ") {
cleaned = cleaned.replace(" ", " ");
}
cleaned
}
pub struct ApplicationProvider {
items: Vec<LaunchItem>,
@@ -12,25 +75,8 @@ impl ApplicationProvider {
Self { items: Vec::new() }
}
fn get_application_dirs() -> Vec<PathBuf> {
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
fn get_application_dirs() -> Vec<std::path::PathBuf> {
paths::system_data_dirs()
}
}
@@ -85,16 +131,16 @@ impl Provider for ApplicationProvider {
};
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::<Vec<_>>()
.join(" ")
}
Some(e) => clean_desktop_exec_field(e),
None => continue,
};
// Extract categories as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry
.categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
@@ -103,6 +149,7 @@ impl Provider for ApplicationProvider {
provider: ProviderType::Application,
command: run_cmd,
terminal: desktop_entry.terminal(),
tags,
};
self.items.push(item);
@@ -118,3 +165,49 @@ impl Provider for ApplicationProvider {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_desktop_exec_simple() {
assert_eq!(clean_desktop_exec_field("firefox"), "firefox");
assert_eq!(clean_desktop_exec_field("firefox %u"), "firefox");
assert_eq!(clean_desktop_exec_field("code %F"), "code");
}
#[test]
fn test_clean_desktop_exec_multiple_placeholders() {
assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app");
assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other");
}
#[test]
fn test_clean_desktop_exec_preserves_quotes() {
// Double quotes preserve spacing but field codes are still processed
assert_eq!(
clean_desktop_exec_field(r#"bash -c "echo hello""#),
r#"bash -c "echo hello""#
);
// Field codes in double quotes are stripped (per FreeDesktop spec: undefined behavior,
// but practical implementations strip them)
assert_eq!(
clean_desktop_exec_field(r#"bash -c "test %u value""#),
r#"bash -c "test value""#
);
}
#[test]
fn test_clean_desktop_exec_escaped_percent() {
assert_eq!(clean_desktop_exec_field("echo 100%%"), "echo 100%");
}
#[test]
fn test_clean_desktop_exec_single_quotes() {
assert_eq!(
clean_desktop_exec_field("bash -c 'echo %u'"),
"bash -c 'echo %u'"
);
}
}

225
src/providers/bookmarks.rs Normal file
View File

@@ -0,0 +1,225 @@
use crate::paths;
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
/// Browser bookmarks provider - reads Firefox and Chrome bookmarks
pub struct BookmarksProvider {
items: Vec<LaunchItem>,
}
impl BookmarksProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn load_bookmarks(&mut self) {
self.items.clear();
// Try Firefox first, then Chrome/Chromium
self.load_firefox_bookmarks();
self.load_chrome_bookmarks();
debug!("Loaded {} bookmarks total", self.items.len());
}
fn load_firefox_bookmarks(&mut self) {
// Firefox stores bookmarks in places.sqlite
// The file is locked when Firefox is running, so we read from backup
let firefox_dir = match paths::firefox_dir() {
Some(d) => d,
None => return,
};
if !firefox_dir.exists() {
debug!("Firefox directory not found");
return;
}
// Find default profile (ends with .default-release or .default)
let profile_dir = match Self::find_firefox_profile(&firefox_dir) {
Some(p) => p,
None => {
debug!("No Firefox profile found");
return;
}
};
// Try to read bookmarkbackups (JSON format, not locked)
let backup_dir = profile_dir.join("bookmarkbackups");
if backup_dir.exists() {
if let Some(latest_backup) = Self::find_latest_file(&backup_dir, "jsonlz4") {
// jsonlz4 files need decompression - skip for now, try places.sqlite
debug!("Found Firefox backup at {:?}, but jsonlz4 not supported", latest_backup);
}
}
// Try places.sqlite directly (may fail if Firefox is running)
let places_db = profile_dir.join("places.sqlite");
if places_db.exists() {
self.read_firefox_places(&places_db);
}
}
fn find_firefox_profile(firefox_dir: &PathBuf) -> Option<PathBuf> {
let entries = fs::read_dir(firefox_dir).ok()?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".default-release") || name.ends_with(".default") {
return Some(entry.path());
}
}
None
}
fn find_latest_file(dir: &PathBuf, extension: &str) -> Option<PathBuf> {
let entries = fs::read_dir(dir).ok()?;
entries
.flatten()
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == extension)
.unwrap_or(false)
})
.max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
.map(|e| e.path())
}
fn read_firefox_places(&mut self, db_path: &PathBuf) {
// Note: This requires the rusqlite crate which we don't have
// For now, skip Firefox SQLite reading
debug!(
"Firefox places.sqlite found at {:?}, but SQLite reading not implemented",
db_path
);
}
fn load_chrome_bookmarks(&mut self) {
// Chrome/Chromium bookmarks are in JSON format (XDG config paths)
for path in paths::chromium_bookmark_paths() {
if path.exists() {
self.read_chrome_bookmarks(&path);
}
}
}
fn read_chrome_bookmarks(&mut self, path: &PathBuf) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read Chrome bookmarks from {:?}: {}", path, e);
return;
}
};
let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) {
Ok(b) => b,
Err(e) => {
warn!("Failed to parse Chrome bookmarks: {}", e);
return;
}
};
// Process bookmark bar and other folders
if let Some(roots) = bookmarks.roots {
if let Some(bar) = roots.bookmark_bar {
self.process_chrome_folder(&bar);
}
if let Some(other) = roots.other {
self.process_chrome_folder(&other);
}
if let Some(synced) = roots.synced {
self.process_chrome_folder(&synced);
}
}
debug!("Loaded Chrome bookmarks from {:?}", path);
}
fn process_chrome_folder(&mut self, folder: &ChromeBookmarkNode) {
if let Some(ref children) = folder.children {
for child in children {
match child.node_type.as_deref() {
Some("url") => {
if let Some(ref url) = child.url {
let name = child.name.clone().unwrap_or_else(|| url.clone());
self.items.push(LaunchItem {
id: format!("bookmark:{}", url),
name,
description: Some(url.clone()),
icon: Some("web-browser".to_string()),
provider: ProviderType::Bookmarks,
command: format!("xdg-open '{}'", url.replace('\'', "'\\''")),
terminal: false,
tags: Vec::new(),
});
}
}
Some("folder") => {
// Recursively process subfolders
self.process_chrome_folder(child);
}
_ => {}
}
}
}
}
}
// Chrome bookmark JSON structures
#[derive(Debug, Deserialize)]
struct ChromeBookmarks {
roots: Option<ChromeBookmarkRoots>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkRoots {
bookmark_bar: Option<ChromeBookmarkNode>,
other: Option<ChromeBookmarkNode>,
synced: Option<ChromeBookmarkNode>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkNode {
name: Option<String>,
url: Option<String>,
#[serde(rename = "type")]
node_type: Option<String>,
children: Option<Vec<ChromeBookmarkNode>>,
}
impl Provider for BookmarksProvider {
fn name(&self) -> &str {
"Bookmarks"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Bookmarks
}
fn refresh(&mut self) {
self.load_bookmarks();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bookmarks_provider() {
let mut provider = BookmarksProvider::new();
provider.refresh();
// Just ensure it doesn't panic
}
}

239
src/providers/calculator.rs Normal file
View File

@@ -0,0 +1,239 @@
use super::{LaunchItem, Provider, ProviderType};
use log::debug;
/// Calculator provider for evaluating math expressions
/// Syntax: `= expression` or `calc expression`
pub struct CalculatorProvider {
/// Cached result from last evaluation
cached_result: Option<LaunchItem>,
}
impl CalculatorProvider {
pub fn new() -> Self {
Self {
cached_result: None,
}
}
/// Check if a query is a calculator expression
pub fn is_calculator_query(query: &str) -> bool {
let trimmed = query.trim();
trimmed.starts_with("=") || trimmed.starts_with("calc ")
}
/// Extract the expression from a calculator query
fn extract_expression(query: &str) -> Option<&str> {
let trimmed = query.trim();
// Support both "= expr" and "=expr" (with or without space)
if let Some(expr) = trimmed.strip_prefix("= ") {
Some(expr.trim())
} else if let Some(expr) = trimmed.strip_prefix("=") {
Some(expr.trim())
} else if let Some(expr) = trimmed.strip_prefix("calc ") {
Some(expr.trim())
} else {
None
}
}
/// Check if string looks like a math expression (for :calc mode)
pub fn looks_like_expression(query: &str) -> bool {
let trimmed = query.trim();
if trimmed.is_empty() {
return false;
}
// Contains math operators or is a number
trimmed.chars().any(|c| "+-*/^()".contains(c))
|| trimmed.parse::<f64>().is_ok()
|| ["pi", "e", "sqrt", "sin", "cos", "tan", "abs", "ln", "log"]
.iter()
.any(|f| trimmed.to_lowercase().contains(f))
}
/// Evaluate a raw expression (for :calc filter mode)
pub fn evaluate_raw(&mut self, expr: &str) -> Option<LaunchItem> {
let trimmed = expr.trim();
if trimmed.is_empty() {
return None;
}
match meval::eval_str(trimmed) {
Ok(result) => {
let result_str = if result.fract() == 0.0 && result.abs() < 1e15 {
format!("{}", result as i64)
} else {
format!("{:.10}", result).trim_end_matches('0').trim_end_matches('.').to_string()
};
Some(LaunchItem {
id: format!("calc:{}", trimmed),
name: format!("{} = {}", trimmed, result_str),
description: Some("Press Enter to copy result".to_string()),
icon: Some("accessories-calculator".to_string()),
provider: ProviderType::Calculator,
command: format!("echo -n '{}' | wl-copy", result_str),
terminal: false,
tags: vec!["math".to_string()],
})
}
Err(_) => None,
}
}
/// Evaluate an expression and return a LaunchItem result
pub fn evaluate(&mut self, query: &str) -> Option<LaunchItem> {
let expr = Self::extract_expression(query)?;
if expr.is_empty() {
return None;
}
debug!("Evaluating expression: {}", expr);
match meval::eval_str(expr) {
Ok(result) => {
// Format result nicely
let result_str = if result.fract() == 0.0 && result.abs() < 1e15 {
// Integer result
format!("{}", result as i64)
} else {
// Float result with reasonable precision
let formatted = format!("{:.10}", result);
// Trim trailing zeros
formatted.trim_end_matches('0').trim_end_matches('.').to_string()
};
let item = LaunchItem {
id: format!("calc:{}", expr),
name: result_str.clone(),
description: Some(format!("= {}", expr)),
icon: Some("accessories-calculator".to_string()),
provider: ProviderType::Calculator,
// Copy result to clipboard using wl-copy
command: format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str),
terminal: false,
tags: vec!["math".to_string()],
};
debug!("Calculator result: {} = {}", expr, result_str);
self.cached_result = Some(item.clone());
Some(item)
}
Err(e) => {
debug!("Calculator error for '{}': {}", expr, e);
None
}
}
}
}
impl Provider for CalculatorProvider {
fn name(&self) -> &str {
"Calculator"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Calculator
}
fn refresh(&mut self) {
// Calculator doesn't need refresh - it evaluates on-demand
self.cached_result = None;
}
fn items(&self) -> &[LaunchItem] {
// Calculator is a dynamic provider - items are generated from query
// Return cached result if available (for UI display)
match &self.cached_result {
Some(item) => std::slice::from_ref(item),
None => &[],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_calculator_query() {
assert!(CalculatorProvider::is_calculator_query("= 5+3"));
assert!(CalculatorProvider::is_calculator_query("calc 5+3"));
assert!(CalculatorProvider::is_calculator_query(" = 5+3"));
assert!(!CalculatorProvider::is_calculator_query("5+3"));
assert!(!CalculatorProvider::is_calculator_query("firefox"));
}
#[test]
fn test_extract_expression() {
assert_eq!(
CalculatorProvider::extract_expression("= 5+3"),
Some("5+3")
);
assert_eq!(
CalculatorProvider::extract_expression("calc 5+3"),
Some("5+3")
);
assert_eq!(
CalculatorProvider::extract_expression("= 5 + 3 "),
Some("5 + 3")
);
assert_eq!(CalculatorProvider::extract_expression("5+3"), None);
}
#[test]
fn test_evaluate_basic() {
let mut calc = CalculatorProvider::new();
let result = calc.evaluate("= 5+3").unwrap();
assert_eq!(result.name, "8");
let result = calc.evaluate("= 10 * 2").unwrap();
assert_eq!(result.name, "20");
let result = calc.evaluate("= 15 / 3").unwrap();
assert_eq!(result.name, "5");
}
#[test]
fn test_evaluate_float() {
let mut calc = CalculatorProvider::new();
let result = calc.evaluate("= 5/2").unwrap();
assert_eq!(result.name, "2.5");
let result = calc.evaluate("= 1/3").unwrap();
assert!(result.name.starts_with("0.333"));
}
#[test]
fn test_evaluate_functions() {
let mut calc = CalculatorProvider::new();
let result = calc.evaluate("= sqrt(16)").unwrap();
assert_eq!(result.name, "4");
let result = calc.evaluate("= abs(-5)").unwrap();
assert_eq!(result.name, "5");
}
#[test]
fn test_evaluate_constants() {
let mut calc = CalculatorProvider::new();
let result = calc.evaluate("= pi").unwrap();
assert!(result.name.starts_with("3.14159"));
let result = calc.evaluate("= e").unwrap();
assert!(result.name.starts_with("2.718"));
}
#[test]
fn test_evaluate_invalid() {
let mut calc = CalculatorProvider::new();
assert!(calc.evaluate("= ").is_none());
assert!(calc.evaluate("= invalid").is_none());
assert!(calc.evaluate("= 5 +").is_none());
}
}

138
src/providers/clipboard.rs Normal file
View File

@@ -0,0 +1,138 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use std::process::Command;
/// Clipboard history provider - integrates with cliphist
pub struct ClipboardProvider {
items: Vec<LaunchItem>,
max_entries: usize,
}
impl ClipboardProvider {
pub fn new() -> Self {
Self {
items: Vec::new(),
max_entries: 50,
}
}
#[allow(dead_code)]
pub fn with_max_entries(max_entries: usize) -> Self {
Self {
items: Vec::new(),
max_entries,
}
}
/// Check if cliphist is available
fn has_cliphist() -> bool {
Command::new("which")
.arg("cliphist")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn load_clipboard_history(&mut self) {
self.items.clear();
if !Self::has_cliphist() {
debug!("cliphist not found, clipboard provider disabled");
return;
}
// Get clipboard history from cliphist
let output = match Command::new("cliphist").arg("list").output() {
Ok(o) => o,
Err(e) => {
warn!("Failed to run cliphist: {}", e);
return;
}
};
if !output.status.success() {
debug!("cliphist list returned non-zero status");
return;
}
let content = String::from_utf8_lossy(&output.stdout);
for (idx, line) in content.lines().take(self.max_entries).enumerate() {
// cliphist format: "id\tpreview"
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.is_empty() {
continue;
}
let clip_id = parts[0];
let preview = if parts.len() > 1 {
// Truncate long previews
let p = parts[1];
if p.len() > 80 {
format!("{}...", &p[..77])
} else {
p.to_string()
}
} else {
"[binary data]".to_string()
};
// Clean up preview - replace newlines with spaces
let preview_clean = preview
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
// Command to paste this entry
// echo "id" | cliphist decode | wl-copy
let command = format!(
"echo '{}' | cliphist decode | wl-copy",
clip_id.replace('\'', "'\\''")
);
self.items.push(LaunchItem {
id: format!("clipboard:{}", idx),
name: preview_clean,
description: Some("Copy to clipboard".to_string()),
icon: Some("edit-paste".to_string()),
provider: ProviderType::Clipboard,
command,
terminal: false,
tags: Vec::new(),
});
}
debug!("Loaded {} clipboard entries", self.items.len());
}
}
impl Provider for ClipboardProvider {
fn name(&self) -> &str {
"Clipboard"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Clipboard
}
fn refresh(&mut self) {
self.load_clipboard_history();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clipboard_provider() {
let mut provider = ClipboardProvider::new();
provider.refresh();
// Just ensure it doesn't panic - cliphist may not be installed
}
}

View File

@@ -87,6 +87,7 @@ impl Provider for CommandProvider {
provider: ProviderType::Command,
command: name,
terminal: false,
tags: Vec::new(),
};
self.items.push(item);

View File

@@ -17,17 +17,37 @@ impl DmenuProvider {
}
/// Check if stdin has data (non-blocking check)
/// Returns true only if stdin is a pipe or regular file with data available.
/// Returns false for TTYs, /dev/null, and other character devices.
pub fn has_stdin_data() -> bool {
use std::os::unix::io::AsRawFd;
let stdin_fd = io::stdin().as_raw_fd();
// First check if stdin is a pipe or regular file (valid dmenu input sources)
// Character devices (TTY, /dev/null) should NOT trigger dmenu mode
let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
let stat_result = unsafe { libc::fstat(stdin_fd, &mut stat_buf) };
if stat_result != 0 {
return false;
}
let mode = stat_buf.st_mode;
let is_pipe = (mode & libc::S_IFMT) == libc::S_IFIFO;
let is_file = (mode & libc::S_IFMT) == libc::S_IFREG;
// Only check for data if stdin is a pipe or file
if !is_pipe && !is_file {
return false;
}
// Non-blocking poll to check if data is available
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
}
@@ -81,6 +101,7 @@ impl Provider for DmenuProvider {
provider: ProviderType::Dmenu,
command: line.to_string(),
terminal: false,
tags: Vec::new(),
};
self.items.push(item);

451
src/providers/emoji.rs Normal file
View File

@@ -0,0 +1,451 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
/// Emoji picker provider - search and copy emojis
pub struct EmojiProvider {
items: Vec<LaunchItem>,
}
impl EmojiProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn load_emojis(&mut self) {
self.items.clear();
// Common emojis with searchable names
// Format: (emoji, name, keywords)
let emojis: &[(&str, &str, &str)] = &[
// Smileys & Emotion
("😀", "grinning face", "smile happy"),
("😃", "grinning face with big eyes", "smile happy"),
("😄", "grinning face with smiling eyes", "smile happy laugh"),
("😁", "beaming face with smiling eyes", "smile happy grin"),
("😅", "grinning face with sweat", "smile nervous"),
("🤣", "rolling on the floor laughing", "lol rofl funny"),
("😂", "face with tears of joy", "laugh cry funny lol"),
("🙂", "slightly smiling face", "smile"),
("😊", "smiling face with smiling eyes", "blush happy"),
("😇", "smiling face with halo", "angel innocent"),
("🥰", "smiling face with hearts", "love adore"),
("😍", "smiling face with heart-eyes", "love crush"),
("🤩", "star-struck", "excited wow amazing"),
("😘", "face blowing a kiss", "kiss love"),
("😜", "winking face with tongue", "playful silly"),
("🤪", "zany face", "crazy silly wild"),
("😎", "smiling face with sunglasses", "cool"),
("🤓", "nerd face", "geek glasses"),
("🧐", "face with monocle", "thinking inspect"),
("😏", "smirking face", "smug"),
("😒", "unamused face", "meh annoyed"),
("🙄", "face with rolling eyes", "whatever annoyed"),
("😬", "grimacing face", "awkward nervous"),
("😮‍💨", "face exhaling", "sigh relief"),
("🤥", "lying face", "pinocchio lie"),
("😌", "relieved face", "relaxed peaceful"),
("😔", "pensive face", "sad thoughtful"),
("😪", "sleepy face", "tired"),
("🤤", "drooling face", "hungry yummy"),
("😴", "sleeping face", "zzz tired"),
("😷", "face with medical mask", "sick covid"),
("🤒", "face with thermometer", "sick fever"),
("🤕", "face with head-bandage", "hurt injured"),
("🤢", "nauseated face", "sick gross"),
("🤮", "face vomiting", "sick puke"),
("🤧", "sneezing face", "achoo sick"),
("🥵", "hot face", "sweating heat"),
("🥶", "cold face", "freezing"),
("😵", "face with crossed-out eyes", "dizzy dead"),
("🤯", "exploding head", "mind blown wow"),
("🤠", "cowboy hat face", "yeehaw western"),
("🥳", "partying face", "celebration party"),
("🥸", "disguised face", "incognito"),
("😎", "cool face", "sunglasses"),
("🤡", "clown face", "circus"),
("👻", "ghost", "halloween spooky"),
("💀", "skull", "dead death"),
("☠️", "skull and crossbones", "danger death"),
("👽", "alien", "ufo extraterrestrial"),
("🤖", "robot", "bot android"),
("💩", "pile of poo", "poop shit"),
("😈", "smiling face with horns", "devil evil"),
("👿", "angry face with horns", "devil evil"),
// Gestures & People
("👋", "waving hand", "hello hi bye wave"),
("🤚", "raised back of hand", "stop"),
("🖐️", "hand with fingers splayed", "five high"),
("", "raised hand", "stop high five"),
("🖖", "vulcan salute", "spock trek"),
("👌", "ok hand", "okay perfect"),
("🤌", "pinched fingers", "italian"),
("🤏", "pinching hand", "small tiny"),
("✌️", "victory hand", "peace two"),
("🤞", "crossed fingers", "luck hope"),
("🤟", "love-you gesture", "ily rock"),
("🤘", "sign of the horns", "rock metal"),
("🤙", "call me hand", "shaka hang loose"),
("👈", "backhand index pointing left", "left point"),
("👉", "backhand index pointing right", "right point"),
("👆", "backhand index pointing up", "up point"),
("👇", "backhand index pointing down", "down point"),
("☝️", "index pointing up", "one point"),
("👍", "thumbs up", "like yes good approve"),
("👎", "thumbs down", "dislike no bad"),
("", "raised fist", "power solidarity"),
("👊", "oncoming fist", "punch bump"),
("🤛", "left-facing fist", "fist bump"),
("🤜", "right-facing fist", "fist bump"),
("👏", "clapping hands", "applause bravo"),
("🙌", "raising hands", "hooray celebrate"),
("👐", "open hands", "hug"),
("🤲", "palms up together", "prayer"),
("🤝", "handshake", "agreement deal"),
("🙏", "folded hands", "prayer please thanks"),
("✍️", "writing hand", "write"),
("💪", "flexed biceps", "strong muscle"),
("🦾", "mechanical arm", "robot prosthetic"),
("🦵", "leg", "kick"),
("🦶", "foot", "kick"),
("👂", "ear", "listen hear"),
("👃", "nose", "smell"),
("🧠", "brain", "smart think"),
("👀", "eyes", "look see watch"),
("👁️", "eye", "see look"),
("👅", "tongue", "taste lick"),
("👄", "mouth", "lips kiss"),
// Hearts & Love
("❤️", "red heart", "love"),
("🧡", "orange heart", "love"),
("💛", "yellow heart", "love friendship"),
("💚", "green heart", "love"),
("💙", "blue heart", "love"),
("💜", "purple heart", "love"),
("🖤", "black heart", "love dark"),
("🤍", "white heart", "love pure"),
("🤎", "brown heart", "love"),
("💔", "broken heart", "heartbreak sad"),
("❤️‍🔥", "heart on fire", "passion love"),
("❤️‍🩹", "mending heart", "healing recovery"),
("💕", "two hearts", "love"),
("💞", "revolving hearts", "love"),
("💓", "beating heart", "love"),
("💗", "growing heart", "love"),
("💖", "sparkling heart", "love"),
("💘", "heart with arrow", "love cupid"),
("💝", "heart with ribbon", "love gift"),
("💟", "heart decoration", "love"),
// Animals
("🐶", "dog face", "puppy"),
("🐱", "cat face", "kitty"),
("🐭", "mouse face", ""),
("🐹", "hamster", ""),
("🐰", "rabbit face", "bunny"),
("🦊", "fox", ""),
("🐻", "bear", ""),
("🐼", "panda", ""),
("🐨", "koala", ""),
("🐯", "tiger face", ""),
("🦁", "lion", ""),
("🐮", "cow face", ""),
("🐷", "pig face", ""),
("🐸", "frog", ""),
("🐵", "monkey face", ""),
("🦄", "unicorn", "magic"),
("🐝", "bee", "honeybee"),
("🦋", "butterfly", ""),
("🐌", "snail", "slow"),
("🐛", "bug", "caterpillar"),
("🦀", "crab", ""),
("🐙", "octopus", ""),
("🐠", "tropical fish", ""),
("🐟", "fish", ""),
("🐬", "dolphin", ""),
("🐳", "whale", ""),
("🦈", "shark", ""),
("🐊", "crocodile", "alligator"),
("🐢", "turtle", ""),
("🦎", "lizard", ""),
("🐍", "snake", ""),
("🦖", "t-rex", "dinosaur"),
("🦕", "sauropod", "dinosaur"),
("🐔", "chicken", ""),
("🐧", "penguin", ""),
("🦅", "eagle", "bird"),
("🦆", "duck", ""),
("🦉", "owl", ""),
// Food & Drink
("🍎", "red apple", "fruit"),
("🍐", "pear", "fruit"),
("🍊", "orange", "tangerine fruit"),
("🍋", "lemon", "fruit"),
("🍌", "banana", "fruit"),
("🍉", "watermelon", "fruit"),
("🍇", "grapes", "fruit"),
("🍓", "strawberry", "fruit"),
("🍒", "cherries", "fruit"),
("🍑", "peach", "fruit"),
("🥭", "mango", "fruit"),
("🍍", "pineapple", "fruit"),
("🥥", "coconut", "fruit"),
("🥝", "kiwi", "fruit"),
("🍅", "tomato", "vegetable"),
("🥑", "avocado", ""),
("🥦", "broccoli", "vegetable"),
("🥬", "leafy green", "vegetable salad"),
("🥒", "cucumber", "vegetable"),
("🌶️", "hot pepper", "spicy chili"),
("🌽", "corn", ""),
("🥕", "carrot", "vegetable"),
("🧄", "garlic", ""),
("🧅", "onion", ""),
("🥔", "potato", ""),
("🍞", "bread", ""),
("🥐", "croissant", ""),
("🥖", "baguette", "bread french"),
("🥨", "pretzel", ""),
("🧀", "cheese", ""),
("🥚", "egg", ""),
("🍳", "cooking", "frying pan egg"),
("🥞", "pancakes", "breakfast"),
("🧇", "waffle", "breakfast"),
("🥓", "bacon", "breakfast"),
("🍔", "hamburger", "burger"),
("🍟", "french fries", ""),
("🍕", "pizza", ""),
("🌭", "hot dog", ""),
("🥪", "sandwich", ""),
("🌮", "taco", "mexican"),
("🌯", "burrito", "mexican"),
("🍜", "steaming bowl", "ramen noodles"),
("🍝", "spaghetti", "pasta"),
("🍣", "sushi", "japanese"),
("🍱", "bento box", "japanese"),
("🍩", "doughnut", "donut dessert"),
("🍪", "cookie", "dessert"),
("🎂", "birthday cake", "dessert"),
("🍰", "shortcake", "dessert"),
("🧁", "cupcake", "dessert"),
("🍫", "chocolate bar", "dessert"),
("🍬", "candy", "sweet"),
("🍭", "lollipop", "candy sweet"),
("🍦", "soft ice cream", "dessert"),
("🍨", "ice cream", "dessert"),
("", "hot beverage", "coffee tea"),
("🍵", "teacup", "tea"),
("🧃", "juice box", ""),
("🥤", "cup with straw", "soda drink"),
("🍺", "beer mug", "drink alcohol"),
("🍻", "clinking beer mugs", "cheers drink"),
("🥂", "clinking glasses", "champagne cheers"),
("🍷", "wine glass", "drink alcohol"),
("🥃", "tumbler glass", "whiskey drink"),
("🍸", "cocktail glass", "martini drink"),
// Objects & Symbols
("💻", "laptop", "computer"),
("🖥️", "desktop computer", "pc"),
("⌨️", "keyboard", ""),
("🖱️", "computer mouse", ""),
("💾", "floppy disk", "save"),
("💿", "optical disk", "cd"),
("📱", "mobile phone", "smartphone"),
("☎️", "telephone", "phone"),
("📧", "email", "mail"),
("📨", "incoming envelope", "email"),
("📩", "envelope with arrow", "email send"),
("📝", "memo", "note write"),
("📄", "page facing up", "document"),
("📃", "page with curl", "document"),
("📑", "bookmark tabs", ""),
("📚", "books", "library read"),
("📖", "open book", "read"),
("🔗", "link", "chain url"),
("📎", "paperclip", "attachment"),
("🔒", "locked", "security"),
("🔓", "unlocked", "security open"),
("🔑", "key", "password"),
("🔧", "wrench", "tool fix"),
("🔨", "hammer", "tool"),
("⚙️", "gear", "settings"),
("🧲", "magnet", ""),
("💡", "light bulb", "idea"),
("🔦", "flashlight", ""),
("🔋", "battery", "power"),
("🔌", "electric plug", "power"),
("💰", "money bag", ""),
("💵", "dollar", "money cash"),
("💳", "credit card", "payment"),
("", "alarm clock", "time"),
("⏱️", "stopwatch", "timer"),
("📅", "calendar", "date"),
("📆", "tear-off calendar", "date"),
("", "check mark", "done yes"),
("", "cross mark", "no wrong delete"),
("", "question mark", "help"),
("", "exclamation mark", "important warning"),
("⚠️", "warning", "caution alert"),
("🚫", "prohibited", "no ban forbidden"),
("", "hollow circle", ""),
("🔴", "red circle", ""),
("🟠", "orange circle", ""),
("🟡", "yellow circle", ""),
("🟢", "green circle", ""),
("🔵", "blue circle", ""),
("🟣", "purple circle", ""),
("", "black circle", ""),
("", "white circle", ""),
("🟤", "brown circle", ""),
("", "black square", ""),
("", "white square", ""),
("🔶", "large orange diamond", ""),
("🔷", "large blue diamond", ""),
("", "star", "favorite"),
("🌟", "glowing star", "sparkle"),
("", "sparkles", "magic shine"),
("💫", "dizzy", "star"),
("🔥", "fire", "hot lit"),
("💧", "droplet", "water"),
("🌊", "wave", "water ocean"),
("🎵", "musical note", "music"),
("🎶", "musical notes", "music"),
("🎤", "microphone", "sing karaoke"),
("🎧", "headphones", "music"),
("🎮", "video game", "gaming controller"),
("🕹️", "joystick", "gaming"),
("🎯", "direct hit", "target bullseye"),
("🏆", "trophy", "winner award"),
("🥇", "1st place medal", "gold winner"),
("🥈", "2nd place medal", "silver"),
("🥉", "3rd place medal", "bronze"),
("🎁", "wrapped gift", "present"),
("🎈", "balloon", "party"),
("🎉", "party popper", "celebration tada"),
("🎊", "confetti ball", "celebration"),
// Arrows & Misc
("➡️", "right arrow", ""),
("⬅️", "left arrow", ""),
("⬆️", "up arrow", ""),
("⬇️", "down arrow", ""),
("↗️", "up-right arrow", ""),
("↘️", "down-right arrow", ""),
("↙️", "down-left arrow", ""),
("↖️", "up-left arrow", ""),
("↕️", "up-down arrow", ""),
("↔️", "left-right arrow", ""),
("🔄", "counterclockwise arrows", "refresh reload"),
("🔃", "clockwise arrows", "refresh reload"),
("", "plus", "add"),
("", "minus", "subtract"),
("", "division", "divide"),
("✖️", "multiply", "times"),
("♾️", "infinity", "forever"),
("💯", "hundred points", "100 perfect"),
("🆗", "ok button", "okay"),
("🆕", "new button", ""),
("🆓", "free button", ""),
("", "information", "info"),
("🅿️", "parking", ""),
("🚀", "rocket", "launch startup"),
("✈️", "airplane", "travel flight"),
("🚗", "car", "automobile"),
("🚕", "taxi", "cab"),
("🚌", "bus", ""),
("🚂", "locomotive", "train"),
("🏠", "house", "home"),
("🏢", "office building", "work"),
("🏥", "hospital", ""),
("🏫", "school", ""),
("🏛️", "classical building", ""),
("", "church", ""),
("🕌", "mosque", ""),
("🕍", "synagogue", ""),
("🗽", "statue of liberty", "usa america"),
("🗼", "tokyo tower", "japan"),
("🗾", "map of japan", ""),
("🌍", "globe europe-africa", "earth world"),
("🌎", "globe americas", "earth world"),
("🌏", "globe asia-australia", "earth world"),
("🌑", "new moon", ""),
("🌕", "full moon", ""),
("☀️", "sun", "sunny"),
("🌙", "crescent moon", "night"),
("", "star", ""),
("☁️", "cloud", ""),
("🌧️", "cloud with rain", "rainy"),
("⛈️", "cloud with lightning", "storm thunder"),
("🌈", "rainbow", ""),
("❄️", "snowflake", "cold winter"),
("☃️", "snowman", "winter"),
("🎄", "christmas tree", "xmas holiday"),
("🎃", "jack-o-lantern", "halloween pumpkin"),
("🐚", "shell", "beach"),
("🌸", "cherry blossom", "flower spring"),
("🌺", "hibiscus", "flower"),
("🌻", "sunflower", "flower"),
("🌹", "rose", "flower love"),
("🌷", "tulip", "flower"),
("🌱", "seedling", "plant grow"),
("🌲", "evergreen tree", ""),
("🌳", "deciduous tree", ""),
("🌴", "palm tree", "tropical"),
("🌵", "cactus", "desert"),
("🍀", "four leaf clover", "luck irish"),
("🍁", "maple leaf", "fall autumn canada"),
("🍂", "fallen leaf", "fall autumn"),
];
for (emoji, name, keywords) in emojis {
// Combine name and keywords for better searching
let search_text = format!("{} {}", name, keywords);
self.items.push(LaunchItem {
id: format!("emoji:{}", emoji),
name: name.to_string(),
description: Some(format!("{} {}", emoji, keywords)),
icon: None,
provider: ProviderType::Emoji,
// Copy emoji to clipboard using wl-copy
command: format!("printf '%s' '{}' | wl-copy", emoji),
terminal: false,
tags: Vec::new(), // TODO: Extract category from emoji data
});
// Store the search text for matching (not used directly but could be)
let _ = search_text;
}
}
}
impl Provider for EmojiProvider {
fn name(&self) -> &str {
"Emoji"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Emoji
}
fn refresh(&mut self) {
self.load_emojis();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emoji_provider() {
let mut provider = EmojiProvider::new();
provider.refresh();
assert!(provider.items().len() > 100);
// Emoji character is in description, name is the human-readable name
assert!(provider
.items()
.iter()
.any(|i| i.description.as_ref().is_some_and(|d| d.contains("😀"))));
}
}

226
src/providers/files.rs Normal file
View File

@@ -0,0 +1,226 @@
use crate::paths;
use crate::providers::{LaunchItem, ProviderType};
use log::{debug, warn};
use std::process::Command;
/// File search provider - uses fd or locate for fast file finding
pub struct FileSearchProvider {
search_tool: SearchTool,
max_results: usize,
}
#[derive(Debug, Clone, Copy)]
enum SearchTool {
Fd,
Locate,
None,
}
impl FileSearchProvider {
pub fn new() -> Self {
let search_tool = Self::detect_search_tool();
debug!("File search using: {:?}", search_tool);
Self {
search_tool,
max_results: 20,
}
}
fn detect_search_tool() -> SearchTool {
// Prefer fd (faster, respects .gitignore)
if Self::command_exists("fd") {
return SearchTool::Fd;
}
// Fall back to locate (requires updatedb)
if Self::command_exists("locate") {
return SearchTool::Locate;
}
SearchTool::None
}
fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Check if query is a file search query
/// Triggers on: `/ query`, `file query`, `find query`
pub fn is_file_query(query: &str) -> bool {
let trimmed = query.trim();
trimmed.starts_with("/ ")
|| trimmed.starts_with("/")
|| trimmed.to_lowercase().starts_with("file ")
|| trimmed.to_lowercase().starts_with("find ")
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("/ ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("/") {
Some(rest.trim())
} else if trimmed.to_lowercase().starts_with("file ") {
Some(trimmed[5..].trim())
} else if trimmed.to_lowercase().starts_with("find ") {
Some(trimmed[5..].trim())
} else {
None
}
}
/// Evaluate a file search query
pub fn evaluate(&self, query: &str) -> Vec<LaunchItem> {
let search_term = match Self::extract_search_term(query) {
Some(t) if !t.is_empty() => t,
_ => return Vec::new(),
};
self.search_files(search_term)
}
/// Evaluate a raw search term (for :file filter mode)
pub fn evaluate_raw(&self, search_term: &str) -> Vec<LaunchItem> {
let trimmed = search_term.trim();
if trimmed.is_empty() {
return Vec::new();
}
self.search_files(trimmed)
}
fn search_files(&self, pattern: &str) -> Vec<LaunchItem> {
match self.search_tool {
SearchTool::Fd => self.search_with_fd(pattern),
SearchTool::Locate => self.search_with_locate(pattern),
SearchTool::None => {
debug!("No file search tool available");
Vec::new()
}
}
}
fn search_with_fd(&self, pattern: &str) -> Vec<LaunchItem> {
// fd searches from home directory by default
let home = paths::home().unwrap_or_default();
let output = match Command::new("fd")
.args([
"--max-results",
&self.max_results.to_string(),
"--type",
"f", // Files only
"--type",
"d", // And directories
pattern,
])
.current_dir(&home)
.output()
{
Ok(o) => o,
Err(e) => {
warn!("Failed to run fd: {}", e);
return Vec::new();
}
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout), &home)
}
fn search_with_locate(&self, pattern: &str) -> Vec<LaunchItem> {
let home = paths::home().unwrap_or_default();
let output = match Command::new("locate")
.args([
"--limit",
&self.max_results.to_string(),
"--ignore-case",
pattern,
])
.output()
{
Ok(o) => o,
Err(e) => {
warn!("Failed to run locate: {}", e);
return Vec::new();
}
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout), &home)
}
fn parse_file_results(&self, output: &str, home: &std::path::Path) -> Vec<LaunchItem> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|path| {
let path = path.trim();
let full_path = if path.starts_with('/') {
path.to_string()
} else {
home.join(path).to_string_lossy().to_string()
};
// Get filename for display
let filename = std::path::Path::new(&full_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| full_path.clone());
// Determine icon based on whether it's a directory
let is_dir = std::path::Path::new(&full_path).is_dir();
let icon = if is_dir {
"folder"
} else {
"text-x-generic"
};
// Command to open with xdg-open
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
LaunchItem {
id: format!("file:{}", full_path),
name: filename,
description: Some(full_path.clone()),
icon: Some(icon.to_string()),
provider: ProviderType::Files,
command,
terminal: false,
tags: Vec::new(),
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_file_query() {
assert!(FileSearchProvider::is_file_query("/ config"));
assert!(FileSearchProvider::is_file_query("/config"));
assert!(FileSearchProvider::is_file_query("file config"));
assert!(FileSearchProvider::is_file_query("find config"));
assert!(!FileSearchProvider::is_file_query("config"));
assert!(!FileSearchProvider::is_file_query("? search"));
}
#[test]
fn test_extract_search_term() {
assert_eq!(
FileSearchProvider::extract_search_term("/ config.toml"),
Some("config.toml")
);
assert_eq!(
FileSearchProvider::extract_search_term("file bashrc"),
Some("bashrc")
);
}
}

View File

@@ -1,15 +1,39 @@
mod application;
mod bookmarks;
mod calculator;
mod clipboard;
mod command;
mod dmenu;
mod emoji;
mod files;
mod scripts;
mod ssh;
mod system;
mod uuctl;
mod websearch;
pub use application::ApplicationProvider;
pub use bookmarks::BookmarksProvider;
pub use calculator::CalculatorProvider;
pub use clipboard::ClipboardProvider;
pub use command::CommandProvider;
pub use dmenu::DmenuProvider;
pub use emoji::EmojiProvider;
pub use files::FileSearchProvider;
pub use scripts::ScriptsProvider;
pub use ssh::SshProvider;
pub use system::SystemProvider;
pub use uuctl::UuctlProvider;
pub use websearch::WebSearchProvider;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use log::info;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::data::FrecencyStore;
/// Represents a single searchable/launchable item
#[derive(Debug, Clone)]
@@ -22,14 +46,25 @@ pub struct LaunchItem {
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProviderType {
Application,
Bookmarks,
Calculator,
Clipboard,
Command,
Dmenu,
Emoji,
Files,
Scripts,
Ssh,
System,
Uuctl,
WebSearch,
}
impl std::str::FromStr for ProviderType {
@@ -38,10 +73,22 @@ impl std::str::FromStr for ProviderType {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
"bookmark" | "bookmarks" | "bm" => Ok(ProviderType::Bookmarks),
"calc" | "calculator" => Ok(ProviderType::Calculator),
"clip" | "clipboard" => Ok(ProviderType::Clipboard),
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"uuctl" => Ok(ProviderType::Uuctl),
"dmenu" => Ok(ProviderType::Dmenu),
_ => Err(format!("Unknown provider: '{}'. Valid: app, cmd, uuctl", s)),
"emoji" | "emojis" => Ok(ProviderType::Emoji),
"file" | "files" | "find" => Ok(ProviderType::Files),
"script" | "scripts" => Ok(ProviderType::Scripts),
"ssh" => Ok(ProviderType::Ssh),
"sys" | "system" | "power" => Ok(ProviderType::System),
"uuctl" => Ok(ProviderType::Uuctl),
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
_ => Err(format!(
"Unknown provider: '{}'. Valid: app, bookmark, calc, clip, cmd, emoji, file, script, ssh, sys, web",
s
)),
}
}
}
@@ -50,9 +97,18 @@ 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::Bookmarks => write!(f, "bookmark"),
ProviderType::Calculator => write!(f, "calc"),
ProviderType::Clipboard => write!(f, "clip"),
ProviderType::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Emoji => write!(f, "emoji"),
ProviderType::Files => write!(f, "file"),
ProviderType::Scripts => write!(f, "script"),
ProviderType::Ssh => write!(f, "ssh"),
ProviderType::System => write!(f, "sys"),
ProviderType::Uuctl => write!(f, "uuctl"),
ProviderType::WebSearch => write!(f, "web"),
}
}
}
@@ -69,13 +125,28 @@ pub trait Provider: Send {
/// Manages all providers and handles searching
pub struct ProviderManager {
providers: Vec<Box<dyn Provider>>,
calculator: CalculatorProvider,
websearch: WebSearchProvider,
filesearch: FileSearchProvider,
matcher: SkimMatcherV2,
}
impl ProviderManager {
#[allow(dead_code)]
pub fn new() -> Self {
Self::with_search_engine("duckduckgo")
}
pub fn with_search_engine(search_engine: &str) -> Self {
Self::with_config(search_engine, "kitty")
}
pub fn with_config(search_engine: &str, terminal: &str) -> Self {
let mut manager = Self {
providers: Vec::new(),
calculator: CalculatorProvider::new(),
websearch: WebSearchProvider::with_engine(search_engine),
filesearch: FileSearchProvider::new(),
matcher: SkimMatcherV2::default(),
};
@@ -92,6 +163,14 @@ impl ProviderManager {
manager.providers.push(Box::new(ApplicationProvider::new()));
manager.providers.push(Box::new(CommandProvider::new()));
manager.providers.push(Box::new(UuctlProvider::new()));
// New providers
manager.providers.push(Box::new(SystemProvider::new()));
manager.providers.push(Box::new(SshProvider::with_terminal(terminal)));
manager.providers.push(Box::new(ClipboardProvider::new()));
manager.providers.push(Box::new(BookmarksProvider::new()));
manager.providers.push(Box::new(EmojiProvider::new()));
manager.providers.push(Box::new(ScriptsProvider::new()));
}
// Initial refresh
@@ -110,6 +189,11 @@ impl ProviderManager {
pub fn refresh_all(&mut self) {
for provider in &mut self.providers {
provider.refresh();
info!(
"Provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
}
@@ -200,6 +284,163 @@ impl ProviderManager {
results
}
/// Search with frecency boosting, calculator support, and tag filtering
pub fn search_with_frecency(
&mut self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
frecency: &FrecencyStore,
frecency_weight: f64,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> {
#[cfg(feature = "dev-logging")]
debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight);
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Check for calculator query (= or calc prefix)
if CalculatorProvider::is_calculator_query(query) {
if let Some(calc_result) = self.calculator.evaluate(query) {
#[cfg(feature = "dev-logging")]
debug!("[Search] Calculator result: {}", calc_result.name);
results.push((calc_result, 10000));
}
}
// Also check for raw expression when in :calc filter mode
else if filter.active_prefix() == Some(ProviderType::Calculator)
&& CalculatorProvider::looks_like_expression(query)
{
if let Some(calc_result) = self.calculator.evaluate_raw(query) {
results.push((calc_result, 10000));
}
}
// Check for web search query
if WebSearchProvider::is_websearch_query(query) {
if let Some(web_result) = self.websearch.evaluate(query) {
// Web search results get a high score to appear first
results.push((web_result, 9000));
}
}
// Also check for raw query when in :web filter mode
else if filter.active_prefix() == Some(ProviderType::WebSearch) && !query.is_empty() {
if let Some(web_result) = self.websearch.evaluate_raw(query) {
results.push((web_result, 9000));
}
}
// Check for file search query
if FileSearchProvider::is_file_query(query) {
let file_results = self.filesearch.evaluate(query);
#[cfg(feature = "dev-logging")]
debug!("[Search] File search returned {} results", file_results.len());
for (idx, item) in file_results.into_iter().enumerate() {
// Score decreases for each result to maintain order
results.push((item, 8000 - idx as i64));
}
}
// Also check for raw query when in :file filter mode
else if filter.active_prefix() == Some(ProviderType::Files) && !query.is_empty() {
let file_results = self.filesearch.evaluate_raw(query);
for (idx, item) in file_results.into_iter().enumerate() {
results.push((item, 8000 - idx as i64));
}
}
// Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() {
let mut items: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned())
.filter(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score(&item.id);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1));
items.truncate(max_results);
return items;
}
// Regular search with frecency boost and tag matching
let search_results: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|provider| filter.is_active(provider.provider_type()))
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
if !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) {
return None;
}
}
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));
// Also match against tags (lower weight)
let tag_score = item
.tags
.iter()
.filter_map(|t| self.matcher.fuzzy_match(t, query))
.max()
.map(|s| s / 3); // Lower weight for tag matches
let base_score = match (name_score, desc_score, tag_score) {
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
(Some(n), Some(d), None) => Some(n.max(d)),
(Some(n), None, Some(t)) => Some(n.max(t)),
(Some(n), None, None) => Some(n),
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
(None, Some(d), None) => Some(d / 2),
(None, None, Some(t)) => Some(t),
(None, None, None) => None,
};
base_score.map(|s| {
let frecency_score = frecency.get_score(&item.id);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
(item.clone(), s + frecency_boost)
})
})
})
.collect();
results.extend(search_results);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
#[cfg(feature = "dev-logging")]
{
debug!("[Search] Returning {} results", results.len());
for (i, (item, score)) in results.iter().take(5).enumerate() {
debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider);
}
if results.len() > 5 {
debug!("[Search] ... and {} more", results.len() - 5);
}
}
results
}
/// Get all available provider types (for UI tabs)
#[allow(dead_code)]
pub fn available_providers(&self) -> Vec<ProviderType> {

179
src/providers/scripts.rs Normal file
View File

@@ -0,0 +1,179 @@
use crate::paths;
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
/// Custom scripts provider - runs user scripts from `$XDG_DATA_HOME/owlry/scripts/`
pub struct ScriptsProvider {
items: Vec<LaunchItem>,
}
impl ScriptsProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn load_scripts(&mut self) {
self.items.clear();
let scripts_dir = match paths::scripts_dir() {
Some(p) => p,
None => {
debug!("Could not determine scripts directory");
return;
}
};
if !scripts_dir.exists() {
debug!("Scripts directory not found at {:?}", scripts_dir);
// Create the directory for the user
if let Err(e) = paths::ensure_dir(&scripts_dir) {
warn!("Failed to create scripts directory: {}", e);
}
return;
}
let entries = match fs::read_dir(&scripts_dir) {
Ok(e) => e,
Err(e) => {
warn!("Failed to read scripts directory: {}", e);
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
// Skip directories
if path.is_dir() {
continue;
}
// Check if executable
let metadata = match path.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let is_executable = metadata.permissions().mode() & 0o111 != 0;
if !is_executable {
continue;
}
// Get script name without extension
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let name = path
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(filename.clone());
// Try to read description from first line comment
let description = Self::read_script_description(&path);
// Determine icon based on extension or shebang
let icon = Self::determine_icon(&path);
self.items.push(LaunchItem {
id: format!("script:{}", filename),
name: format!("Script: {}", name),
description,
icon: Some(icon),
provider: ProviderType::Scripts,
command: path.to_string_lossy().to_string(),
terminal: false,
tags: vec!["script".to_string()],
});
}
debug!("Loaded {} scripts from {:?}", self.items.len(), scripts_dir);
}
fn read_script_description(path: &PathBuf) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let mut lines = content.lines();
// Skip shebang if present
let first_line = lines.next()?;
let check_line = if first_line.starts_with("#!") {
lines.next()?
} else {
first_line
};
// Look for a comment description
if check_line.starts_with("# ") {
Some(check_line[2..].trim().to_string())
} else if check_line.starts_with("// ") {
Some(check_line[3..].trim().to_string())
} else {
None
}
}
fn determine_icon(path: &PathBuf) -> String {
// Check extension first
if let Some(ext) = path.extension() {
match ext.to_string_lossy().as_ref() {
"sh" | "bash" | "zsh" => return "utilities-terminal".to_string(),
"py" | "python" => return "text-x-python".to_string(),
"js" | "ts" => return "text-x-javascript".to_string(),
"rb" => return "text-x-ruby".to_string(),
"pl" => return "text-x-perl".to_string(),
_ => {}
}
}
// Check shebang
if let Ok(content) = fs::read_to_string(path) {
if let Some(first_line) = content.lines().next() {
if first_line.contains("bash") || first_line.contains("sh") {
return "utilities-terminal".to_string();
} else if first_line.contains("python") {
return "text-x-python".to_string();
} else if first_line.contains("node") {
return "text-x-javascript".to_string();
} else if first_line.contains("ruby") {
return "text-x-ruby".to_string();
}
}
}
"application-x-executable".to_string()
}
}
impl Provider for ScriptsProvider {
fn name(&self) -> &str {
"Scripts"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Scripts
}
fn refresh(&mut self) {
self.load_scripts();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scripts_provider() {
let mut provider = ScriptsProvider::new();
provider.refresh();
// Just ensure it doesn't panic
}
}

198
src/providers/ssh.rs Normal file
View File

@@ -0,0 +1,198 @@
use crate::paths;
use crate::providers::{LaunchItem, Provider, ProviderType};
use log::{debug, warn};
use std::fs;
/// SSH connections provider - parses ~/.ssh/config
pub struct SshProvider {
items: Vec<LaunchItem>,
terminal_command: String,
}
impl SshProvider {
#[allow(dead_code)]
pub fn new() -> Self {
Self::with_terminal("kitty")
}
pub fn with_terminal(terminal: &str) -> Self {
Self {
items: Vec::new(),
terminal_command: terminal.to_string(),
}
}
#[allow(dead_code)]
pub fn set_terminal(&mut self, terminal: &str) {
self.terminal_command = terminal.to_string();
}
fn ssh_config_path() -> Option<std::path::PathBuf> {
paths::ssh_config()
}
fn parse_ssh_config(&mut self) {
self.items.clear();
let config_path = match Self::ssh_config_path() {
Some(p) => p,
None => {
debug!("Could not determine SSH config path");
return;
}
};
if !config_path.exists() {
debug!("SSH config not found at {:?}", config_path);
return;
}
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read SSH config: {}", e);
return;
}
};
let mut current_host: Option<String> = None;
let mut current_hostname: Option<String> = None;
let mut current_user: Option<String> = None;
let mut current_port: Option<String> = None;
for line in content.lines() {
let line = line.trim();
// Skip comments and empty lines
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split on whitespace or '='
let parts: Vec<&str> = line.splitn(2, |c: char| c.is_whitespace() || c == '=')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if parts.len() < 2 {
continue;
}
let key = parts[0].to_lowercase();
let value = parts[1];
match key.as_str() {
"host" => {
// Save previous host if exists
if let Some(host) = current_host.take() {
self.add_host_item(
&host,
current_hostname.take(),
current_user.take(),
current_port.take(),
);
}
// Skip wildcards and patterns
if !value.contains('*') && !value.contains('?') && value != "*" {
current_host = Some(value.to_string());
}
current_hostname = None;
current_user = None;
current_port = None;
}
"hostname" => {
current_hostname = Some(value.to_string());
}
"user" => {
current_user = Some(value.to_string());
}
"port" => {
current_port = Some(value.to_string());
}
_ => {}
}
}
// Don't forget the last host
if let Some(host) = current_host.take() {
self.add_host_item(&host, current_hostname, current_user, current_port);
}
debug!("Loaded {} SSH hosts", self.items.len());
}
fn add_host_item(
&mut self,
host: &str,
hostname: Option<String>,
user: Option<String>,
port: Option<String>,
) {
// Build description
let mut desc_parts = Vec::new();
if let Some(ref h) = hostname {
desc_parts.push(h.clone());
}
if let Some(ref u) = user {
desc_parts.push(format!("user: {}", u));
}
if let Some(ref p) = port {
desc_parts.push(format!("port: {}", p));
}
let description = if desc_parts.is_empty() {
None
} else {
Some(desc_parts.join(", "))
};
// Build SSH command - just use the host alias, SSH will resolve the rest
let ssh_command = format!("ssh {}", host);
// Wrap in terminal
let command = format!("{} -e {}", self.terminal_command, ssh_command);
self.items.push(LaunchItem {
id: format!("ssh:{}", host),
name: format!("SSH: {}", host),
description,
icon: Some("utilities-terminal".to_string()),
provider: ProviderType::Ssh,
command,
terminal: false, // We're already wrapping in terminal
tags: vec!["ssh".to_string()],
});
}
}
impl Provider for SshProvider {
fn name(&self) -> &str {
"SSH"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Ssh
}
fn refresh(&mut self) {
self.parse_ssh_config();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ssh_config() {
// This test will only work if the user has an SSH config
let mut provider = SshProvider::new();
provider.refresh();
// Just ensure it doesn't panic
}
}

116
src/providers/system.rs Normal file
View File

@@ -0,0 +1,116 @@
use crate::providers::{LaunchItem, Provider, ProviderType};
/// System commands provider - shutdown, reboot, lock, etc.
pub struct SystemProvider {
items: Vec<LaunchItem>,
}
impl SystemProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn load_commands(&mut self) {
self.items.clear();
// Define system commands
// Format: (id, name, description, icon, command)
let commands: Vec<(&str, &str, &str, &str, &str)> = vec![
(
"system:shutdown",
"Shutdown",
"Power off the system",
"system-shutdown",
"systemctl poweroff",
),
(
"system:reboot",
"Reboot",
"Restart the system",
"system-reboot",
"systemctl reboot",
),
(
"system:reboot-bios",
"Reboot into BIOS",
"Restart into UEFI/BIOS setup",
"system-reboot",
"systemctl reboot --firmware-setup",
),
(
"system:suspend",
"Suspend",
"Suspend to RAM",
"system-suspend",
"systemctl suspend",
),
(
"system:hibernate",
"Hibernate",
"Suspend to disk",
"system-suspend-hibernate",
"systemctl hibernate",
),
(
"system:lock",
"Lock Screen",
"Lock the session",
"system-lock-screen",
"loginctl lock-session",
),
(
"system:logout",
"Log Out",
"End the current session",
"system-log-out",
"loginctl terminate-session self",
),
];
for (id, name, description, icon, command) in commands {
self.items.push(LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(icon.to_string()),
provider: ProviderType::System,
command: command.to_string(),
terminal: false,
tags: vec!["power".to_string(), "system".to_string()],
});
}
}
}
impl Provider for SystemProvider {
fn name(&self) -> &str {
"System"
}
fn provider_type(&self) -> ProviderType {
ProviderType::System
}
fn refresh(&mut self) {
self.load_commands();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_system_provider() {
let mut provider = SystemProvider::new();
provider.refresh();
assert!(provider.items().len() >= 6);
assert!(provider.items().iter().any(|i| i.name == "Shutdown"));
assert!(provider.items().iter().any(|i| i.name == "Reboot into BIOS"));
}
}

View File

@@ -46,6 +46,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user restart {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -56,6 +57,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user stop {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -66,6 +68,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user reload {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -76,6 +79,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user kill {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
} else {
actions.push(LaunchItem {
@@ -86,6 +90,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user start {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
}
@@ -98,6 +103,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user status {}", unit_name),
terminal: true,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -108,6 +114,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("journalctl --user -u {} -f", unit_name),
terminal: true,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -118,6 +125,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user enable {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions.push(LaunchItem {
@@ -128,6 +136,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: format!("systemctl --user disable {}", unit_name),
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
actions
@@ -189,6 +198,7 @@ impl UuctlProvider {
provider: ProviderType::Uuctl,
command: submenu_data, // Special marker for submenu
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
});
}

196
src/providers/websearch.rs Normal file
View File

@@ -0,0 +1,196 @@
use crate::providers::{LaunchItem, ProviderType};
/// Common search engine URL templates
/// {query} is replaced with the URL-encoded search term
pub const SEARCH_ENGINES: &[(&str, &str)] = &[
("google", "https://www.google.com/search?q={query}"),
("duckduckgo", "https://duckduckgo.com/?q={query}"),
("bing", "https://www.bing.com/search?q={query}"),
("startpage", "https://www.startpage.com/search?q={query}"),
("searxng", "https://searx.be/search?q={query}"),
("brave", "https://search.brave.com/search?q={query}"),
("ecosia", "https://www.ecosia.org/search?q={query}"),
];
/// Default search engine if not configured
pub const DEFAULT_ENGINE: &str = "duckduckgo";
/// Web search provider - opens browser with search query
pub struct WebSearchProvider {
/// URL template with {query} placeholder
url_template: String,
}
impl WebSearchProvider {
#[allow(dead_code)]
pub fn new() -> Self {
Self::with_engine(DEFAULT_ENGINE)
}
/// Create provider with specific search engine
pub fn with_engine(engine_name: &str) -> Self {
let url_template = SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == engine_name.to_lowercase())
.map(|(_, url)| url.to_string())
.unwrap_or_else(|| {
// If not a known engine, treat it as a custom URL template
if engine_name.contains("{query}") {
engine_name.to_string()
} else {
// Fall back to default
SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == DEFAULT_ENGINE)
.map(|(_, url)| url.to_string())
.unwrap()
}
});
Self { url_template }
}
/// Check if query is a web search query
/// Triggers on: `? query`, `web query`, `search query`
pub fn is_websearch_query(query: &str) -> bool {
let trimmed = query.trim();
trimmed.starts_with("? ")
|| trimmed.starts_with("?")
|| trimmed.to_lowercase().starts_with("web ")
|| trimmed.to_lowercase().starts_with("search ")
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("? ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("?") {
Some(rest.trim())
} else if trimmed.to_lowercase().starts_with("web ") {
// Need to get the original casing
Some(trimmed[4..].trim())
} else if trimmed.to_lowercase().starts_with("search ") {
Some(trimmed[7..].trim())
} else {
None
}
}
/// URL-encode a search query
fn url_encode(query: &str) -> String {
// TODO: This is where you can implement the URL encoding logic!
// Consider: Should we use a crate like `urlencoding` or implement manually?
// Manual encoding needs to handle: spaces, &, =, ?, #, etc.
query
.chars()
.map(|c| match c {
' ' => "+".to_string(),
'&' => "%26".to_string(),
'=' => "%3D".to_string(),
'?' => "%3F".to_string(),
'#' => "%23".to_string(),
'+' => "%2B".to_string(),
'%' => "%25".to_string(),
c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(),
c => format!("%{:02X}", c as u32),
})
.collect()
}
/// Build the search URL from a query
fn build_search_url(&self, search_term: &str) -> String {
let encoded = Self::url_encode(search_term);
self.url_template.replace("{query}", &encoded)
}
/// Evaluate a web search query and return a LaunchItem if valid
pub fn evaluate(&self, query: &str) -> Option<LaunchItem> {
let search_term = Self::extract_search_term(query)?;
if search_term.is_empty() {
return None;
}
self.evaluate_raw(search_term)
}
/// Evaluate a raw search term (for :web filter mode)
pub fn evaluate_raw(&self, search_term: &str) -> Option<LaunchItem> {
let trimmed = search_term.trim();
if trimmed.is_empty() {
return None;
}
let url = self.build_search_url(trimmed);
// Use xdg-open to open the browser
let command = format!("xdg-open '{}'", url);
Some(LaunchItem {
id: format!("websearch:{}", trimmed),
name: format!("Search: {}", trimmed),
description: Some("Open in browser".to_string()),
icon: Some("web-browser".to_string()),
provider: ProviderType::WebSearch,
command,
terminal: false,
tags: vec!["web".to_string(), "search".to_string()],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_websearch_query() {
assert!(WebSearchProvider::is_websearch_query("? rust programming"));
assert!(WebSearchProvider::is_websearch_query("?rust"));
assert!(WebSearchProvider::is_websearch_query("web rust"));
assert!(WebSearchProvider::is_websearch_query("search rust"));
assert!(!WebSearchProvider::is_websearch_query("rust"));
assert!(!WebSearchProvider::is_websearch_query("= 5+3"));
}
#[test]
fn test_extract_search_term() {
assert_eq!(
WebSearchProvider::extract_search_term("? rust programming"),
Some("rust programming")
);
assert_eq!(
WebSearchProvider::extract_search_term("?rust"),
Some("rust")
);
assert_eq!(
WebSearchProvider::extract_search_term("web rust docs"),
Some("rust docs")
);
}
#[test]
fn test_url_encode() {
assert_eq!(WebSearchProvider::url_encode("hello world"), "hello+world");
assert_eq!(WebSearchProvider::url_encode("foo&bar"), "foo%26bar");
assert_eq!(WebSearchProvider::url_encode("a=b"), "a%3Db");
}
#[test]
fn test_build_search_url() {
let provider = WebSearchProvider::with_engine("duckduckgo");
let url = provider.build_search_url("rust programming");
assert_eq!(url, "https://duckduckgo.com/?q=rust+programming");
}
#[test]
fn test_evaluate() {
let provider = WebSearchProvider::new();
let item = provider.evaluate("? rust docs").unwrap();
assert_eq!(item.name, "Search: rust docs");
assert!(item.command.contains("xdg-open"));
assert!(item.command.contains("duckduckgo"));
}
}

View File

@@ -106,6 +106,21 @@
color: var(--owlry-badge-app, @blue_3);
}
.owlry-badge-bookmark {
background-color: alpha(var(--owlry-badge-bookmark, #f5a623), 0.2);
color: var(--owlry-badge-bookmark, #f5a623);
}
.owlry-badge-calc {
background-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.2);
color: var(--owlry-badge-calc, @yellow_3);
}
.owlry-badge-clip {
background-color: alpha(var(--owlry-badge-clip, #8b5cf6), 0.2);
color: var(--owlry-badge-clip, #8b5cf6);
}
.owlry-badge-cmd {
background-color: alpha(var(--owlry-badge-cmd, @purple_3), 0.2);
color: var(--owlry-badge-cmd, @purple_3);
@@ -116,11 +131,41 @@
color: var(--owlry-badge-dmenu, @green_3);
}
.owlry-badge-emoji {
background-color: alpha(var(--owlry-badge-emoji, #f472b6), 0.2);
color: var(--owlry-badge-emoji, #f472b6);
}
.owlry-badge-file {
background-color: alpha(var(--owlry-badge-file, #22d3ee), 0.2);
color: var(--owlry-badge-file, #22d3ee);
}
.owlry-badge-script {
background-color: alpha(var(--owlry-badge-script, #a3e635), 0.2);
color: var(--owlry-badge-script, #a3e635);
}
.owlry-badge-ssh {
background-color: alpha(var(--owlry-badge-ssh, #2dd4bf), 0.2);
color: var(--owlry-badge-ssh, #2dd4bf);
}
.owlry-badge-sys {
background-color: alpha(var(--owlry-badge-sys, #ef4444), 0.2);
color: var(--owlry-badge-sys, #ef4444);
}
.owlry-badge-uuctl {
background-color: alpha(var(--owlry-badge-uuctl, @orange_3), 0.2);
color: var(--owlry-badge-uuctl, @orange_3);
}
.owlry-badge-web {
background-color: alpha(var(--owlry-badge-web, @teal_3), 0.2);
color: var(--owlry-badge-web, @teal_3);
}
/* Header bar */
.owlry-header {
margin-bottom: 4px;
@@ -166,22 +211,76 @@
border-color: alpha(var(--owlry-badge-app, @blue_3), 0.4);
}
.owlry-filter-bookmark:checked {
background-color: alpha(var(--owlry-badge-bookmark, #f5a623), 0.2);
color: var(--owlry-badge-bookmark, #f5a623);
border-color: alpha(var(--owlry-badge-bookmark, #f5a623), 0.4);
}
.owlry-filter-calc:checked {
background-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.2);
color: var(--owlry-badge-calc, @yellow_3);
border-color: alpha(var(--owlry-badge-calc, @yellow_3), 0.4);
}
.owlry-filter-clip:checked {
background-color: alpha(var(--owlry-badge-clip, #8b5cf6), 0.2);
color: var(--owlry-badge-clip, #8b5cf6);
border-color: alpha(var(--owlry-badge-clip, #8b5cf6), 0.4);
}
.owlry-filter-cmd:checked {
background-color: alpha(var(--owlry-badge-cmd, @purple_3), 0.2);
color: var(--owlry-badge-cmd, @purple_3);
border-color: alpha(var(--owlry-badge-cmd, @purple_3), 0.4);
}
.owlry-filter-dmenu:checked {
background-color: alpha(var(--owlry-badge-dmenu, @green_3), 0.2);
color: var(--owlry-badge-dmenu, @green_3);
border-color: alpha(var(--owlry-badge-dmenu, @green_3), 0.4);
}
.owlry-filter-emoji:checked {
background-color: alpha(var(--owlry-badge-emoji, #f472b6), 0.2);
color: var(--owlry-badge-emoji, #f472b6);
border-color: alpha(var(--owlry-badge-emoji, #f472b6), 0.4);
}
.owlry-filter-file:checked {
background-color: alpha(var(--owlry-badge-file, #22d3ee), 0.2);
color: var(--owlry-badge-file, #22d3ee);
border-color: alpha(var(--owlry-badge-file, #22d3ee), 0.4);
}
.owlry-filter-script:checked {
background-color: alpha(var(--owlry-badge-script, #a3e635), 0.2);
color: var(--owlry-badge-script, #a3e635);
border-color: alpha(var(--owlry-badge-script, #a3e635), 0.4);
}
.owlry-filter-ssh:checked {
background-color: alpha(var(--owlry-badge-ssh, #2dd4bf), 0.2);
color: var(--owlry-badge-ssh, #2dd4bf);
border-color: alpha(var(--owlry-badge-ssh, #2dd4bf), 0.4);
}
.owlry-filter-sys:checked {
background-color: alpha(var(--owlry-badge-sys, #ef4444), 0.2);
color: var(--owlry-badge-sys, #ef4444);
border-color: alpha(var(--owlry-badge-sys, #ef4444), 0.4);
}
.owlry-filter-uuctl:checked {
background-color: alpha(var(--owlry-badge-uuctl, @orange_3), 0.2);
color: var(--owlry-badge-uuctl, @orange_3);
border-color: alpha(var(--owlry-badge-uuctl, @orange_3), 0.4);
}
.owlry-filter-dmenu:checked {
background-color: alpha(var(--owlry-badge-dmenu, @green_3), 0.2);
color: var(--owlry-badge-dmenu, @green_3);
border-color: alpha(var(--owlry-badge-dmenu, @green_3), 0.4);
.owlry-filter-web:checked {
background-color: alpha(var(--owlry-badge-web, @teal_3), 0.2);
color: var(--owlry-badge-web, @teal_3);
border-color: alpha(var(--owlry-badge-web, @teal_3), 0.4);
}
/* Hints bar at bottom */
@@ -216,6 +315,22 @@ scrollbar slider:active {
background-color: var(--owlry-accent, @theme_selected_bg_color);
}
/* Tag badges */
.owlry-tag-badge {
font-size: calc(var(--owlry-font-size, 14px) - 4px);
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
background-color: alpha(var(--owlry-border, @borders), 0.3);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.6));
margin-top: 4px;
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.2);
color: var(--owlry-accent-bright, @theme_selected_fg_color);
}
/* Text selection */
selection {
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.3);

View File

@@ -35,15 +35,42 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
if let Some(ref badge_app) = config.colors.badge_app {
css.push_str(&format!(" --owlry-badge-app: {};\n", badge_app));
}
if let Some(ref badge_bookmark) = config.colors.badge_bookmark {
css.push_str(&format!(" --owlry-badge-bookmark: {};\n", badge_bookmark));
}
if let Some(ref badge_calc) = config.colors.badge_calc {
css.push_str(&format!(" --owlry-badge-calc: {};\n", badge_calc));
}
if let Some(ref badge_clip) = config.colors.badge_clip {
css.push_str(&format!(" --owlry-badge-clip: {};\n", badge_clip));
}
if let Some(ref badge_cmd) = config.colors.badge_cmd {
css.push_str(&format!(" --owlry-badge-cmd: {};\n", badge_cmd));
}
if let Some(ref badge_dmenu) = config.colors.badge_dmenu {
css.push_str(&format!(" --owlry-badge-dmenu: {};\n", badge_dmenu));
}
if let Some(ref badge_emoji) = config.colors.badge_emoji {
css.push_str(&format!(" --owlry-badge-emoji: {};\n", badge_emoji));
}
if let Some(ref badge_file) = config.colors.badge_file {
css.push_str(&format!(" --owlry-badge-file: {};\n", badge_file));
}
if let Some(ref badge_script) = config.colors.badge_script {
css.push_str(&format!(" --owlry-badge-script: {};\n", badge_script));
}
if let Some(ref badge_ssh) = config.colors.badge_ssh {
css.push_str(&format!(" --owlry-badge-ssh: {};\n", badge_ssh));
}
if let Some(ref badge_sys) = config.colors.badge_sys {
css.push_str(&format!(" --owlry-badge-sys: {};\n", badge_sys));
}
if let Some(ref badge_uuctl) = config.colors.badge_uuctl {
css.push_str(&format!(" --owlry-badge-uuctl: {};\n", badge_uuctl));
}
if let Some(ref badge_web) = config.colors.badge_web {
css.push_str(&format!(" --owlry-badge-web: {};\n", badge_web));
}
css.push_str("}\n");
css

View File

@@ -1,4 +1,5 @@
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::providers::{LaunchItem, ProviderManager, ProviderType, UuctlProvider};
use crate::ui::ResultRow;
@@ -9,6 +10,10 @@ use gtk4::{
ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton,
};
use log::info;
#[cfg(feature = "dev-logging")]
use log::debug;
use std::cell::RefCell;
use std::collections::HashMap;
use std::process::Command;
@@ -36,12 +41,15 @@ pub struct MainWindow {
scrolled: ScrolledWindow,
config: Rc<RefCell<Config>>,
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
current_results: Rc<RefCell<Vec<LaunchItem>>>,
filter: Rc<RefCell<ProviderFilter>>,
mode_label: Label,
hints_label: Label,
filter_buttons: Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
submenu_state: Rc<RefCell<SubmenuState>>,
/// Parsed tab config (ProviderTypes for cycling)
tab_order: Rc<Vec<ProviderType>>,
}
impl MainWindow {
@@ -49,6 +57,7 @@ impl MainWindow {
app: &Application,
config: Rc<RefCell<Config>>,
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
filter: Rc<RefCell<ProviderFilter>>,
) -> Self {
let cfg = config.borrow();
@@ -101,8 +110,17 @@ impl MainWindow {
.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);
// Parse tabs config to ProviderTypes
let tab_order: Vec<ProviderType> = cfg
.general
.tabs
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let tab_order = Rc::new(tab_order);
// Create toggle buttons for each provider (from config)
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &cfg.general.tabs);
let filter_buttons = Rc::new(RefCell::new(filter_buttons));
header_box.append(&mode_label);
@@ -140,7 +158,7 @@ impl MainWindow {
hints_box.add_css_class("owlry-hints");
let hints_label = Label::builder()
.label("Tab: cycle mode ↑↓: navigate Enter: launch Esc: close :app :cmd :uuctl")
.label(&Self::build_hints(&cfg.providers))
.halign(gtk4::Align::Center)
.hexpand(true)
.build();
@@ -163,12 +181,14 @@ impl MainWindow {
scrolled,
config,
providers,
frecency,
current_results: Rc::new(RefCell::new(Vec::new())),
filter,
mode_label,
hints_label,
filter_buttons,
submenu_state: Rc::new(RefCell::new(SubmenuState::default())),
tab_order,
};
main_window.setup_signals();
@@ -183,29 +203,31 @@ impl MainWindow {
fn create_filter_buttons(
container: &GtkBox,
filter: &Rc<RefCell<ProviderFilter>>,
tabs: &[String],
) -> HashMap<ProviderType, ToggleButton> {
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 {
// Parse tab strings to ProviderType and create buttons
for (idx, tab_str) in tabs.iter().enumerate() {
let provider_type: ProviderType = match tab_str.parse() {
Ok(pt) => pt,
Err(e) => {
log::warn!("Invalid tab config '{}': {}", tab_str, e);
continue;
}
};
let label = Self::provider_tab_label(provider_type);
let shortcut = format!("Ctrl+{}", idx + 1);
let button = ToggleButton::builder()
.label(label)
.tooltip_text(shortcut)
.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",
};
let css_class = Self::provider_css_class(provider_type);
button.add_css_class(css_class);
container.append(&button);
@@ -215,21 +237,114 @@ impl MainWindow {
buttons
}
/// Get display label for a provider tab
fn provider_tab_label(provider: ProviderType) -> &'static str {
match provider {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clip",
ProviderType::Command => "Cmds",
ProviderType::Dmenu => "Dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
}
}
/// Get CSS class for a provider
fn provider_css_class(provider: ProviderType) -> &'static str {
match provider {
ProviderType::Application => "owlry-filter-app",
ProviderType::Bookmarks => "owlry-filter-bookmark",
ProviderType::Calculator => "owlry-filter-calc",
ProviderType::Clipboard => "owlry-filter-clip",
ProviderType::Command => "owlry-filter-cmd",
ProviderType::Dmenu => "owlry-filter-dmenu",
ProviderType::Emoji => "owlry-filter-emoji",
ProviderType::Files => "owlry-filter-file",
ProviderType::Scripts => "owlry-filter-script",
ProviderType::Ssh => "owlry-filter-ssh",
ProviderType::System => "owlry-filter-sys",
ProviderType::Uuctl => "owlry-filter-uuctl",
ProviderType::WebSearch => "owlry-filter-web",
}
}
fn build_placeholder(filter: &ProviderFilter) -> String {
let active: Vec<&str> = filter
.enabled_providers()
.iter()
.map(|p| match p {
ProviderType::Application => "applications",
ProviderType::Bookmarks => "bookmarks",
ProviderType::Calculator => "calculator",
ProviderType::Clipboard => "clipboard",
ProviderType::Command => "commands",
ProviderType::Uuctl => "uuctl units",
ProviderType::Dmenu => "options",
ProviderType::Emoji => "emoji",
ProviderType::Files => "files",
ProviderType::Scripts => "scripts",
ProviderType::Ssh => "SSH hosts",
ProviderType::System => "system",
ProviderType::Uuctl => "uuctl units",
ProviderType::WebSearch => "web",
})
.collect();
format!("Search {}...", active.join(", "))
}
/// Build dynamic hints based on enabled providers
fn build_hints(config: &crate::config::ProvidersConfig) -> String {
let mut parts: Vec<String> = vec![
"Tab: cycle".to_string(),
"↑↓: nav".to_string(),
"Enter: launch".to_string(),
"Esc: close".to_string(),
];
// Add trigger hints for enabled dynamic providers
if config.calculator {
parts.push("= calc".to_string());
}
if config.websearch {
parts.push("? web".to_string());
}
if config.files {
parts.push("/ files".to_string());
}
// Add prefix hints for static providers
let mut prefixes = Vec::new();
if config.system {
prefixes.push(":sys");
}
if config.emoji {
prefixes.push(":emoji");
}
if config.ssh {
prefixes.push(":ssh");
}
if config.clipboard {
prefixes.push(":clip");
}
if config.bookmarks {
prefixes.push(":bm");
}
// Only show first few prefixes to avoid overflow
if !prefixes.is_empty() {
parts.push(prefixes[..prefixes.len().min(4)].join(" "));
}
parts.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();
@@ -276,6 +391,9 @@ impl MainWindow {
display_name: &str,
is_active: bool,
) {
#[cfg(feature = "dev-logging")]
debug!("[UI] Entering submenu for service: {} (active={})", unit_name, is_active);
let actions = UuctlProvider::actions_for_service(unit_name, display_name, is_active);
// Save current state
@@ -318,7 +436,11 @@ impl MainWindow {
hints_label: &Label,
search_entry: &Entry,
filter: &Rc<RefCell<ProviderFilter>>,
config: &Rc<RefCell<Config>>,
) {
#[cfg(feature = "dev-logging")]
debug!("[UI] Exiting submenu");
let saved_search = {
let mut state = submenu_state.borrow_mut();
state.active = false;
@@ -328,7 +450,7 @@ impl MainWindow {
// 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");
hints_label.set_label(&Self::build_hints(&config.borrow().providers));
search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow())));
search_entry.set_text(&saved_search);
@@ -341,6 +463,7 @@ impl MainWindow {
let providers = self.providers.clone();
let results_list = self.results_list.clone();
let config = self.config.clone();
let frecency = self.frecency.clone();
let current_results = self.current_results.clone();
let filter = self.filter.clone();
let mode_label = self.mode_label.clone();
@@ -401,21 +524,44 @@ impl MainWindow {
if parsed.prefix.is_some() {
let prefix_name = match parsed.prefix.unwrap() {
ProviderType::Application => "applications",
ProviderType::Bookmarks => "bookmarks",
ProviderType::Calculator => "calculator",
ProviderType::Clipboard => "clipboard",
ProviderType::Command => "commands",
ProviderType::Uuctl => "uuctl units",
ProviderType::Dmenu => "options",
ProviderType::Emoji => "emoji",
ProviderType::Files => "files",
ProviderType::Scripts => "scripts",
ProviderType::Ssh => "SSH hosts",
ProviderType::System => "system",
ProviderType::Uuctl => "uuctl units",
ProviderType::WebSearch => "web",
};
search_entry_for_change
.set_placeholder_text(Some(&format!("Search {}...", prefix_name)));
}
let max_results = config.borrow().general.max_results;
let results: Vec<LaunchItem> = providers
.borrow()
.search_filtered(&parsed.query, max_results, &filter.borrow())
.into_iter()
.map(|(item, _)| item)
.collect();
let cfg = config.borrow();
let max_results = cfg.general.max_results;
let frecency_weight = cfg.providers.frecency_weight;
let use_frecency = cfg.providers.frecency;
drop(cfg);
let results: Vec<LaunchItem> = if use_frecency {
providers
.borrow_mut()
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref())
.into_iter()
.map(|(item, _)| item)
.collect()
} else {
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);
@@ -437,6 +583,7 @@ impl MainWindow {
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 frecency_for_activate = self.frecency.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();
@@ -470,7 +617,7 @@ impl MainWindow {
);
} else {
// Execute the command
Self::launch_item(item, &config_for_activate.borrow());
Self::launch_item(item, &config_for_activate.borrow(), &frecency_for_activate);
window_for_activate.close();
}
}
@@ -506,17 +653,21 @@ impl MainWindow {
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 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();
let tab_order = self.tab_order.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);
#[cfg(feature = "dev-logging")]
debug!("[UI] Key pressed: {:?} (ctrl={}, shift={})", key, ctrl, shift);
match key {
Key::Escape => {
// If in submenu, exit submenu first
@@ -527,6 +678,7 @@ impl MainWindow {
&hints_label,
&search_entry,
&filter,
&config,
);
gtk4::glib::Propagation::Stop
} else {
@@ -543,6 +695,7 @@ impl MainWindow {
&hints_label,
&search_entry,
&filter,
&config,
);
gtk4::glib::Propagation::Stop
} else {
@@ -584,6 +737,7 @@ impl MainWindow {
&filter_buttons,
&search_entry,
&mode_label,
&tab_order,
!shift,
);
}
@@ -596,45 +750,37 @@ impl MainWindow {
&filter_buttons,
&search_entry,
&mode_label,
&tab_order,
false,
);
}
gtk4::glib::Propagation::Stop
}
// Ctrl+1/2/3 toggle specific providers (only when not in submenu)
Key::_1 if ctrl => {
// Ctrl+1-9 toggle specific providers based on tab order (only when not in submenu)
Key::_1 | Key::_2 | Key::_3 | Key::_4 | Key::_5 |
Key::_6 | Key::_7 | Key::_8 | Key::_9 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,
);
let idx = match key {
Key::_1 => 0,
Key::_2 => 1,
Key::_3 => 2,
Key::_4 => 3,
Key::_5 => 4,
Key::_6 => 5,
Key::_7 => 6,
Key::_8 => 7,
Key::_9 => 8,
_ => return gtk4::glib::Propagation::Proceed,
};
if let Some(&provider) = tab_order.get(idx) {
Self::toggle_provider_button(
provider,
&filter,
&filter_buttons,
&search_entry,
&mode_label,
);
}
}
gtk4::glib::Propagation::Stop
}
@@ -647,6 +793,7 @@ impl MainWindow {
// Double-click to launch
let current_results = self.current_results.clone();
let config = self.config.clone();
let frecency = self.frecency.clone();
let window = self.window.clone();
let submenu_state = self.submenu_state.clone();
let results_list_for_click = self.results_list.clone();
@@ -675,7 +822,7 @@ impl MainWindow {
is_active,
);
} else {
Self::launch_item(item, &config.borrow());
Self::launch_item(item, &config.borrow(), &frecency);
window.close();
}
}
@@ -687,24 +834,24 @@ impl MainWindow {
buttons: &Rc<RefCell<HashMap<ProviderType, ToggleButton>>>,
entry: &Entry,
mode_label: &Label,
tab_order: &[ProviderType],
forward: bool,
) {
let order = [
ProviderType::Application,
ProviderType::Command,
ProviderType::Uuctl,
];
if tab_order.is_empty() {
return;
}
let current = filter.borrow().enabled_providers();
let next = if current.len() == 1 {
let idx = order.iter().position(|p| p == &current[0]).unwrap_or(0);
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
if forward {
order[(idx + 1) % order.len()]
tab_order[(idx + 1) % tab_order.len()]
} else {
order[(idx + order.len() - 1) % order.len()]
tab_order[(idx + tab_order.len() - 1) % tab_order.len()]
}
} else {
ProviderType::Application
tab_order[0]
};
{
@@ -743,14 +890,27 @@ impl MainWindow {
}
fn update_results(&self, query: &str) {
let max_results = self.config.borrow().general.max_results;
let results: Vec<LaunchItem> = self
.providers
.borrow()
.search_filtered(query, max_results, &self.filter.borrow())
.into_iter()
.map(|(item, _)| item)
.collect();
let cfg = self.config.borrow();
let max_results = cfg.general.max_results;
let frecency_weight = cfg.providers.frecency_weight;
let use_frecency = cfg.providers.frecency;
drop(cfg);
let results: Vec<LaunchItem> = if use_frecency {
self.providers
.borrow_mut()
.search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight, None)
.into_iter()
.map(|(item, _)| item)
.collect()
} else {
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);
@@ -768,16 +928,45 @@ impl MainWindow {
*self.current_results.borrow_mut() = results;
}
fn launch_item(item: &LaunchItem, config: &Config) {
fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) {
// Record this launch for frecency tracking
if config.providers.frecency {
frecency.borrow_mut().record_launch(&item.id);
#[cfg(feature = "dev-logging")]
debug!("[UI] Recorded frecency launch for: {}", item.id);
}
info!("Launching: {} ({})", item.name, item.command);
#[cfg(feature = "dev-logging")]
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider);
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() {
// Use launch wrapper if configured (uwsm, hyprctl, etc.)
let result = match &config.general.launch_wrapper {
Some(wrapper) if !wrapper.is_empty() => {
info!("Using launch wrapper: {}", wrapper);
// Split wrapper into command and args (e.g., "uwsm app --" -> ["uwsm", "app", "--"])
let mut wrapper_parts: Vec<&str> = wrapper.split_whitespace().collect();
if wrapper_parts.is_empty() {
Command::new("sh").arg("-c").arg(&cmd).spawn()
} else {
let wrapper_cmd = wrapper_parts.remove(0);
Command::new(wrapper_cmd)
.args(&wrapper_parts)
.arg(&cmd)
.spawn()
}
}
_ => Command::new("sh").arg("-c").arg(&cmd).spawn(),
};
if let Err(e) = result {
log::error!("Failed to launch '{}': {}", item.name, e);
}
}

View File

@@ -32,9 +32,18 @@ impl ResultRow {
// Default icon based on provider type
let default_icon = match item.provider {
crate::providers::ProviderType::Application => "application-x-executable",
crate::providers::ProviderType::Bookmarks => "user-bookmarks",
crate::providers::ProviderType::Calculator => "accessories-calculator",
crate::providers::ProviderType::Clipboard => "edit-paste",
crate::providers::ProviderType::Command => "utilities-terminal",
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
crate::providers::ProviderType::Emoji => "face-smile",
crate::providers::ProviderType::Files => "folder",
crate::providers::ProviderType::Scripts => "application-x-executable",
crate::providers::ProviderType::Ssh => "network-server",
crate::providers::ProviderType::System => "system-shutdown",
crate::providers::ProviderType::Uuctl => "system-run",
crate::providers::ProviderType::WebSearch => "web-browser",
};
Image::from_icon_name(default_icon)
};
@@ -73,6 +82,25 @@ impl ResultRow {
text_box.append(&name_label);
}
// Tag badges (show first 3 tags)
if !item.tags.is_empty() {
let tags_box = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(4)
.halign(gtk4::Align::Start)
.build();
for tag in item.tags.iter().take(3) {
let tag_label = Label::builder()
.label(tag)
.build();
tag_label.add_css_class("owlry-tag-badge");
tags_box.append(&tag_label);
}
text_box.append(&tags_box);
}
// Provider badge
let badge = Label::builder()
.label(&item.provider.to_string())