53 Commits
v0.4.0 ... main

Author SHA1 Message Date
c0ea40a393 docs(config): sync example config with current features
- Add dmenu usage examples with | sh pattern
- Fix max_results default (10 → 100)
- Add widget providers (media, weather, pomodoro) with settings
- Add provider badge color customization options
- Add plugin sandbox settings section
- Fix disabled → disabled_plugins, add enabled and registry_url
- Add weather and pomodoro configuration options
2026-01-02 19:00:51 +01:00
44f0915ba9 docs: improve dmenu examples with proper output handling
- Clarify that dmenu outputs to stdout (doesn't execute)
- Add screenshot menu example with | sh pattern
- Use printf instead of echo -e for POSIX compliance
- Add xdg-open example for opening files
- Use shorter -p flag instead of --prompt
2026-01-02 18:56:19 +01:00
a55567b422 chore(owlry-rune): bump version to 0.4.10 2026-01-02 16:59:22 +01:00
707caefadf chore(owlry-lua): bump version to 0.4.10 2026-01-02 16:59:22 +01:00
78895d34b5 chore(plugins): bump all plugins to 0.4.10 2026-01-02 16:59:14 +01:00
e6f217f19c chore: bump version to 0.4.10 2026-01-02 16:59:06 +01:00
ff04675417 refactor(config): replace launch_wrapper with use_uwsm boolean
- Replace complex auto-detection with explicit use_uwsm config option
- Remove detect_launch_wrapper() function and hyprctl/uwsm auto-detection
- Use gio launch as default (always available via GTK4's glib2 dependency)
- When use_uwsm=true, launch via uwsm app -- for systemd session integration
- Add error handling for when uwsm is enabled but not installed
- Update documentation in README.md, CLAUDE.md, and config.example.toml
2026-01-02 16:57:40 +01:00
b85f85c4da feat(dmenu): add full dmenu compatibility
- Add free-form text input (output typed text when no item matches)
- Add proper exit codes (0=selection, 1=cancelled)
- Detect dmenu mode via ProviderManager::is_dmenu_mode()

This enables standard dmenu usage patterns like:
  echo -e "yes\nno" | owlry -m dmenu && echo "selected"
2026-01-02 16:36:40 +01:00
1aa92ee1e5 chore(owlry-rune): bump version to 0.4.9 2026-01-02 16:18:19 +01:00
9532b3cfde chore(owlry-lua): bump version to 0.4.9 2026-01-02 16:18:18 +01:00
551e5d74ae chore(plugins): bump all plugins to 0.4.9 2026-01-02 16:18:18 +01:00
60eaffb2ab chore: bump version to 0.4.9 2026-01-02 16:18:08 +01:00
6d8d4a9f89 fix(providers): improve app discovery and launch reliability
- Add Keywords field from desktop files to searchable tags
  (fixes apps like Nautilus not found when searching by legacy name)
- Respect XDG_DATA_DIRS with proper fallbacks for app directories
- Add Flatpak, Snap, and Nix application directory support
- Simplify desktop file launch to use gio directly (guaranteed by GTK4)
- Add desktop notifications for launch failures
- Check desktop file existence before launch attempt
2026-01-02 16:18:00 +01:00
3ef9398655 chore: bump all crates to 0.4.8 2026-01-01 23:30:45 +01:00
46bb4bfb38 chore: bump version to 0.4.8 2026-01-01 23:28:09 +01:00
c8aed5faf5 fix(dmenu): print selection to stdout instead of executing
dmenu mode was incorrectly trying to execute the selected item
as a command (via hyprctl/sh). Now it properly prints the
selection to stdout, enabling standard dmenu piping workflows
like: git branch | owlry -m dmenu | xargs git checkout
2026-01-01 23:28:03 +01:00
bf8a31af78 chore: bump all crates to 0.4.7 2026-01-01 22:29:00 +01:00
e23bdf5cee fix(providers): enable submenu support for static native plugins
Static native plugins (systemd, clipboard, etc.) were being boxed as
Box<dyn Provider>, which lost access to the query() method needed for
submenu support. The Provider trait only has refresh() and items().

Add static_native_providers field to keep static native plugins as
NativeProvider instances, preserving their query() method. Update all
search methods and query_submenu_actions() to include this new list.

Fixes systemd plugin submenu not showing actions when selecting a service.
2026-01-01 22:14:43 +01:00
25c4d40d36 docs: add comprehensive usage documentation
- Expand CLI --help with examples, dmenu mode, and search prefixes
- Add dmenu mode section to README with practical examples
- Add plugin management CLI reference to README
- Update argument descriptions with all valid modes listed
2026-01-01 21:45:52 +01:00
b36dd2a438 chore: update bump-all to include core in single commit 2025-12-30 20:32:28 +01:00
35a0f580c3 chore(owlry-rune): bump version to 0.4.6 2025-12-30 20:23:58 +01:00
7ed36c58c2 chore(owlry-lua): bump version to 0.4.6 2025-12-30 20:23:57 +01:00
7cccd3b512 chore(plugins): bump all plugins to 0.4.6 2025-12-30 20:23:48 +01:00
9f6d0c5935 chore: bump version to 0.4.6 2025-12-30 20:23:38 +01:00
026a232e0c docs: add ROADMAP.md with feature ideas
- High value/low effort: hot-reload, frecency pruning, :recent, clipboard images
- Medium effort: universal actions, plugin settings UI, result capture
- Bigger bets: window switcher, cross-device sync, natural language, plugin marketplace
- Technical debt: meval→evalexpr, API compat, per-plugin config

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:04:55 +01:00
1557119448 docs: comprehensive documentation update
README.md:
- Fix bundle package names (add meta- prefix)
- Add Firefox support to bookmarks plugin description
- Add system paths table (plugins, runtimes, example config)
- Add Quick Start section for copying example config
- Expand config example with providers section

docs/PLUGINS.md:
- Add Firefox support to bookmarks
- Fix bundle package names
- Remove outdated [plugins.weather] and [plugins.pomodoro] config examples

docs/PLUGIN_DEVELOPMENT.md:
- Fix Rust edition from 2024 to 2021
- Add position and priority fields to ProviderInfo
- Add ProviderPosition enum documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 08:49:30 +01:00
b814d07382 chore(owlry-rune): bump version to 0.4.5 2025-12-30 08:29:57 +01:00
0dead603ec chore(owlry-lua): bump version to 0.4.5 2025-12-30 08:29:57 +01:00
c1eb5ae2eb chore(plugins): bump all plugins to 0.4.5 2025-12-30 08:29:47 +01:00
07847c76d8 chore: bump version to 0.4.5 2025-12-30 08:29:25 +01:00
2dfce67f3b chore(owlry-rune): bump version to 0.4.4 2025-12-30 08:01:52 +01:00
b1198f4600 chore(owlry-lua): bump version to 0.4.4 2025-12-30 08:01:51 +01:00
e6776b803c chore(plugins): bump all plugins to 0.4.4 2025-12-30 08:01:15 +01:00
6e2d60466b chore: bump version to 0.4.4 2025-12-30 07:45:57 +01:00
8c1cf88474 feat: simplify ProviderType, add plugin priority, fix bookmarks SQLite
Core changes:
- Simplified ProviderType enum to 4 core types + Plugin(String)
- Added priority field to plugin API (API_VERSION = 3)
- Removed hardcoded plugin-specific code from core
- Updated filter.rs to use Plugin(type_id) for all plugins
- Updated main_window.rs UI mappings to derive from type_id
- Fixed weather/media SVG icon colors

Plugin changes:
- All plugins now declare their own priority values
- Widget plugins: weather(12000), pomodoro(11500), media(11000)
- Dynamic plugins: calc(10000), websearch(9000), filesearch(8000)
- Static plugins: priority 0 (frecency-based)

Bookmarks plugin:
- Replaced SQLx with rusqlite + bundled SQLite
- Fixes "undefined symbol: sqlite3_db_config" build errors
- No longer depends on system SQLite version

Config:
- Fixed config.example.toml invalid nested TOML sections
- Removed [providers.websearch], [providers.weather], etc.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:45:49 +01:00
ecaaae39e3 refactor(aur): rename meta packages to owlry-meta-*
Renamed for consistency:
- owlry-essentials → owlry-meta-essentials
- owlry-tools → owlry-meta-tools
- owlry-widgets → owlry-meta-widgets
- owlry-full → owlry-meta-full

New packages include replaces/conflicts for smooth transition.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:46:19 +01:00
96e9b09a31 docs(justfile): clarify meta-package static versioning
Meta-packages now use static 1.0.0 version, only bumping pkgrel
when dependencies change.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:42:54 +01:00
e053f7d5d5 refactor(justfile): simplify AUR update for unified versioning
Removed _srcver handling since all packages now share the same version.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:29:56 +01:00
b1f11c076b chore: unify all package versions to 0.4.3
All crates (core, plugins, runtimes, plugin-api) now share the same
version number for simpler release management and clearer compatibility.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:27:23 +01:00
2d7fb33f30 fix(bookmarks): fix test calling non-existent method
Changed test to use static method process_chrome_folder_static.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:26:31 +01:00
3b1ff03ff8 chore: bump version to 0.4.2 2025-12-30 06:22:15 +01:00
e1fb63d6c4 fix(tests): make runtime tests environment-agnostic
Tests now verify functions don't panic rather than assuming
runtimes aren't installed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:22:04 +01:00
33e2f9cb5e chore(owlry-plugin-weather): bump version to 0.2.2 2025-12-30 06:13:44 +01:00
6b21602a07 chore(owlry-plugin-pomodoro): bump version to 0.2.2 2025-12-30 06:13:44 +01:00
4516865c21 chore(owlry-plugin-emoji): bump version to 0.2.2 2025-12-30 06:13:43 +01:00
4fbc7fc4c9 chore(owlry-plugin-bookmarks): bump version to 0.2.2 2025-12-30 06:13:43 +01:00
536c5c5012 chore: bump version to 0.4.1 2025-12-30 06:12:02 +01:00
abd4df6939 feat: add lazy loading, non-blocking bookmarks, and file search fix
- Add lazy loading for result lists (load more on scroll/selection)
- Add non-blocking bookmark loading with JSON cache
- Add Firefox favicon extraction and caching
- Fix dynamic provider filtering (files/calc/websearch in All mode)
- Fix clippy warnings across core and plugins
- Add apex-neon theme
- Add aur/ to gitignore

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:11:50 +01:00
43f7228be2 feat(justfile): add bump-meta and aur-publish-meta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:54:13 +01:00
a1b47b8ba0 chore: bump all plugins and runtimes to 0.2.1 2025-12-30 03:49:36 +01:00
ccce9b8572 fix(justfile): handle _srcver for plugin AUR packages
Plugins use _srcver (core version) for source tarball, separate from
pkgver (plugin version). This allows independent plugin versioning
while still downloading from the core release tag.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:45:39 +01:00
ffb4c2f127 fix: prevent .SRCINFO creation in project root
- Use subshell for cd in aur-update-all recipe
- Add .SRCINFO to root .gitignore

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:40:08 +01:00
cde599db03 feat(justfile): add comprehensive release automation
- bump-all: bump plugins + runtimes together
- aur-update-all: update all 20 AUR packages
- aur-publish-all: publish all AUR packages
- release-all: complete release workflow (bump, tag, push, update AUR)
- release-core: core-only release workflow

Usage: just release-all 0.5.0 0.3.0
       (core version, plugin version)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:39:13 +01:00
65 changed files with 2619 additions and 825 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ aur/*/*.tar.gz
aur/*/*.tar.xz aur/*/*.tar.xz
aur/*/*.pkg.tar.* aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked # Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

92
Cargo.lock generated
View File

@@ -764,6 +764,18 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -1417,12 +1429,30 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -1694,7 +1724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.16.1",
] ]
[[package]] [[package]]
@@ -1807,6 +1837,17 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
@@ -2332,7 +2373,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry" name = "owlry"
version = "0.4.0" version = "0.4.10"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@@ -2361,7 +2402,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-lua" name = "owlry-lua"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"chrono", "chrono",
@@ -2379,7 +2420,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-api" name = "owlry-plugin-api"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"serde", "serde",
@@ -2387,18 +2428,19 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-bookmarks" name = "owlry-plugin-bookmarks"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"dirs", "dirs",
"owlry-plugin-api", "owlry-plugin-api",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
] ]
[[package]] [[package]]
name = "owlry-plugin-calculator" name = "owlry-plugin-calculator"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"meval", "meval",
@@ -2407,7 +2449,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-clipboard" name = "owlry-plugin-clipboard"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"owlry-plugin-api", "owlry-plugin-api",
@@ -2415,7 +2457,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-emoji" name = "owlry-plugin-emoji"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"owlry-plugin-api", "owlry-plugin-api",
@@ -2423,7 +2465,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-filesearch" name = "owlry-plugin-filesearch"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"dirs", "dirs",
@@ -2432,7 +2474,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-media" name = "owlry-plugin-media"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"owlry-plugin-api", "owlry-plugin-api",
@@ -2440,7 +2482,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-pomodoro" name = "owlry-plugin-pomodoro"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"dirs", "dirs",
@@ -2452,7 +2494,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-scripts" name = "owlry-plugin-scripts"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"dirs", "dirs",
@@ -2461,7 +2503,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-ssh" name = "owlry-plugin-ssh"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"dirs", "dirs",
@@ -2470,7 +2512,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-system" name = "owlry-plugin-system"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"owlry-plugin-api", "owlry-plugin-api",
@@ -2478,7 +2520,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-systemd" name = "owlry-plugin-systemd"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"owlry-plugin-api", "owlry-plugin-api",
@@ -2486,7 +2528,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-weather" name = "owlry-plugin-weather"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"dirs", "dirs",
@@ -2499,7 +2541,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-websearch" name = "owlry-plugin-websearch"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"owlry-plugin-api", "owlry-plugin-api",
@@ -2507,7 +2549,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-rune" name = "owlry-rune"
version = "0.2.0" version = "0.4.10"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs", "dirs",
@@ -3043,6 +3085,20 @@ dependencies = [
"syn 2.0.111", "syn 2.0.111",
] ]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"

123
README.md
View File

@@ -32,10 +32,10 @@ yay -S owlry
yay -S owlry-plugin-calculator owlry-plugin-weather yay -S owlry-plugin-calculator owlry-plugin-weather
# Or install bundles: # Or install bundles:
yay -S owlry-essentials # calculator, system, ssh, scripts, bookmarks yay -S owlry-meta-essentials # calculator, system, ssh, scripts, bookmarks
yay -S owlry-widgets # weather, media, pomodoro yay -S owlry-meta-widgets # weather, media, pomodoro
yay -S owlry-tools # clipboard, emoji, websearch, filesearch, systemd yay -S owlry-meta-tools # clipboard, emoji, websearch, filesearch, systemd
yay -S owlry-full # everything yay -S owlry-meta-full # everything
# For custom Lua/Rune plugins # For custom Lua/Rune plugins
yay -S owlry-lua # Lua 5.4 runtime yay -S owlry-lua # Lua 5.4 runtime
@@ -53,7 +53,7 @@ yay -S owlry-rune # Rune runtime
| `owlry-plugin-clipboard` | History via cliphist | | `owlry-plugin-clipboard` | History via cliphist |
| `owlry-plugin-emoji` | 400+ searchable emoji | | `owlry-plugin-emoji` | 400+ searchable emoji |
| `owlry-plugin-scripts` | User scripts | | `owlry-plugin-scripts` | User scripts |
| `owlry-plugin-bookmarks` | Chrome, Brave, Edge bookmarks | | `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
| `owlry-plugin-websearch` | Web search (`? query`) | | `owlry-plugin-websearch` | Web search (`? query`) |
| `owlry-plugin-filesearch` | File search (`/ filename`) | | `owlry-plugin-filesearch` | File search (`/ filename`) |
| `owlry-plugin-systemd` | User services with actions | | `owlry-plugin-systemd` | User services with actions |
@@ -99,12 +99,45 @@ sudo cp target/release/libowlry_plugin_*.so /usr/lib/owlry/plugins/
## Usage ## Usage
```bash ```bash
owlry # Launch with defaults owlry # Launch with all providers
owlry --mode app # Applications only owlry -m app # Applications only
owlry --providers app,cmd # Specific providers owlry -m cmd # PATH commands only
owlry --help # Show all options owlry -p app,cmd # Multiple specific providers
owlry -m calc # Calculator plugin only (if installed)
owlry --help # Show all options with examples
``` ```
### dmenu Mode
Owlry is dmenu-compatible. Pipe input for interactive selection - the selected item is printed to stdout (not executed), so you pipe the output to execute it:
```bash
# Screenshot menu (execute selected command)
printf '%s\n' \
"grimblast --notify copy screen" \
"grimblast --notify copy area" \
"grimblast --notify edit screen" \
| owlry -m dmenu -p "Screenshot" \
| sh
# Git branch checkout
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
# Kill a process
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
# Select and open a project
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
# Package manager search
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
# Open selected file
ls ~/Documents | owlry -m dmenu | xargs xdg-open
```
The `-p` / `--prompt` flag sets a custom label for the search input.
### Keyboard Shortcuts ### Keyboard Shortcuts
| Key | Action | | Key | Action |
@@ -158,6 +191,21 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| `~/.local/share/owlry/scripts/` | User scripts | | `~/.local/share/owlry/scripts/` | User scripts |
| `~/.local/share/owlry/frecency.json` | Usage history | | `~/.local/share/owlry/frecency.json` | Usage history |
System locations:
| Path | Purpose |
|------|---------|
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
| `/usr/lib/owlry/runtimes/*.so` | Lua/Rune script runtimes |
| `/usr/share/doc/owlry/config.example.toml` | Example configuration |
### Quick Start
```bash
# Copy example config
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
```
### Example Configuration ### Example Configuration
```toml ```toml
@@ -165,12 +213,12 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
show_icons = true show_icons = true
max_results = 10 max_results = 10
tabs = ["app", "cmd", "uuctl"] tabs = ["app", "cmd", "uuctl"]
# terminal_command = "kitty" # Auto-detected # terminal_command = "kitty" # Auto-detected
# launch_wrapper = "uwsm app --" # Auto-detected # use_uwsm = false # Enable for systemd session integration
[appearance] [appearance]
width = 700 width = 850
height = 500 height = 650
font_size = 14 font_size = 14
border_radius = 12 border_radius = 12
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc. # theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
@@ -178,17 +226,18 @@ border_radius = 12
[plugins] [plugins]
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"] disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
# Per-plugin configuration (new in 0.4.0) [providers]
[plugins.weather] applications = true # .desktop files
provider = "wttr.in" # or: openweathermap, open-meteo commands = true # PATH executables
location = "Berlin" # city name or "lat,lon" frecency = true # Boost frequently used items
# api_key = "..." # Required for OpenWeatherMap frecency_weight = 0.3 # 0.0-1.0
[plugins.pomodoro] # Web search engine: google, duckduckgo, bing, startpage, brave, ecosia
work_mins = 25 # Work session duration search_engine = "duckduckgo"
break_mins = 5 # Break duration
``` ```
See `/usr/share/doc/owlry/config.example.toml` for all options with documentation.
## Plugin System ## Plugin System
Owlry uses a modular plugin architecture. Plugins are loaded from: Owlry uses a modular plugin architecture. Plugins are loaded from:
@@ -205,6 +254,38 @@ Add plugin IDs to the disabled list in your config:
disabled = ["emoji", "pomodoro"] disabled = ["emoji", "pomodoro"]
``` ```
### Plugin Management CLI
```bash
# List installed plugins
owlry plugin list
owlry plugin list --enabled # Only enabled
owlry plugin list --available # Show registry plugins
# Search registry
owlry plugin search "weather"
# Install/remove
owlry plugin install <name> # From registry
owlry plugin install ./my-plugin # From local path
owlry plugin remove <name>
# Enable/disable
owlry plugin enable <name>
owlry plugin disable <name>
# Plugin info
owlry plugin info <name>
owlry plugin commands <name> # List plugin CLI commands
# Create new plugin
owlry plugin create my-plugin # Lua (default)
owlry plugin create my-plugin -r rune # Rune
# Run plugin command
owlry plugin run <plugin-id> <command> [args...]
```
### Creating Custom Plugins ### Creating Custom Plugins
See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for: See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:

109
ROADMAP.md Normal file
View File

@@ -0,0 +1,109 @@
# Owlry Roadmap
Feature ideas and future development plans for Owlry.
## High Value, Low Effort
### Plugin hot-reload
Detect `.so` file changes in `/usr/lib/owlry/plugins/` and reload without restarting the launcher. The loader infrastructure already exists.
### Frecency pruning
Add `max_entries` and `max_age_days` config options. Prune old entries on startup to prevent `frecency.json` from growing unbounded.
### `:recent` prefix
Show last N launched items. Data already exists in frecency.json — just needs a provider to surface it.
### Clipboard images
`cliphist` supports images. Extend the clipboard plugin to show image thumbnails in results.
---
## Medium Effort, High Value
### Actions on any result
Generalize the submenu system beyond systemd. Every result type gets contextual actions:
| Provider | Actions |
|----------|---------|
| Applications | Open, Open in terminal, Show .desktop location |
| Files | Open, Open folder, Copy path, Delete |
| SSH | Connect, Copy hostname, Edit config |
| Bookmarks | Open, Copy URL, Open incognito |
| Clipboard | Paste, Delete from history |
This is the difference between a launcher and a command palette.
### Plugin settings UI
A `:settings` provider that lists installed plugins and their configurable options. Edit values inline, writes to `config.toml`.
### Result action capture
Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of "launching" it. Useful for calculator, file paths, URLs.
---
## Bigger Bets
### Window switcher with live thumbnails
A `windows` plugin using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab.
### Cross-device bookmark sync
Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices" or "bookmarks from phone".
### Natural language commands
Parse simple natural language into system commands:
```
"shutdown in 30 minutes" → systemd-run --user --on-active=30m systemctl poweroff
"remind me in 1 hour" → notify-send scheduled via at/systemd timer
"volume 50%" → wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.5
```
Local pattern matching, no AI/cloud required.
### Plugin marketplace
A curated registry of third-party Lua/Rune plugins with one-command install:
```bash
owlry plugin install github-notifications
owlry plugin install todoist
owlry plugin install spotify-controls
```
The script runtimes make this viable without recompiling.
---
## Technical Debt
### Split monorepo for user build efficiency
Currently, a small core fix requires all 16 AUR packages to rebuild (same source tarball). Split into 3 repos:
| Repo | Contents | Versioning |
|------|----------|------------|
| `owlry` | Core binary | Independent |
| `owlry-plugin-api` | ABI interface (crates.io) | Semver, conservative |
| `owlry-plugins` | 13 plugins + 2 runtimes | Independent per plugin |
**Execution order:**
1. Publish `owlry-plugin-api` to crates.io
2. Update monorepo to use crates.io dependency
3. Create `owlry-plugins` repo, move plugins + runtimes
4. Slim current repo to core-only
5. Update AUR PKGBUILDs with new source URLs
**Benefit:** Core bugfix = 1 rebuild. Plugin fix = 1 rebuild. Third-party plugins possible via crates.io.
### Replace meval with evalexpr
`meval` depends on `nom v1.2.4` which will be rejected by future Rust versions. Migrate calculator plugin and Lua runtime to `evalexpr` v13+.
### Plugin API backwards compatibility
When `API_VERSION` increments, provide a compatibility shim so v3 plugins work with v4 core. Prevents ecosystem fragmentation.
### Per-plugin configuration
Current flat `[providers]` config doesn't scale. Design a `[plugins.weather]`, `[plugins.pomodoro]` structure that plugins can declare and the core validates.
---
## Priority
If we had to pick one: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive.

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-lua" name = "owlry-lua"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-api" name = "owlry-plugin-api"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -31,7 +31,9 @@ use abi_stable::StableAbi;
pub use abi_stable::std_types::{ROption, RStr, RString, RVec}; pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
/// Current plugin API version - plugins must match this /// Current plugin API version - plugins must match this
pub const API_VERSION: u32 = 1; /// v2: Added ProviderPosition for widget support
/// v3: Added priority field for plugin-declared result ordering
pub const API_VERSION: u32 = 3;
/// Plugin metadata returned by the info function /// Plugin metadata returned by the info function
#[repr(C)] #[repr(C)]
@@ -65,6 +67,14 @@ pub struct ProviderInfo {
pub provider_type: ProviderKind, pub provider_type: ProviderKind,
/// Short type identifier for UI badges (e.g., "calc", "web") /// Short type identifier for UI badges (e.g., "calc", "web")
pub type_id: RString, pub type_id: RString,
/// Display position (Normal or Widget)
pub position: ProviderPosition,
/// Priority for result ordering (higher values appear first)
/// Suggested ranges:
/// - Widgets: 10000-12000
/// - Dynamic providers: 7000-10000
/// - Static providers: 0-5000 (use 0 for frecency-based ordering)
pub priority: i32,
} }
/// Provider behavior type /// Provider behavior type
@@ -77,6 +87,20 @@ pub enum ProviderKind {
Dynamic, Dynamic,
} }
/// Provider display position
///
/// Controls where in the result list this provider's items appear.
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ProviderPosition {
/// Standard position in results (sorted by score/frecency)
#[default]
Normal,
/// Widget position - appears at top of results when query is empty
/// Widgets are always visible regardless of filter settings
Widget,
}
/// A single searchable/launchable item returned by providers /// A single searchable/launchable item returned by providers
#[repr(C)] #[repr(C)]
#[derive(StableAbi, Clone, Debug)] #[derive(StableAbi, Clone, Debug)]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-bookmarks" name = "owlry-plugin-bookmarks"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
@@ -25,3 +25,7 @@ dirs = "5.0"
# For parsing Chrome bookmarks JSON # For parsing Chrome bookmarks JSON
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
# For reading Firefox bookmarks (places.sqlite)
# Use bundled SQLite to avoid system library version conflicts
rusqlite = { version = "0.32", features = ["bundled"] }

View File

@@ -1,9 +1,9 @@
//! Bookmarks Plugin for Owlry //! Bookmarks Plugin for Owlry
//! //!
//! A static provider that reads browser bookmarks from Chrome/Chromium. //! A static provider that reads browser bookmarks from various browsers.
//! Firefox support would require the rusqlite crate for reading places.sqlite.
//! //!
//! Supported browsers: //! Supported browsers:
//! - Firefox (via places.sqlite using rusqlite with bundled SQLite)
//! - Chrome //! - Chrome
//! - Chromium //! - Chromium
//! - Brave //! - Brave
@@ -11,11 +11,17 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
use rusqlite::{Connection, OpenFlags};
use serde::Deserialize; use serde::Deserialize;
use std::fs; use std::fs;
use std::path::PathBuf; use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
// Plugin metadata // Plugin metadata
const PLUGIN_ID: &str = "bookmarks"; const PLUGIN_ID: &str = "bookmarks";
@@ -27,17 +33,141 @@ const PLUGIN_DESCRIPTION: &str = "Browser bookmark search";
const PROVIDER_ID: &str = "bookmarks"; const PROVIDER_ID: &str = "bookmarks";
const PROVIDER_NAME: &str = "Bookmarks"; const PROVIDER_NAME: &str = "Bookmarks";
const PROVIDER_PREFIX: &str = ":bm"; const PROVIDER_PREFIX: &str = ":bm";
const PROVIDER_ICON: &str = "web-browser"; const PROVIDER_ICON: &str = "user-bookmarks-symbolic";
const PROVIDER_TYPE_ID: &str = "bookmarks"; const PROVIDER_TYPE_ID: &str = "bookmarks";
/// Bookmarks provider state - holds cached items /// Bookmarks provider state - holds cached items
struct BookmarksState { struct BookmarksState {
/// Cached bookmark items (returned immediately on refresh)
items: Vec<PluginItem>, items: Vec<PluginItem>,
/// Flag to prevent concurrent background loads
loading: Arc<AtomicBool>,
} }
impl BookmarksState { impl BookmarksState {
fn new() -> Self { fn new() -> Self {
Self { items: Vec::new() } Self {
items: Vec::new(),
loading: Arc::new(AtomicBool::new(false)),
}
}
/// Get or create the favicon cache directory
fn favicon_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/favicons"))
}
/// Ensure the favicon cache directory exists
fn ensure_favicon_cache_dir() -> Option<PathBuf> {
Self::favicon_cache_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
Some(dir)
})
}
/// Hash a URL to create a cache filename
fn url_to_cache_filename(url: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
format!("{:016x}.png", hasher.finish())
}
/// Get the bookmark cache file path
fn bookmark_cache_file() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json"))
}
/// Load cached bookmarks from disk (fast)
fn load_cached_bookmarks() -> Vec<PluginItem> {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return Vec::new(),
};
if !cache_file.exists() {
return Vec::new();
}
let content = match fs::read_to_string(&cache_file) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Parse cached bookmarks (simple JSON format)
#[derive(serde::Deserialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = match serde_json::from_str(&content) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
cached
.into_iter()
.map(|b| {
let mut item = PluginItem::new(b.id, b.name, b.command)
.with_icon(&b.icon)
.with_keywords(vec!["bookmark".to_string()]);
if let Some(desc) = b.description {
item = item.with_description(desc);
}
item
})
.collect()
}
/// Save bookmarks to cache file
fn save_cached_bookmarks(items: &[PluginItem]) {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return,
};
// Ensure cache directory exists
if let Some(parent) = cache_file.parent() {
let _ = fs::create_dir_all(parent);
}
#[derive(serde::Serialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = items
.iter()
.map(|item| {
let desc: Option<String> = match &item.description {
abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()),
abi_stable::std_types::ROption::RNone => None,
};
let icon: String = match &item.icon {
abi_stable::std_types::ROption::RSome(s) => s.to_string(),
abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(),
};
CachedBookmark {
id: item.id.to_string(),
name: item.name.to_string(),
command: item.command.to_string(),
description: desc,
icon,
}
})
.collect();
if let Ok(json) = serde_json::to_string(&cached) {
let _ = fs::write(&cache_file, json);
}
} }
fn chromium_bookmark_paths() -> Vec<PathBuf> { fn chromium_bookmark_paths() -> Vec<PathBuf> {
@@ -61,18 +191,77 @@ impl BookmarksState {
paths paths
} }
fn load_bookmarks(&mut self) { fn firefox_places_paths() -> Vec<PathBuf> {
self.items.clear(); let mut paths = Vec::new();
// Load Chrome/Chromium bookmarks if let Some(home) = dirs::home_dir() {
for path in Self::chromium_bookmark_paths() { let firefox_dir = home.join(".mozilla/firefox");
if path.exists() { if firefox_dir.exists() {
self.read_chrome_bookmarks(&path); // Find all profile directories
if let Ok(entries) = fs::read_dir(&firefox_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let places = path.join("places.sqlite");
if places.exists() {
paths.push(places);
}
}
}
}
} }
} }
paths
}
/// Find Firefox favicons.sqlite paths (paired with places.sqlite)
fn firefox_favicons_path(places_path: &Path) -> Option<PathBuf> {
let favicons = places_path.parent()?.join("favicons.sqlite");
if favicons.exists() {
Some(favicons)
} else {
None
}
} }
fn read_chrome_bookmarks(&mut self, path: &PathBuf) { fn load_bookmarks(&mut self) {
// Fast path: load from cache immediately
if self.items.is_empty() {
self.items = Self::load_cached_bookmarks();
}
// Don't start another background load if one is already running
if self.loading.swap(true, Ordering::SeqCst) {
return;
}
// Spawn background thread to refresh bookmarks
let loading = self.loading.clone();
thread::spawn(move || {
let mut items = Vec::new();
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks_static(&path, &mut items);
}
}
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
for path in Self::firefox_places_paths() {
Self::read_firefox_bookmarks(&path, &mut items);
}
// Save to cache for next startup
Self::save_cached_bookmarks(&items);
loading.store(false, Ordering::SeqCst);
});
}
/// Read Chrome bookmarks (static helper for background thread)
fn read_chrome_bookmarks_static(path: &PathBuf, items: &mut Vec<PluginItem>) {
let content = match fs::read_to_string(path) { let content = match fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,
Err(_) => return, Err(_) => return,
@@ -83,29 +272,27 @@ impl BookmarksState {
Err(_) => return, Err(_) => return,
}; };
// Process bookmark bar and other folders
if let Some(roots) = bookmarks.roots { if let Some(roots) = bookmarks.roots {
if let Some(bar) = roots.bookmark_bar { if let Some(bar) = roots.bookmark_bar {
self.process_chrome_folder(&bar); Self::process_chrome_folder_static(&bar, items);
} }
if let Some(other) = roots.other { if let Some(other) = roots.other {
self.process_chrome_folder(&other); Self::process_chrome_folder_static(&other, items);
} }
if let Some(synced) = roots.synced { if let Some(synced) = roots.synced {
self.process_chrome_folder(&synced); Self::process_chrome_folder_static(&synced, items);
} }
} }
} }
fn process_chrome_folder(&mut self, folder: &ChromeBookmarkNode) { fn process_chrome_folder_static(folder: &ChromeBookmarkNode, items: &mut Vec<PluginItem>) {
if let Some(ref children) = folder.children { if let Some(ref children) = folder.children {
for child in children { for child in children {
match child.node_type.as_deref() { match child.node_type.as_deref() {
Some("url") => { Some("url") => {
if let Some(ref url) = child.url { if let Some(ref url) = child.url {
let name = child.name.clone().unwrap_or_else(|| url.clone()); let name = child.name.clone().unwrap_or_else(|| url.clone());
items.push(
self.items.push(
PluginItem::new( PluginItem::new(
format!("bookmark:{}", url), format!("bookmark:{}", url),
name, name,
@@ -113,19 +300,183 @@ impl BookmarksState {
) )
.with_description(url.clone()) .with_description(url.clone())
.with_icon(PROVIDER_ICON) .with_icon(PROVIDER_ICON)
.with_keywords(vec!["bookmark".to_string(), "web".to_string()]), .with_keywords(vec!["bookmark".to_string(), "chrome".to_string()]),
); );
} }
} }
Some("folder") => { Some("folder") => {
// Recursively process subfolders Self::process_chrome_folder_static(child, items);
self.process_chrome_folder(child);
} }
_ => {} _ => {}
} }
} }
} }
} }
/// Read Firefox bookmarks using rusqlite (synchronous, bundled SQLite)
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
let temp_dir = std::env::temp_dir();
let temp_db = temp_dir.join("owlry_places_temp.sqlite");
// Copy database to temp location to avoid locking issues
if fs::copy(places_path, &temp_db).is_err() {
return;
}
// Also copy WAL file if it exists
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
// Copy favicons database if available
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite");
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let cache_dir = Self::ensure_favicon_cache_dir();
// Read bookmarks from places.sqlite
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
// Clean up temp files
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
for (title, url, favicon_path) in bookmarks {
let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string());
items.push(
PluginItem::new(
format!("bookmark:firefox:{}", url),
title,
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
)
.with_description(url)
.with_icon(&icon)
.with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]),
);
}
}
/// Fetch Firefox bookmarks with optional favicons
fn fetch_firefox_bookmarks(
places_path: &Path,
favicons_path: &Path,
cache_dir: Option<&PathBuf>,
) -> Vec<(String, String, Option<String>)> {
// Open places.sqlite in read-only mode
let conn = match Connection::open_with_flags(
places_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Query bookmarks joining moz_bookmarks with moz_places
// type=1 means URL bookmarks (not folders, separators, etc.)
let query = r#"
SELECT b.title, p.url
FROM moz_bookmarks b
JOIN moz_places p ON b.fk = p.id
WHERE b.type = 1
AND p.url NOT LIKE 'place:%'
AND p.url NOT LIKE 'about:%'
AND b.title IS NOT NULL
AND b.title != ''
ORDER BY b.dateAdded DESC
LIMIT 500
"#;
let mut stmt = match conn.prepare(query) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let bookmarks: Vec<(String, String)> = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.ok()
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
// If no favicons or cache dir, return without favicons
let cache_dir = match cache_dir {
Some(c) => c,
None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Try to open favicons database
let fav_conn = match Connection::open_with_flags(
favicons_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Fetch favicons for each URL
let mut results = Vec::new();
for (title, url) in bookmarks {
let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir);
results.push((title, url, favicon_path));
}
results
}
/// Get favicon for a URL, caching to file if needed
fn get_favicon_for_url(
conn: &Connection,
page_url: &str,
cache_dir: &Path,
) -> Option<String> {
// Check if already cached
let cache_filename = Self::url_to_cache_filename(page_url);
let cache_path = cache_dir.join(&cache_filename);
if cache_path.exists() {
return Some(cache_path.to_string_lossy().to_string());
}
// Query favicon data from database
// Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons
// Prefer smaller icons (32px) for efficiency
let query = r#"
SELECT i.data
FROM moz_pages_w_icons p
JOIN moz_icons_to_pages ip ON p.id = ip.page_id
JOIN moz_icons i ON ip.icon_id = i.id
WHERE p.page_url = ?
AND i.data IS NOT NULL
ORDER BY ABS(i.width - 32) ASC
LIMIT 1
"#;
let data: Option<Vec<u8>> = conn
.query_row(query, [page_url], |row| row.get(0))
.ok();
let data = data?;
if data.is_empty() {
return None;
}
// Write favicon data to cache file
let mut file = fs::File::create(&cache_path).ok()?;
file.write_all(&data).ok()?;
Some(cache_path.to_string_lossy().to_string())
}
} }
// Chrome bookmark JSON structures // Chrome bookmark JSON structures
@@ -172,6 +523,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}] }]
.into() .into()
} }
@@ -241,6 +594,14 @@ mod tests {
assert!(!paths.is_empty()); assert!(!paths.is_empty());
} }
#[test]
fn test_firefox_paths() {
// This will find paths if Firefox is installed
let paths = BookmarksState::firefox_places_paths();
// Path detection should work (may be empty if Firefox not installed)
let _ = paths.len(); // Just ensure it doesn't panic
}
#[test] #[test]
fn test_parse_chrome_bookmarks() { fn test_parse_chrome_bookmarks() {
let json = r#"{ let json = r#"{
@@ -271,7 +632,7 @@ mod tests {
#[test] #[test]
fn test_process_folder() { fn test_process_folder() {
let mut state = BookmarksState::new(); let mut items = Vec::new();
let folder = ChromeBookmarkNode { let folder = ChromeBookmarkNode {
name: Some("Test Folder".to_string()), name: Some("Test Folder".to_string()),
@@ -287,9 +648,9 @@ mod tests {
]), ]),
}; };
state.process_chrome_folder(&folder); BookmarksState::process_chrome_folder_static(&folder, &mut items);
assert_eq!(state.items.len(), 1); assert_eq!(items.len(), 1);
assert_eq!(state.items[0].name.as_str(), "Test Bookmark"); assert_eq!(items[0].name.as_str(), "Test Bookmark");
} }
#[test] #[test]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-calculator" name = "owlry-plugin-calculator"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -10,7 +10,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
// Plugin metadata // Plugin metadata
@@ -51,6 +52,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic, provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 10000, // Dynamic: calculator results first
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-clipboard" name = "owlry-plugin-clipboard"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -9,7 +9,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
use std::process::Command; use std::process::Command;
@@ -137,6 +138,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-emoji" name = "owlry-plugin-emoji"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -9,7 +9,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
// Plugin metadata // Plugin metadata
@@ -423,6 +424,7 @@ impl EmojiState {
name.to_string(), name.to_string(),
format!("printf '%s' '{}' | wl-copy", emoji), format!("printf '%s' '{}' | wl-copy", emoji),
) )
.with_icon(*emoji) // Use emoji character as icon
.with_description(format!("{} {}", emoji, keywords)) .with_description(format!("{} {}", emoji, keywords))
.with_keywords(vec![name.to_string(), keywords.to_string()]), .with_keywords(vec![name.to_string(), keywords.to_string()]),
); );
@@ -452,6 +454,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-filesearch" name = "owlry-plugin-filesearch"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -12,7 +12,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
@@ -207,6 +208,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic, provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 8000, // Dynamic: file search
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-media" name = "owlry-plugin-media"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -5,7 +5,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
use std::process::Command; use std::process::Command;
@@ -355,6 +356,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Widget,
priority: 11000, // Widget: media player
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-pomodoro" name = "owlry-plugin-pomodoro"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -16,7 +16,7 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
notify_with_urgency, owlry_plugin, NotifyUrgency, PluginInfo, PluginItem, ProviderHandle, notify_with_urgency, owlry_plugin, NotifyUrgency, PluginInfo, PluginItem, ProviderHandle,
ProviderInfo, ProviderKind, API_VERSION, ProviderInfo, ProviderKind, ProviderPosition, API_VERSION,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
@@ -57,31 +57,31 @@ impl PomodoroConfig {
let config_content = config_path let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok()); .and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content { if let Some(content) = config_content
if let Ok(toml) = content.parse::<toml::Table>() { && let Ok(toml) = content.parse::<toml::Table>()
// Try [plugins.pomodoro] first (new format) {
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) { // Try [plugins.pomodoro] first (new format)
if let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table()) { if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
return Self::from_toml_table(pomodoro); && let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table())
} {
} return Self::from_toml_table(pomodoro);
}
// Fallback to [providers] section (old format) // Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) { if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let work_mins = providers let work_mins = providers
.get("pomodoro_work_mins") .get("pomodoro_work_mins")
.and_then(|v| v.as_integer()) .and_then(|v| v.as_integer())
.map(|v| v as u32) .map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS); .unwrap_or(DEFAULT_WORK_MINS);
let break_mins = providers let break_mins = providers
.get("pomodoro_break_mins") .get("pomodoro_break_mins")
.and_then(|v| v.as_integer()) .and_then(|v| v.as_integer())
.map(|v| v as u32) .map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS); .unwrap_or(DEFAULT_BREAK_MINS);
return Self { work_mins, break_mins }; return Self { work_mins, break_mins };
}
} }
} }
@@ -396,6 +396,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Widget,
priority: 11500, // Widget: pomodoro timer
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-scripts" name = "owlry-plugin-scripts"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -12,7 +12,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
use std::fs; use std::fs;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -187,6 +188,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-ssh" name = "owlry-plugin-ssh"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -9,7 +9,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -204,6 +205,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-system" name = "owlry-plugin-system"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -13,7 +13,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
// Plugin metadata // Plugin metadata
@@ -129,6 +130,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-systemd" name = "owlry-plugin-systemd"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -10,7 +10,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
use std::process::Command; use std::process::Command;
@@ -285,6 +286,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-weather" name = "owlry-plugin-weather"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -20,7 +20,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
@@ -82,41 +83,41 @@ impl WeatherConfig {
let config_content = config_path let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok()); .and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content { if let Some(content) = config_content
if let Ok(toml) = content.parse::<toml::Table>() { && let Ok(toml) = content.parse::<toml::Table>()
// Try [plugins.weather] first (new format) {
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) { // Try [plugins.weather] first (new format)
if let Some(weather) = plugins.get("weather").and_then(|v| v.as_table()) { if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
return Self::from_toml_table(weather); && let Some(weather) = plugins.get("weather").and_then(|v| v.as_table())
} {
} return Self::from_toml_table(weather);
}
// Fallback to [providers] section (old format) // Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) { if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let provider_str = providers let provider_str = providers
.get("weather_provider") .get("weather_provider")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("wttr.in"); .unwrap_or("wttr.in");
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn); let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let api_key = providers let api_key = providers
.get("weather_api_key") .get("weather_api_key")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from); .map(String::from);
let location = providers let location = providers
.get("weather_location") .get("weather_location")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
return Self { return Self {
provider, provider,
api_key, api_key,
location, location,
}; };
}
} }
} }
@@ -642,6 +643,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Widget,
priority: 12000, // Widget: highest priority
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-plugin-websearch" name = "owlry-plugin-websearch"
version = "0.2.0" version = "0.4.10"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -10,7 +10,8 @@
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
}; };
// Plugin metadata // Plugin metadata
@@ -164,6 +165,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from(PROVIDER_ICON), icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic, provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID), type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 9000, // Dynamic: web search
}] }]
.into() .into()
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-rune" name = "owlry-rune"
version = "0.2.0" version = "0.4.10"
edition = "2024" edition = "2024"
rust-version = "1.90" rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins" description = "Rune scripting runtime for owlry plugins"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry" name = "owlry"
version = "0.4.0" version = "0.4.10"
edition = "2024" edition = "2024"
rust-version = "1.90" rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland" description = "A lightweight, owl-themed application launcher for Wayland"

View File

@@ -56,7 +56,10 @@ impl OwlryApp {
let native_providers = Self::load_native_plugins(&config.borrow()); let native_providers = Self::load_native_plugins(&config.borrow());
// Create provider manager with native plugins // Create provider manager with native plugins
#[cfg(feature = "lua")]
let mut provider_manager = ProviderManager::with_native_plugins(native_providers); let mut provider_manager = ProviderManager::with_native_plugins(native_providers);
#[cfg(not(feature = "lua"))]
let provider_manager = ProviderManager::with_native_plugins(native_providers);
// Load Lua plugins if enabled (requires lua feature) // Load Lua plugins if enabled (requires lua feature)
#[cfg(feature = "lua")] #[cfg(feature = "lua")]
@@ -75,7 +78,7 @@ impl OwlryApp {
); );
let filter = Rc::new(RefCell::new(filter)); let filter = Rc::new(RefCell::new(filter));
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone()); let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone(), args.prompt.clone());
// Set up layer shell for Wayland overlay behavior // Set up layer shell for Wayland overlay behavior
window.init_layer_shell(); window.init_layer_shell();

View File

@@ -10,17 +10,57 @@ use crate::providers::ProviderType;
#[command( #[command(
name = "owlry", name = "owlry",
about = "An owl-themed application launcher for Wayland", about = "An owl-themed application launcher for Wayland",
version long_about = "An owl-themed application launcher for Wayland, built with GTK4 and Layer Shell.\n\n\
Owlry provides fuzzy search across applications, commands, and plugins.\n\
Native plugins add features like calculator, clipboard, emoji, weather, and more.",
version,
after_help = "\
EXAMPLES:
owlry Launch with all providers
owlry -m app Applications only
owlry -m cmd PATH commands only
owlry -m dmenu dmenu-compatible mode (reads from stdin)
owlry -p app,cmd Multiple providers
owlry -m calc Calculator plugin only (if installed)
DMENU MODE:
Pipe input to owlry for interactive selection:
echo -e \"Option A\\nOption B\" | owlry -m dmenu
ls | owlry -m dmenu
git branch | owlry -m dmenu --prompt \"checkout:\"
SEARCH PREFIXES:
:app firefox Search applications
:cmd git Search PATH commands
= 5+3 Calculator (requires plugin)
? rust docs Web search (requires plugin)
/ .bashrc File search (requires plugin)
For configuration, see ~/.config/owlry/config.toml
For plugin management, see: owlry plugin --help"
)] )]
pub struct CliArgs { pub struct CliArgs {
/// Start in single-provider mode (app, cmd, uuctl) /// Start in single-provider mode
#[arg(long, short = 'm', value_parser = parse_provider)] ///
/// Core modes: app, cmd, dmenu
/// Plugin modes: calc, clip, emoji, ssh, sys, bm, file, web, uuctl, weather, media, pomodoro
#[arg(long, short = 'm', value_parser = parse_provider, value_name = "MODE")]
pub mode: Option<ProviderType>, pub mode: Option<ProviderType>,
/// Comma-separated list of enabled providers (app,cmd,uuctl) /// Comma-separated list of enabled providers
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)] ///
/// Examples: -p app,cmd or -p app,calc,emoji
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider, value_name = "PROVIDERS")]
pub providers: Option<Vec<ProviderType>>, pub providers: Option<Vec<ProviderType>>,
/// Custom prompt text for the search input
///
/// Useful in dmenu mode to indicate what the user is selecting.
/// Example: --prompt "Select file:"
#[arg(long, value_name = "TEXT")]
pub prompt: Option<String>,
/// Subcommand to run (if any) /// Subcommand to run (if any)
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,

View File

@@ -6,10 +6,13 @@ use std::process::Command;
use crate::paths; use crate::paths;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config { pub struct Config {
#[serde(default)]
pub general: GeneralConfig, pub general: GeneralConfig,
#[serde(default)]
pub appearance: AppearanceConfig, pub appearance: AppearanceConfig,
#[serde(default)]
pub providers: ProvidersConfig, pub providers: ProvidersConfig,
#[serde(default)] #[serde(default)]
pub plugins: PluginsConfig, pub plugins: PluginsConfig,
@@ -17,20 +20,41 @@ pub struct Config {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig { pub struct GeneralConfig {
#[serde(default = "default_true")]
pub show_icons: bool, pub show_icons: bool,
#[serde(default = "default_max_results")]
pub max_results: usize, pub max_results: usize,
pub terminal_command: String, /// Terminal command (auto-detected if not specified)
/// 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)] #[serde(default)]
pub launch_wrapper: Option<String>, pub terminal_command: Option<String>,
/// Enable uwsm (Universal Wayland Session Manager) for launching apps.
/// When enabled, desktop files are launched via `uwsm app -- <file>`
/// which starts apps in a proper systemd user session.
/// When disabled (default), apps are launched via `gio launch`.
#[serde(default)]
pub use_uwsm: bool,
/// Provider tabs shown in the header bar. /// Provider tabs shown in the header bar.
/// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web /// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
#[serde(default = "default_tabs")] #[serde(default = "default_tabs")]
pub tabs: Vec<String>, pub tabs: Vec<String>,
} }
impl Default for GeneralConfig {
fn default() -> Self {
Self {
show_icons: true,
max_results: 100,
terminal_command: None,
use_uwsm: false,
tabs: default_tabs(),
}
}
}
fn default_max_results() -> usize {
100
}
fn default_tabs() -> Vec<String> { fn default_tabs() -> Vec<String> {
vec![ vec![
"app".to_string(), "app".to_string(),
@@ -40,9 +64,10 @@ fn default_tabs() -> Vec<String> {
} }
/// User-customizable theme colors /// User-customizable theme colors
/// All fields are optional - unset values inherit from GTK theme /// All fields are optional - unset values inherit from theme or GTK defaults
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeColors { pub struct ThemeColors {
// Core colors
pub background: Option<String>, pub background: Option<String>,
pub background_secondary: Option<String>, pub background_secondary: Option<String>,
pub border: Option<String>, pub border: Option<String>,
@@ -64,13 +89,21 @@ pub struct ThemeColors {
pub badge_sys: Option<String>, pub badge_sys: Option<String>,
pub badge_uuctl: Option<String>, pub badge_uuctl: Option<String>,
pub badge_web: Option<String>, pub badge_web: Option<String>,
// Widget badge colors
pub badge_media: Option<String>,
pub badge_weather: Option<String>,
pub badge_pomo: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppearanceConfig { pub struct AppearanceConfig {
#[serde(default = "default_width")]
pub width: i32, pub width: i32,
#[serde(default = "default_height")]
pub height: i32, pub height: i32,
#[serde(default = "default_font_size")]
pub font_size: u32, pub font_size: u32,
#[serde(default = "default_border_radius")]
pub border_radius: u32, pub border_radius: u32,
/// Theme name: None = GTK default, "owl" = built-in owl theme /// Theme name: None = GTK default, "owl" = built-in owl theme
#[serde(default)] #[serde(default)]
@@ -80,10 +113,31 @@ pub struct AppearanceConfig {
pub colors: ThemeColors, pub colors: ThemeColors,
} }
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
width: 850,
height: 650,
font_size: 14,
border_radius: 12,
theme: None,
colors: ThemeColors::default(),
}
}
}
fn default_width() -> i32 { 850 }
fn default_height() -> i32 { 650 }
fn default_font_size() -> u32 { 14 }
fn default_border_radius() -> u32 { 12 }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidersConfig { pub struct ProvidersConfig {
#[serde(default = "default_true")]
pub applications: bool, pub applications: bool,
#[serde(default = "default_true")]
pub commands: bool, pub commands: bool,
#[serde(default = "default_true")]
pub uuctl: bool, pub uuctl: bool,
/// Enable calculator provider (= expression or calc expression) /// Enable calculator provider (= expression or calc expression)
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -159,6 +213,36 @@ pub struct ProvidersConfig {
pub pomodoro_break_mins: u32, pub pomodoro_break_mins: u32,
} }
impl Default for ProvidersConfig {
fn default() -> Self {
Self {
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,
media: true,
weather: false,
weather_provider: "wttr.in".to_string(),
weather_api_key: None,
weather_location: Some("Berlin".to_string()),
pomodoro: false,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
}
}
}
/// Configuration for plugins /// Configuration for plugins
/// ///
/// Supports per-plugin configuration via `[plugins.<name>]` sections: /// Supports per-plugin configuration via `[plugins.<name>]` sections:
@@ -313,28 +397,6 @@ fn default_pomodoro_break() -> u32 {
5 5
} }
/// 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())
&& 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()
&& 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 /// Detect the best available terminal emulator
/// Fallback chain: /// Fallback chain:
@@ -450,57 +512,7 @@ fn command_exists(cmd: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
impl Default for Config { // Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
fn default() -> Self {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
Self {
general: GeneralConfig {
show_icons: true,
max_results: 10,
terminal_command: terminal,
launch_wrapper: detect_launch_wrapper(),
tabs: default_tabs(),
},
appearance: AppearanceConfig {
width: 850,
height: 650,
font_size: 14,
border_radius: 12,
theme: None,
colors: ThemeColors::default(),
},
providers: ProvidersConfig {
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,
// Widget providers
media: true,
weather: false,
weather_provider: "wttr.in".to_string(),
weather_api_key: None,
weather_location: Some("Berlin".to_string()),
pomodoro: false,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
},
plugins: PluginsConfig::default(),
}
}
}
impl Config { impl Config {
pub fn config_path() -> Option<PathBuf> { pub fn config_path() -> Option<PathBuf> {
@@ -517,23 +529,32 @@ impl Config {
pub fn load() -> Result<Self, Box<dyn std::error::Error>> { pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?; let path = Self::config_path().ok_or("Could not determine config path")?;
if !path.exists() { let mut config = if !path.exists() {
info!("Config file not found, using defaults"); info!("Config file not found, using defaults");
return Ok(Self::default()); Self::default()
} } else {
let content = std::fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
config
};
let content = std::fs::read_to_string(&path)?; // Auto-detect terminal if not configured or configured terminal doesn't exist
let mut config: Config = toml::from_str(&content)?; match &config.general.terminal_command {
info!("Loaded config from {:?}", path); None => {
let terminal = detect_terminal();
// Validate terminal - if configured terminal doesn't exist, auto-detect info!("Detected terminal: {}", terminal);
if !command_exists(&config.general.terminal_command) { config.general.terminal_command = Some(terminal);
warn!( }
"Configured terminal '{}' not found, auto-detecting", Some(term) if !command_exists(term) => {
config.general.terminal_command warn!("Configured terminal '{}' not found, auto-detecting", term);
); let terminal = detect_terminal();
config.general.terminal_command = detect_terminal(); info!("Using detected terminal: {}", terminal);
info!("Using detected terminal: {}", config.general.terminal_command); config.general.terminal_command = Some(terminal);
}
Some(term) => {
debug!("Using configured terminal: {}", term);
}
} }
Ok(config) Ok(config)

View File

@@ -37,35 +37,45 @@ impl ProviderFilter {
} else { } else {
// Use config file settings, default to apps only // Use config file settings, default to apps only
let mut set = HashSet::new(); let mut set = HashSet::new();
// Core providers
if config_providers.applications { if config_providers.applications {
set.insert(ProviderType::Application); set.insert(ProviderType::Application);
} }
if config_providers.commands { if config_providers.commands {
set.insert(ProviderType::Command); set.insert(ProviderType::Command);
} }
// Plugin providers - use Plugin(type_id) for all
if config_providers.uuctl { if config_providers.uuctl {
set.insert(ProviderType::Uuctl); set.insert(ProviderType::Plugin("uuctl".to_string()));
} }
if config_providers.system { if config_providers.system {
set.insert(ProviderType::System); set.insert(ProviderType::Plugin("system".to_string()));
} }
if config_providers.ssh { if config_providers.ssh {
set.insert(ProviderType::Ssh); set.insert(ProviderType::Plugin("ssh".to_string()));
} }
if config_providers.clipboard { if config_providers.clipboard {
set.insert(ProviderType::Clipboard); set.insert(ProviderType::Plugin("clipboard".to_string()));
} }
if config_providers.bookmarks { if config_providers.bookmarks {
set.insert(ProviderType::Bookmarks); set.insert(ProviderType::Plugin("bookmarks".to_string()));
} }
if config_providers.emoji { if config_providers.emoji {
set.insert(ProviderType::Emoji); set.insert(ProviderType::Plugin("emoji".to_string()));
} }
if config_providers.scripts { if config_providers.scripts {
set.insert(ProviderType::Scripts); set.insert(ProviderType::Plugin("scripts".to_string()));
}
// Dynamic providers
if config_providers.files {
set.insert(ProviderType::Plugin("filesearch".to_string()));
}
if config_providers.calculator {
set.insert(ProviderType::Plugin("calc".to_string()));
}
if config_providers.websearch {
set.insert(ProviderType::Plugin("websearch".to_string()));
} }
// 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 // Default to apps if nothing enabled
if set.is_empty() { if set.is_empty() {
set.insert(ProviderType::Application); set.insert(ProviderType::Application);
@@ -104,9 +114,11 @@ impl ProviderFilter {
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled); debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled);
} else { } else {
#[cfg(feature = "dev-logging")]
let provider_debug = format!("{:?}", provider);
self.enabled.insert(provider); self.enabled.insert(provider);
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled ON {:?}, enabled: {:?}", provider, self.enabled); debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled);
} }
} }
@@ -159,6 +171,7 @@ impl ProviderFilter {
} }
/// Parse query for prefix syntax /// Parse query for prefix syntax
/// Prefixes map to Plugin(type_id) for plugin providers
pub fn parse_query(query: &str) -> ParsedQuery { pub fn parse_query(query: &str) -> ParsedQuery {
let trimmed = query.trim_start(); let trimmed = query.trim_start();
@@ -186,37 +199,57 @@ impl ProviderFilter {
} }
} }
// Check for prefix patterns (with trailing space) // Core provider prefixes
let prefixes = [ let core_prefixes: &[(&str, ProviderType)] = &[
(":app ", ProviderType::Application), (":app ", ProviderType::Application),
(":apps ", 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), (":cmd ", ProviderType::Command),
(":command ", 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 { // Plugin provider prefixes - mapped to Plugin(type_id)
let plugin_prefixes: &[(&str, &str)] = &[
(":bm ", "bookmarks"),
(":bookmark ", "bookmarks"),
(":bookmarks ", "bookmarks"),
(":calc ", "calc"),
(":calculator ", "calc"),
(":clip ", "clipboard"),
(":clipboard ", "clipboard"),
(":emoji ", "emoji"),
(":emojis ", "emoji"),
(":file ", "filesearch"),
(":files ", "filesearch"),
(":find ", "filesearch"),
(":script ", "scripts"),
(":scripts ", "scripts"),
(":ssh ", "ssh"),
(":sys ", "system"),
(":system ", "system"),
(":power ", "system"),
(":uuctl ", "uuctl"),
(":systemd ", "uuctl"),
(":web ", "websearch"),
(":search ", "websearch"),
];
// Check core prefixes
for (prefix_str, provider) in core_prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) { 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.clone()),
tag_filter: None,
query: rest.to_string(),
};
}
}
// Check plugin prefixes
for (prefix_str, type_id) in plugin_prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest);
return ParsedQuery { return ParsedQuery {
@@ -227,37 +260,54 @@ impl ProviderFilter {
} }
} }
// Handle prefix without trailing space (still typing) // Handle partial prefixes (still typing)
let partial_prefixes = [ let partial_core: &[(&str, ProviderType)] = &[
(":app", ProviderType::Application), (":app", ProviderType::Application),
(":apps", 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), (":cmd", ProviderType::Command),
(":command", 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 { let partial_plugin: &[(&str, &str)] = &[
if trimmed == prefix_str { (":bm", "bookmarks"),
(":bookmark", "bookmarks"),
(":bookmarks", "bookmarks"),
(":calc", "calc"),
(":calculator", "calc"),
(":clip", "clipboard"),
(":clipboard", "clipboard"),
(":emoji", "emoji"),
(":emojis", "emoji"),
(":file", "filesearch"),
(":files", "filesearch"),
(":find", "filesearch"),
(":script", "scripts"),
(":scripts", "scripts"),
(":ssh", "ssh"),
(":sys", "system"),
(":system", "system"),
(":power", "system"),
(":uuctl", "uuctl"),
(":systemd", "uuctl"),
(":web", "websearch"),
(":search", "websearch"),
];
for (prefix_str, provider) in partial_core {
if trimmed == *prefix_str {
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
return ParsedQuery {
prefix: Some(provider.clone()),
tag_filter: None,
query: String::new(),
};
}
}
for (prefix_str, type_id) in partial_plugin {
if trimmed == *prefix_str {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
return ParsedQuery { return ParsedQuery {
@@ -285,22 +335,9 @@ impl ProviderFilter {
let mut providers: Vec<_> = self.enabled.iter().cloned().collect(); let mut providers: Vec<_> = self.enabled.iter().cloned().collect();
providers.sort_by_key(|p| match p { providers.sort_by_key(|p| match p {
ProviderType::Application => 0, ProviderType::Application => 0,
ProviderType::Bookmarks => 1, ProviderType::Command => 1,
ProviderType::Calculator => 2, ProviderType::Dmenu => 2,
ProviderType::Clipboard => 3, ProviderType::Plugin(_) => 100, // Plugin providers sort after core
ProviderType::Command => 4,
ProviderType::Dmenu => 5,
ProviderType::Emoji => 6,
ProviderType::Files => 7,
ProviderType::MediaPlayer => 8,
ProviderType::Pomodoro => 9,
ProviderType::Scripts => 10,
ProviderType::Ssh => 11,
ProviderType::System => 12,
ProviderType::Uuctl => 13,
ProviderType::Weather => 14,
ProviderType::WebSearch => 15,
ProviderType::Plugin(_) => 100, // Plugin providers sort last
}); });
providers providers
} }
@@ -310,21 +347,8 @@ impl ProviderFilter {
if let Some(ref prefix) = self.active_prefix { if let Some(ref prefix) = self.active_prefix {
return match prefix { return match prefix {
ProviderType::Application => "Apps", ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands", ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu", ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::MediaPlayer => "Media",
ProviderType::Pomodoro => "Pomodoro",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::Weather => "Weather",
ProviderType::WebSearch => "Web",
ProviderType::Plugin(_) => "Plugin", ProviderType::Plugin(_) => "Plugin",
}; };
} }
@@ -333,21 +357,8 @@ impl ProviderFilter {
if enabled.len() == 1 { if enabled.len() == 1 {
match &enabled[0] { match &enabled[0] {
ProviderType::Application => "Apps", ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands", ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu", ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::MediaPlayer => "Media",
ProviderType::Pomodoro => "Pomodoro",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::Weather => "Weather",
ProviderType::WebSearch => "Web",
ProviderType::Plugin(_) => "Plugin", ProviderType::Plugin(_) => "Plugin",
} }
} else { } else {
@@ -381,6 +392,13 @@ mod tests {
assert_eq!(result.query, ""); assert_eq!(result.query, "");
} }
#[test]
fn test_parse_query_plugin_prefix() {
let result = ProviderFilter::parse_query(":calc 5+3");
assert_eq!(result.prefix, Some(ProviderType::Plugin("calc".to_string())));
assert_eq!(result.query, "5+3");
}
#[test] #[test]
fn test_toggle_ensures_one_enabled() { fn test_toggle_ensures_one_enabled() {
let mut filter = ProviderFilter::apps_only(); let mut filter = ProviderFilter::apps_only();

View File

@@ -99,23 +99,57 @@ pub fn frecency_file() -> Option<PathBuf> {
// ============================================================================= // =============================================================================
/// System data directories for applications (XDG_DATA_DIRS) /// System data directories for applications (XDG_DATA_DIRS)
///
/// Follows the XDG Base Directory Specification:
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
/// - Additional Flatpak and Snap directories
pub fn system_data_dirs() -> Vec<PathBuf> { pub fn system_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
let mut seen = std::collections::HashSet::new();
// User data directory first // Helper to add unique directories
let mut add_dir = |path: PathBuf| {
if seen.insert(path.clone()) {
dirs.push(path);
}
};
// 1. User data directory first (highest priority)
if let Some(data) = data_home() { if let Some(data) = data_home() {
dirs.push(data.join("applications")); add_dir(data.join("applications"));
} }
// System directories // 2. XDG_DATA_DIRS - parse the environment variable
dirs.push(PathBuf::from("/usr/share/applications")); // Default per spec: /usr/local/share:/usr/share
dirs.push(PathBuf::from("/usr/local/share/applications")); let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
// Flatpak directories for dir in xdg_data_dirs.split(':') {
if let Some(data) = data_home() { if !dir.is_empty() {
dirs.push(data.join("flatpak/exports/share/applications")); add_dir(PathBuf::from(dir).join("applications"));
}
} }
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
// 3. Always include standard system directories as fallback
// Some environments set XDG_DATA_DIRS without including these
add_dir(PathBuf::from("/usr/share/applications"));
add_dir(PathBuf::from("/usr/local/share/applications"));
// 4. Flatpak directories (user and system)
if let Some(data) = data_home() {
add_dir(data.join("flatpak/exports/share/applications"));
}
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
// 5. Snap directories
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
// 6. Nix directories (common on NixOS)
if let Some(home) = dirs::home_dir() {
add_dir(home.join(".nix-profile/share/applications"));
}
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
dirs dirs
} }

View File

@@ -156,6 +156,7 @@ pub fn discover_plugins(plugins_dir: &Path) -> PluginResult<HashMap<String, (Plu
} }
/// Check if a plugin is compatible with the given owlry version /// Check if a plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> { pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> {
if !manifest.is_compatible_with(owlry_version) { if !manifest.is_compatible_with(owlry_version) {
return Err(PluginError::VersionMismatch { return Err(PluginError::VersionMismatch {
@@ -230,6 +231,7 @@ impl PluginManifest {
} }
/// Check if this plugin is compatible with the given owlry version /// Check if this plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn is_compatible_with(&self, owlry_version: &str) -> bool { pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) { let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r, Ok(r) => r,

View File

@@ -43,6 +43,7 @@ pub use api::provider::{PluginItem, ProviderRegistration};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use api::{ActionRegistration, HookEvent, ThemeRegistration}; pub use api::{ActionRegistration, HookEvent, ThemeRegistration};
#[allow(unused_imports)]
pub use error::{PluginError, PluginResult}; pub use error::{PluginError, PluginResult};
#[cfg(feature = "lua")] #[cfg(feature = "lua")]

View File

@@ -271,14 +271,16 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_lua_runtime_not_installed() { fn test_lua_runtime_check_doesnt_panic() {
// In test environment, runtime shouldn't be installed // Just verify the function runs without panicking
assert!(!lua_runtime_available()); // Result depends on whether runtime is installed
let _available = lua_runtime_available();
} }
#[test] #[test]
fn test_rune_runtime_not_installed() { fn test_rune_runtime_check_doesnt_panic() {
// In test environment, runtime shouldn't be installed // Just verify the function runs without panicking
assert!(!rune_runtime_available()); // Result depends on whether runtime is installed
let _available = rune_runtime_available();
} }
} }

View File

@@ -98,6 +98,15 @@ impl Provider for ApplicationProvider {
// Empty locale list for default locale // Empty locale list for default locale
let locales: &[&str] = &[]; let locales: &[&str] = &[];
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.split(':')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
for path in Iter::new(dirs.into_iter()) { for path in Iter::new(dirs.into_iter()) {
let content = match std::fs::read_to_string(&path) { let content = match std::fs::read_to_string(&path) {
Ok(c) => c, Ok(c) => c,
@@ -125,6 +134,24 @@ impl Provider for ApplicationProvider {
continue; continue;
} }
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
if !current_desktops.is_empty() {
// OnlyShowIn: if set, current desktop must be in the list
if desktop_entry.only_show_in().is_some_and(|only| {
!current_desktops.iter().any(|de| only.contains(&de.as_str()))
}) {
continue;
}
// NotShowIn: if current desktop is in the list, skip
if desktop_entry.not_show_in().is_some_and(|not| {
current_desktops.iter().any(|de| not.contains(&de.as_str()))
}) {
continue;
}
}
let name = match desktop_entry.name(locales) { let name = match desktop_entry.name(locales) {
Some(n) => n.to_string(), Some(n) => n.to_string(),
None => continue, None => continue,
@@ -135,12 +162,17 @@ impl Provider for ApplicationProvider {
None => continue, None => continue,
}; };
// Extract categories as tags (lowercase for consistency) // Extract categories and keywords as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry let mut tags: Vec<String> = desktop_entry
.categories() .categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect()) .map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default(); .unwrap_or_default();
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
if let Some(keywords) = desktop_entry.keywords(locales) {
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
}
let item = LaunchItem { let item = LaunchItem {
id: path.to_string_lossy().to_string(), id: path.to_string_lossy().to_string(),
name, name,
@@ -157,6 +189,13 @@ impl Provider for ApplicationProvider {
debug!("Found {} applications", self.items.len()); debug!("Found {} applications", self.items.len());
#[cfg(feature = "dev-logging")]
debug!(
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
current_desktops,
Self::get_application_dirs().len()
);
// Sort alphabetically by name // Sort alphabetically by name
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
} }
@@ -210,4 +249,18 @@ mod tests {
"bash -c 'echo %u'" "bash -c 'echo %u'"
); );
} }
#[test]
fn test_clean_desktop_exec_preserves_env() {
// env VAR=value pattern should be preserved
assert_eq!(
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
);
// Multiple env vars
assert_eq!(
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
"env FOO=bar BAZ=qux myapp"
);
}
} }

View File

@@ -44,27 +44,17 @@ pub struct LaunchItem {
/// Provider type identifier for filtering and badge display /// Provider type identifier for filtering and badge display
/// ///
/// Note: Plugin is a special case that stores a type_id string /// Core types are built-in providers. All native plugins use Plugin(type_id).
/// for custom plugin-defined provider types. /// This keeps the core app free of plugin-specific knowledge.
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProviderType { pub enum ProviderType {
/// Built-in: Desktop applications from XDG directories
Application, Application,
Bookmarks, /// Built-in: Shell commands from PATH
Calculator,
Clipboard,
Command, Command,
/// Built-in: Pipe-based input (dmenu compatibility)
Dmenu, Dmenu,
Emoji, /// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
Files,
MediaPlayer,
Pomodoro,
Scripts,
Ssh,
System,
Uuctl,
Weather,
WebSearch,
/// Plugin-defined provider type with custom type_id
Plugin(String), Plugin(String),
} }
@@ -73,27 +63,11 @@ impl std::str::FromStr for ProviderType {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
// Core built-in providers
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application), "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), "cmd" | "command" | "commands" => Ok(ProviderType::Command),
"dmenu" => Ok(ProviderType::Dmenu), "dmenu" => Ok(ProviderType::Dmenu),
"emoji" | "emojis" => Ok(ProviderType::Emoji), // Everything else is a plugin
"file" | "files" | "find" => Ok(ProviderType::Files),
"media" | "mpris" | "player" => Ok(ProviderType::MediaPlayer),
"pomo" | "pomodoro" | "timer" => Ok(ProviderType::Pomodoro),
"script" | "scripts" => Ok(ProviderType::Scripts),
"ssh" => Ok(ProviderType::Ssh),
"sys" | "system" | "power" => Ok(ProviderType::System),
"uuctl" => Ok(ProviderType::Uuctl),
"weather" => Ok(ProviderType::Weather),
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
// Plugin types are prefixed with "plugin:" (e.g., "plugin:github-repos")
other if other.starts_with("plugin:") => {
Ok(ProviderType::Plugin(other[7..].to_string()))
}
// Unknown types become plugin types
other => Ok(ProviderType::Plugin(other.to_string())), other => Ok(ProviderType::Plugin(other.to_string())),
} }
} }
@@ -103,21 +77,8 @@ impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
ProviderType::Application => write!(f, "app"), 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::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"), ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Emoji => write!(f, "emoji"),
ProviderType::Files => write!(f, "file"),
ProviderType::MediaPlayer => write!(f, "media"),
ProviderType::Pomodoro => write!(f, "pomo"),
ProviderType::Scripts => write!(f, "script"),
ProviderType::Ssh => write!(f, "ssh"),
ProviderType::System => write!(f, "sys"),
ProviderType::Uuctl => write!(f, "uuctl"),
ProviderType::Weather => write!(f, "weather"),
ProviderType::WebSearch => write!(f, "web"),
ProviderType::Plugin(type_id) => write!(f, "{}", type_id), ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
} }
} }
@@ -134,8 +95,10 @@ pub trait Provider: Send {
/// Manages all providers and handles searching /// Manages all providers and handles searching
pub struct ProviderManager { pub struct ProviderManager {
/// Static providers (apps, commands, and native static plugins) /// Core static providers (apps, commands, dmenu)
providers: Vec<Box<dyn Provider>>, providers: Vec<Box<dyn Provider>>,
/// Static native plugin providers (need query() for submenu support)
static_native_providers: Vec<NativeProvider>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch) /// Dynamic providers from native plugins (calculator, websearch, filesearch)
/// These are queried per-keystroke, not cached /// These are queried per-keystroke, not cached
dynamic_providers: Vec<NativeProvider>, dynamic_providers: Vec<NativeProvider>,
@@ -146,22 +109,18 @@ pub struct ProviderManager {
matcher: SkimMatcherV2, matcher: SkimMatcherV2,
} }
/// Known dynamic provider type IDs (need per-query evaluation)
const DYNAMIC_TYPE_IDS: &[&str] = &["calc", "websearch", "filesearch"];
/// Known widget provider type IDs (appear at top of results)
const WIDGET_TYPE_IDS: &[&str] = &["weather", "media", "pomodoro"];
impl ProviderManager { impl ProviderManager {
/// Create a new ProviderManager with native plugins /// Create a new ProviderManager with native plugins
/// ///
/// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized into: /// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized based on
/// - Static providers (added to providers vec) /// their declared ProviderKind and ProviderPosition:
/// - Dynamic providers (queried per-keystroke: calculator, websearch, filesearch) /// - Static providers with Normal position (added to providers vec)
/// - Widget providers (shown at top: weather, media, pomodoro) /// - Dynamic providers (queried per-keystroke, declared via ProviderKind::Dynamic)
/// - Widget providers (shown at top, declared via ProviderPosition::Widget)
pub fn with_native_plugins(native_providers: Vec<NativeProvider>) -> Self { pub fn with_native_plugins(native_providers: Vec<NativeProvider>) -> Self {
let mut manager = Self { let mut manager = Self {
providers: Vec::new(), providers: Vec::new(),
static_native_providers: Vec::new(),
dynamic_providers: Vec::new(), dynamic_providers: Vec::new(),
widget_providers: Vec::new(), widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(), matcher: SkimMatcherV2::default(),
@@ -180,19 +139,22 @@ impl ProviderManager {
manager.providers.push(Box::new(ApplicationProvider::new())); manager.providers.push(Box::new(ApplicationProvider::new()));
manager.providers.push(Box::new(CommandProvider::new())); manager.providers.push(Box::new(CommandProvider::new()));
// Categorize native plugins // Categorize native plugins based on their declared ProviderKind and ProviderPosition
for provider in native_providers { for provider in native_providers {
let type_id = provider.type_id(); let type_id = provider.type_id();
if DYNAMIC_TYPE_IDS.contains(&type_id) { if provider.is_dynamic() {
// Dynamic providers declare ProviderKind::Dynamic
info!("Registered dynamic provider: {} ({})", provider.name(), type_id); info!("Registered dynamic provider: {} ({})", provider.name(), type_id);
manager.dynamic_providers.push(provider); manager.dynamic_providers.push(provider);
} else if WIDGET_TYPE_IDS.contains(&type_id) { } else if provider.is_widget() {
// Widgets declare ProviderPosition::Widget
info!("Registered widget provider: {} ({})", provider.name(), type_id); info!("Registered widget provider: {} ({})", provider.name(), type_id);
manager.widget_providers.push(provider); manager.widget_providers.push(provider);
} else { } else {
// Static native providers (keep as NativeProvider for query/submenu support)
info!("Registered static provider: {} ({})", provider.name(), type_id); info!("Registered static provider: {} ({})", provider.name(), type_id);
manager.providers.push(Box::new(provider)); manager.static_native_providers.push(provider);
} }
} }
} }
@@ -211,7 +173,7 @@ impl ProviderManager {
} }
pub fn refresh_all(&mut self) { pub fn refresh_all(&mut self) {
// Refresh static providers (fast, local operations) // Refresh core providers (apps, commands)
for provider in &mut self.providers { for provider in &mut self.providers {
provider.refresh(); provider.refresh();
info!( info!(
@@ -221,6 +183,16 @@ impl ProviderManager {
); );
} }
// Refresh static native providers (clipboard, emoji, ssh, etc.)
for provider in &mut self.static_native_providers {
provider.refresh();
info!(
"Static provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
// Widget providers are refreshed separately to avoid blocking startup // Widget providers are refreshed separately to avoid blocking startup
// Call refresh_widgets() after window is shown // Call refresh_widgets() after window is shown
@@ -242,9 +214,13 @@ impl ProviderManager {
} }
/// Find a native provider by type ID /// Find a native provider by type ID
/// Searches in widget providers and dynamic providers /// Searches in all native provider lists (static, dynamic, widget)
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> { pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
// Check widget providers first (pomodoro, weather, media) // Check static native providers first (clipboard, emoji, ssh, systemd, etc.)
if let Some(p) = self.static_native_providers.iter().find(|p| p.type_id() == type_id) {
return Some(p);
}
// Check widget providers (pomodoro, weather, media)
if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) { if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) {
return Some(p); return Some(p);
} }
@@ -273,49 +249,54 @@ impl ProviderManager {
} }
/// Add a dynamic provider (e.g., from a Lua plugin) /// Add a dynamic provider (e.g., from a Lua plugin)
#[allow(dead_code)]
pub fn add_provider(&mut self, provider: Box<dyn Provider>) { pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
info!("Added plugin provider: {}", provider.name()); info!("Added plugin provider: {}", provider.name());
self.providers.push(provider); self.providers.push(provider);
} }
/// Add multiple providers at once (for batch plugin loading) /// Add multiple providers at once (for batch plugin loading)
#[allow(dead_code)]
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) { pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
for provider in providers { for provider in providers {
self.add_provider(provider); self.add_provider(provider);
} }
} }
/// Iterate over all static provider items (core + native static plugins)
fn all_static_items(&self) -> impl Iterator<Item = &LaunchItem> {
self.providers
.iter()
.flat_map(|p| p.items().iter())
.chain(self.static_native_providers.iter().flat_map(|p| p.items().iter()))
}
#[allow(dead_code)] #[allow(dead_code)]
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> { pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
if query.is_empty() { if query.is_empty() {
// Return recent/popular items when query is empty // Return recent/popular items when query is empty
return self.providers return self.all_static_items()
.iter()
.flat_map(|p| p.items().iter().cloned())
.take(max_results) .take(max_results)
.map(|item| (item, 0)) .map(|item| (item.clone(), 0))
.collect(); .collect();
} }
let mut results: Vec<(LaunchItem, i64)> = self.providers let mut results: Vec<(LaunchItem, i64)> = self.all_static_items()
.iter() .filter_map(|item| {
.flat_map(|provider| { // Match against name and description
provider.items().iter().filter_map(|item| { let name_score = self.matcher.fuzzy_match(&item.name, query);
// Match against name and description let desc_score = item.description
let name_score = self.matcher.fuzzy_match(&item.name, query); .as_ref()
let desc_score = item.description .and_then(|d| self.matcher.fuzzy_match(d, query));
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) { let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)), (Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n), (Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2), // Lower weight for description matches (None, Some(d)) => Some(d / 2), // Lower weight for description matches
(None, None) => None, (None, None) => None,
}; };
score.map(|s| (item.clone(), s)) score.map(|s| (item.clone(), s))
})
}) })
.collect(); .collect();
@@ -332,38 +313,45 @@ impl ProviderManager {
max_results: usize, max_results: usize,
filter: &crate::filter::ProviderFilter, filter: &crate::filter::ProviderFilter,
) -> Vec<(LaunchItem, i64)> { ) -> Vec<(LaunchItem, i64)> {
// Collect items from core providers
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
if query.is_empty() { if query.is_empty() {
return self return core_items
.providers .chain(native_items)
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned())
.take(max_results) .take(max_results)
.map(|item| (item, 0)) .map(|item| (item, 0))
.collect(); .collect();
} }
let mut results: Vec<(LaunchItem, i64)> = self let mut results: Vec<(LaunchItem, i64)> = core_items
.providers .chain(native_items)
.iter() .filter_map(|item| {
.filter(|provider| filter.is_active(provider.provider_type())) let name_score = self.matcher.fuzzy_match(&item.name, query);
.flat_map(|provider| { let desc_score = item
provider.items().iter().filter_map(|item| { .description
let name_score = self.matcher.fuzzy_match(&item.name, query); .as_ref()
let desc_score = item .and_then(|d| self.matcher.fuzzy_match(d, query));
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) { let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)), (Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n), (Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2), (None, Some(d)) => Some(d / 2),
(None, None) => None, (None, None) => None,
}; };
score.map(|s| (item.clone(), s)) score.map(|s| (item, s))
})
}) })
.collect(); .collect();
@@ -391,15 +379,11 @@ impl ProviderManager {
// 1. No specific filter prefix is active // 1. No specific filter prefix is active
// 2. Query is empty (user hasn't started searching) // 2. Query is empty (user hasn't started searching)
// This keeps widgets visible on launch but hides them during active search // This keeps widgets visible on launch but hides them during active search
// Widgets are always visible regardless of filter settings (they declare position via API)
if filter.active_prefix().is_none() && query.is_empty() { if filter.active_prefix().is_none() && query.is_empty() {
// Widget priority scores based on type // Widget priority comes from plugin-declared priority field
for provider in &self.widget_providers { for provider in &self.widget_providers {
let base_score = match provider.type_id() { let base_score = provider.priority() as i64;
"weather" => 12000,
"pomodoro" => 11500,
"media" => 11000,
_ => 10500,
};
for (idx, item) in provider.items().iter().enumerate() { for (idx, item) in provider.items().iter().enumerate() {
results.push((item.clone(), base_score - idx as i64)); results.push((item.clone(), base_score - idx as i64));
} }
@@ -407,16 +391,18 @@ impl ProviderManager {
} }
// Query dynamic providers (calculator, websearch, filesearch) // Query dynamic providers (calculator, websearch, filesearch)
// Each provider internally checks if the query matches its prefix // Only query if:
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
// 2. No specific single-mode filter is active (showing all providers)
if !query.is_empty() { if !query.is_empty() {
for (provider_idx, provider) in self.dynamic_providers.iter().enumerate() { for provider in &self.dynamic_providers {
// Skip if this provider type is explicitly filtered out
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query); let dynamic_results = provider.query(query);
let base_score = match provider.type_id() { // Priority comes from plugin-declared priority field
"calc" => 10000, let base_score = provider.priority() as i64;
"websearch" => 9000,
"filesearch" => 8000,
_ => 7000 - (provider_idx as i64 * 1000),
};
for (idx, item) in dynamic_results.into_iter().enumerate() { for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score - idx as i64)); results.push((item, base_score - idx as i64));
} }
@@ -425,11 +411,22 @@ impl ProviderManager {
// Empty query (after checking special providers) - return frecency-sorted items // Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() { if query.is_empty() {
let items: Vec<(LaunchItem, i64)> = self // Collect items from core providers
let core_items = self
.providers .providers
.iter() .iter()
.filter(|p| filter.is_active(p.provider_type())) .filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned()) .flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let items: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter(|item| { .filter(|item| {
// Apply tag filter if present // Apply tag filter if present
if let Some(tag) = tag_filter { if let Some(tag) = tag_filter {
@@ -453,53 +450,70 @@ impl ProviderManager {
} }
// Regular search with frecency boost and tag matching // Regular search with frecency boost and tag matching
let search_results: Vec<(LaunchItem, i64)> = self // Helper closure for scoring items
.providers let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
.iter() // Apply tag filter if present
.filter(|provider| filter.is_active(provider.provider_type())) if let Some(tag) = tag_filter
.flat_map(|provider| { && !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
provider.items().iter().filter_map(|item| { {
// Apply tag filter if present return None;
if let Some(tag) = tag_filter }
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) {
return None;
}
let name_score = self.matcher.fuzzy_match(&item.name, query); let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item let desc_score = item
.description .description
.as_ref() .as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query)); .and_then(|d| self.matcher.fuzzy_match(d, query));
// Also match against tags (lower weight) // Also match against tags (lower weight)
let tag_score = item let tag_score = item
.tags .tags
.iter() .iter()
.filter_map(|t| self.matcher.fuzzy_match(t, query)) .filter_map(|t| self.matcher.fuzzy_match(t, query))
.max() .max()
.map(|s| s / 3); // Lower weight for tag matches .map(|s| s / 3); // Lower weight for tag matches
let base_score = match (name_score, desc_score, tag_score) { 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), Some(t)) => Some(n.max(d).max(t)),
(Some(n), Some(d), None) => Some(n.max(d)), (Some(n), Some(d), None) => Some(n.max(d)),
(Some(n), None, Some(t)) => Some(n.max(t)), (Some(n), None, Some(t)) => Some(n.max(t)),
(Some(n), None, None) => Some(n), (Some(n), None, None) => Some(n),
(None, Some(d), Some(t)) => Some((d / 2).max(t)), (None, Some(d), Some(t)) => Some((d / 2).max(t)),
(None, Some(d), None) => Some(d / 2), (None, Some(d), None) => Some(d / 2),
(None, None, Some(t)) => Some(t), (None, None, Some(t)) => Some(t),
(None, None, None) => None, (None, None, None) => None,
}; };
base_score.map(|s| { base_score.map(|s| {
let frecency_score = frecency.get_score(&item.id); let frecency_score = frecency.get_score(&item.id);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64; let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
(item.clone(), s + frecency_boost) (item.clone(), s + frecency_boost)
})
})
}) })
.collect(); };
results.extend(search_results); // Search core providers
for provider in &self.providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
}
}
}
// Search static native providers
for provider in &self.static_native_providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
}
}
}
results.sort_by(|a, b| b.1.cmp(&a.1)); results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results); results.truncate(max_results);
@@ -520,7 +534,11 @@ impl ProviderManager {
/// Get all available provider types (for UI tabs) /// Get all available provider types (for UI tabs)
#[allow(dead_code)] #[allow(dead_code)]
pub fn available_providers(&self) -> Vec<ProviderType> { pub fn available_providers(&self) -> Vec<ProviderType> {
self.providers.iter().map(|p| p.provider_type()).collect() self.providers
.iter()
.map(|p| p.provider_type())
.chain(self.static_native_providers.iter().map(|p| p.provider_type()))
.collect()
} }
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media") /// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
@@ -560,6 +578,16 @@ impl ProviderManager {
plugin_id, submenu_query plugin_id, submenu_query
); );
// Search in static native providers (clipboard, emoji, ssh, systemd, etc.)
for provider in &self.static_native_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in dynamic providers // Search in dynamic providers
for provider in &self.dynamic_providers { for provider in &self.dynamic_providers {
if provider.type_id() == plugin_id { if provider.type_id() == plugin_id {
@@ -580,23 +608,6 @@ impl ProviderManager {
} }
} }
// Search in static providers (boxed)
// Note: Static providers don't typically have submenu support,
// but we check for completeness
for provider in &self.providers {
if let ProviderType::Plugin(type_id) = provider.provider_type()
&& type_id == plugin_id
{
// Static providers use the items() method, not query
// Submenu support requires dynamic query capability
#[cfg(feature = "dev-logging")]
debug!(
"[Submenu] Plugin '{}' is static, cannot query for submenu",
plugin_id
);
}
}
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id); debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id);

View File

@@ -9,7 +9,7 @@
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use log::debug; use log::debug;
use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind}; use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition};
use super::{LaunchItem, Provider, ProviderType}; use super::{LaunchItem, Provider, ProviderType};
use crate::plugins::native_loader::NativePlugin; use crate::plugins::native_loader::NativePlugin;
@@ -42,6 +42,12 @@ impl NativeProvider {
} }
} }
/// Get the ProviderType for this native provider
/// All native plugins return Plugin(type_id) - the core has no hardcoded plugin types
fn get_provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string())
}
/// Convert a plugin API item to a core LaunchItem /// Convert a plugin API item to a core LaunchItem
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem { fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
LaunchItem { LaunchItem {
@@ -49,7 +55,7 @@ impl NativeProvider {
name: item.name.to_string(), name: item.name.to_string(),
description: item.description.as_ref().map(|s| s.to_string()).into(), description: item.description.as_ref().map(|s| s.to_string()).into(),
icon: item.icon.as_ref().map(|s| s.to_string()).into(), icon: item.icon.as_ref().map(|s| s.to_string()).into(),
provider: ProviderType::Plugin(self.info.type_id.to_string()), provider: self.get_provider_type(),
command: item.command.to_string(), command: item.command.to_string(),
terminal: item.terminal, terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(), tags: item.keywords.iter().map(|s| s.to_string()).collect(),
@@ -99,6 +105,17 @@ impl NativeProvider {
self.info.type_id.as_str() self.info.type_id.as_str()
} }
/// Check if this is a widget provider (appears at top of results)
pub fn is_widget(&self) -> bool {
self.info.position == ProviderPosition::Widget
}
/// Get the provider's priority for result ordering
/// Higher values appear first in results
pub fn priority(&self) -> i32 {
self.info.priority
}
/// Execute an action command on the provider /// Execute an action command on the provider
/// Uses query with "!" prefix to trigger action handling in the plugin /// Uses query with "!" prefix to trigger action handling in the plugin
pub fn execute_action(&self, action: &str) { pub fn execute_action(&self, action: &str) {
@@ -113,7 +130,7 @@ impl Provider for NativeProvider {
} }
fn provider_type(&self) -> ProviderType { fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string()) self.get_provider_type()
} }
fn refresh(&mut self) { fn refresh(&mut self) {

View File

@@ -67,6 +67,18 @@
opacity: 1; opacity: 1;
} }
/* Symbolic icons - inherit text color */
.owlry-symbolic-icon {
-gtk-icon-style: symbolic;
}
/* Emoji icon - displayed as large text */
.owlry-emoji-icon {
font-size: 24px;
min-width: 32px;
min-height: 32px;
}
/* Result name */ /* Result name */
.owlry-result-name { .owlry-result-name {
font-size: var(--owlry-font-size, 14px); font-size: var(--owlry-font-size, 14px);

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e0e0e0">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/> <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 183 B

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M3.89,17.6c0-0.99,0.31-1.88,0.93-2.65s1.41-1.27,2.38-1.49c0.26-1.17,0.85-2.14,1.78-2.88c0.93-0.75,2-1.12,3.22-1.12 <path d="M3.89,17.6c0-0.99,0.31-1.88,0.93-2.65s1.41-1.27,2.38-1.49c0.26-1.17,0.85-2.14,1.78-2.88c0.93-0.75,2-1.12,3.22-1.12
c1.18,0,2.24,0.36,3.16,1.09c0.93,0.73,1.53,1.66,1.8,2.8h0.27c1.18,0,2.18,0.41,3.01,1.24s1.25,1.83,1.25,3 c1.18,0,2.24,0.36,3.16,1.09c0.93,0.73,1.53,1.66,1.8,2.8h0.27c1.18,0,2.18,0.41,3.01,1.24s1.25,1.83,1.25,3
c0,1.18-0.42,2.18-1.25,3.01s-1.83,1.25-3.01,1.25H8.16c-0.58,0-1.13-0.11-1.65-0.34S5.52,21,5.14,20.62 c0,1.18-0.42,2.18-1.25,3.01s-1.83,1.25-3.01,1.25H8.16c-0.58,0-1.13-0.11-1.65-0.34S5.52,21,5.14,20.62

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M1.56,16.9c0,0.9,0.22,1.73,0.66,2.49s1.04,1.36,1.8,1.8c0.76,0.44,1.58,0.66,2.47,0.66h10.83c0.89,0,1.72-0.22,2.48-0.66 <path d="M1.56,16.9c0,0.9,0.22,1.73,0.66,2.49s1.04,1.36,1.8,1.8c0.76,0.44,1.58,0.66,2.47,0.66h10.83c0.89,0,1.72-0.22,2.48-0.66
c0.76-0.44,1.37-1.04,1.81-1.8c0.44-0.76,0.67-1.59,0.67-2.49c0-0.66-0.14-1.33-0.42-2C22.62,13.98,23,12.87,23,11.6 c0.76-0.44,1.37-1.04,1.81-1.8c0.44-0.76,0.67-1.59,0.67-2.49c0-0.66-0.14-1.33-0.42-2C22.62,13.98,23,12.87,23,11.6
c0-0.71-0.14-1.39-0.41-2.04c-0.27-0.65-0.65-1.2-1.12-1.67C21,7.42,20.45,7.04,19.8,6.77c-0.65-0.28-1.33-0.41-2.04-0.41 c0-0.71-0.14-1.39-0.41-2.04c-0.27-0.65-0.65-1.2-1.12-1.67C21,7.42,20.45,7.04,19.8,6.77c-0.65-0.28-1.33-0.41-2.04-0.41

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.37,14.62c0-0.24,0.08-0.45,0.25-0.62c0.17-0.16,0.38-0.24,0.6-0.24h2.04c0.23,0,0.42,0.08,0.58,0.25 <path d="M4.37,14.62c0-0.24,0.08-0.45,0.25-0.62c0.17-0.16,0.38-0.24,0.6-0.24h2.04c0.23,0,0.42,0.08,0.58,0.25
c0.15,0.17,0.23,0.37,0.23,0.61S8,15.06,7.85,15.23c-0.15,0.17-0.35,0.25-0.58,0.25H5.23c-0.23,0-0.43-0.08-0.6-0.25 c0.15,0.17,0.23,0.37,0.23,0.61S8,15.06,7.85,15.23c-0.15,0.17-0.35,0.25-0.58,0.25H5.23c-0.23,0-0.43-0.08-0.6-0.25
C4.46,15.06,4.37,14.86,4.37,14.62z M7.23,21.55c0-0.23,0.08-0.43,0.23-0.61l1.47-1.43c0.15-0.16,0.35-0.23,0.59-0.23 C4.46,15.06,4.37,14.86,4.37,14.62z M7.23,21.55c0-0.23,0.08-0.43,0.23-0.61l1.47-1.43c0.15-0.16,0.35-0.23,0.59-0.23

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M2.62,21.05c0-0.24,0.08-0.45,0.25-0.61c0.17-0.16,0.38-0.24,0.63-0.24h18.67c0.25,0,0.45,0.08,0.61,0.24 <path d="M2.62,21.05c0-0.24,0.08-0.45,0.25-0.61c0.17-0.16,0.38-0.24,0.63-0.24h18.67c0.25,0,0.45,0.08,0.61,0.24
c0.16,0.16,0.24,0.36,0.24,0.61c0,0.23-0.08,0.43-0.25,0.58c-0.17,0.16-0.37,0.23-0.6,0.23H3.5c-0.25,0-0.46-0.08-0.63-0.23 c0.16,0.16,0.24,0.36,0.24,0.61c0,0.23-0.08,0.43-0.25,0.58c-0.17,0.16-0.37,0.23-0.6,0.23H3.5c-0.25,0-0.46-0.08-0.63-0.23
C2.7,21.47,2.62,21.28,2.62,21.05z M5.24,17.91c0-0.24,0.09-0.44,0.26-0.6c0.15-0.15,0.35-0.23,0.59-0.23h18.67 C2.7,21.47,2.62,21.28,2.62,21.05z M5.24,17.91c0-0.24,0.09-0.44,0.26-0.6c0.15-0.15,0.35-0.23,0.59-0.23h18.67

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M7.91,14.48c0-0.96,0.19-1.87,0.56-2.75s0.88-1.63,1.51-2.26c0.63-0.63,1.39-1.14,2.27-1.52c0.88-0.38,1.8-0.57,2.75-0.57 <path d="M7.91,14.48c0-0.96,0.19-1.87,0.56-2.75s0.88-1.63,1.51-2.26c0.63-0.63,1.39-1.14,2.27-1.52c0.88-0.38,1.8-0.57,2.75-0.57
h1.14c0.16,0.04,0.23,0.14,0.23,0.28l0.05,0.88c0.04,1.27,0.49,2.35,1.37,3.24c0.88,0.89,1.94,1.37,3.19,1.42l0.82,0.07 h1.14c0.16,0.04,0.23,0.14,0.23,0.28l0.05,0.88c0.04,1.27,0.49,2.35,1.37,3.24c0.88,0.89,1.94,1.37,3.19,1.42l0.82,0.07
c0.16,0,0.24,0.08,0.24,0.23v0.98c0.01,1.28-0.3,2.47-0.93,3.56c-0.63,1.09-1.48,1.95-2.57,2.59c-1.08,0.63-2.27,0.95-3.55,0.95 c0.16,0,0.24,0.08,0.24,0.23v0.98c0.01,1.28-0.3,2.47-0.93,3.56c-0.63,1.09-1.48,1.95-2.57,2.59c-1.08,0.63-2.27,0.95-3.55,0.95

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.64,16.91c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.47,2.73-1.73c0.31-1.36,1.02-2.48,2.11-3.36s2.34-1.31,3.75-1.31 <path d="M4.64,16.91c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.47,2.73-1.73c0.31-1.36,1.02-2.48,2.11-3.36s2.34-1.31,3.75-1.31
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.65s1.37,1.03,1.81,1.78 c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.65s1.37,1.03,1.81,1.78
c0.44,0.75,0.67,1.58,0.67,2.47c0,0.88-0.21,1.69-0.63,2.44c-0.42,0.75-1,1.35-1.73,1.8c-0.73,0.45-1.53,0.69-2.4,0.71 c0.44,0.75,0.67,1.58,0.67,2.47c0,0.88-0.21,1.69-0.63,2.44c-0.42,0.75-1,1.35-1.73,1.8c-0.73,0.45-1.53,0.69-2.4,0.71

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.64,16.95c0-1.16,0.35-2.18,1.06-3.08s1.62-1.48,2.74-1.76c0.31-1.36,1.01-2.48,2.1-3.36s2.34-1.31,3.75-1.31 <path d="M4.64,16.95c0-1.16,0.35-2.18,1.06-3.08s1.62-1.48,2.74-1.76c0.31-1.36,1.01-2.48,2.1-3.36s2.34-1.31,3.75-1.31
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.66c0.76,0.44,1.37,1.04,1.81,1.8 c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.66c0.76,0.44,1.37,1.04,1.81,1.8
c0.44,0.76,0.67,1.59,0.67,2.48c0,1.32-0.46,2.47-1.39,3.42c-0.92,0.96-2.05,1.46-3.38,1.5c-0.13,0-0.2-0.06-0.2-0.17v-1.33 c0.44,0.76,0.67,1.59,0.67,2.48c0,1.32-0.46,2.47-1.39,3.42c-0.92,0.96-2.05,1.46-3.38,1.5c-0.13,0-0.2-0.06-0.2-0.17v-1.33

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M9.91,19.56c0-0.85,0.2-1.64,0.59-2.38s0.94-1.35,1.65-1.84V5.42c0-0.8,0.27-1.48,0.82-2.03S14.2,2.55,15,2.55 <path d="M9.91,19.56c0-0.85,0.2-1.64,0.59-2.38s0.94-1.35,1.65-1.84V5.42c0-0.8,0.27-1.48,0.82-2.03S14.2,2.55,15,2.55
c0.81,0,1.49,0.28,2.04,0.83c0.55,0.56,0.83,1.23,0.83,2.03v9.92c0.71,0.49,1.25,1.11,1.64,1.84s0.58,1.53,0.58,2.38 c0.81,0,1.49,0.28,2.04,0.83c0.55,0.56,0.83,1.23,0.83,2.03v9.92c0.71,0.49,1.25,1.11,1.64,1.84s0.58,1.53,0.58,2.38
c0,0.92-0.23,1.78-0.68,2.56s-1.07,1.4-1.85,1.85s-1.63,0.68-2.56,0.68c-0.92,0-1.77-0.23-2.55-0.68s-1.4-1.07-1.86-1.85 c0,0.92-0.23,1.78-0.68,2.56s-1.07,1.4-1.85,1.85s-1.63,0.68-2.56,0.68c-0.92,0-1.77-0.23-2.55-0.68s-1.4-1.07-1.86-1.85

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve"> viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.63,16.91c0,1.11,0.33,2.1,0.99,2.97s1.52,1.47,2.58,1.79l-0.66,1.68c-0.03,0.14,0.02,0.22,0.14,0.22h2.13l-0.98,4.3h0.28 <path d="M4.63,16.91c0,1.11,0.33,2.1,0.99,2.97s1.52,1.47,2.58,1.79l-0.66,1.68c-0.03,0.14,0.02,0.22,0.14,0.22h2.13l-0.98,4.3h0.28
l3.92-5.75c0.04-0.04,0.04-0.09,0.01-0.14c-0.03-0.05-0.08-0.07-0.15-0.07h-2.18l2.48-4.64c0.07-0.14,0.02-0.22-0.14-0.22h-2.94 l3.92-5.75c0.04-0.04,0.04-0.09,0.01-0.14c-0.03-0.05-0.08-0.07-0.15-0.07h-2.18l2.48-4.64c0.07-0.14,0.02-0.22-0.14-0.22h-2.94
c-0.09,0-0.17,0.05-0.23,0.15l-1.07,2.87c-0.71-0.18-1.3-0.57-1.77-1.16c-0.47-0.59-0.7-1.26-0.7-2.01c0-0.83,0.28-1.55,0.85-2.17 c-0.09,0-0.17,0.05-0.23,0.15l-1.07,2.87c-0.71-0.18-1.3-0.57-1.77-1.16c-0.47-0.59-0.7-1.26-0.7-2.01c0-0.83,0.28-1.55,0.85-2.17

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -72,6 +72,17 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
css.push_str(&format!(" --owlry-badge-web: {};\n", badge_web)); css.push_str(&format!(" --owlry-badge-web: {};\n", badge_web));
} }
// Widget badge colors
if let Some(ref badge_media) = config.colors.badge_media {
css.push_str(&format!(" --owlry-badge-media: {};\n", badge_media));
}
if let Some(ref badge_weather) = config.colors.badge_weather {
css.push_str(&format!(" --owlry-badge-weather: {};\n", badge_weather));
}
if let Some(ref badge_pomo) = config.colors.badge_pomo {
css.push_str(&format!(" --owlry-badge-pomo: {};\n", badge_pomo));
}
css.push_str("}\n"); css.push_str("}\n");
css css
} }

View File

@@ -35,6 +35,21 @@ struct SubmenuState {
saved_search: String, saved_search: String,
} }
/// State for lazy loading results
#[derive(Default)]
struct LazyLoadState {
/// All matching results (may be more than displayed)
all_results: Vec<LaunchItem>,
/// Number of items currently displayed
displayed_count: usize,
}
/// Number of items to display initially and per batch
const INITIAL_RESULTS: usize = 15;
const LOAD_MORE_BATCH: usize = 10;
/// Debounce delay for search input (milliseconds)
const SEARCH_DEBOUNCE_MS: u64 = 50;
pub struct MainWindow { pub struct MainWindow {
window: ApplicationWindow, window: ApplicationWindow,
search_entry: Entry, search_entry: Entry,
@@ -51,6 +66,15 @@ pub struct MainWindow {
submenu_state: Rc<RefCell<SubmenuState>>, submenu_state: Rc<RefCell<SubmenuState>>,
/// Parsed tab config (ProviderTypes for cycling) /// Parsed tab config (ProviderTypes for cycling)
tab_order: Rc<Vec<ProviderType>>, tab_order: Rc<Vec<ProviderType>>,
/// Custom prompt text (overrides dynamic placeholder when set)
#[allow(dead_code)]
custom_prompt: Option<String>,
/// Lazy loading state
lazy_state: Rc<RefCell<LazyLoadState>>,
/// Debounce source ID for cancelling pending searches
debounce_source: Rc<RefCell<Option<gtk4::glib::SourceId>>>,
/// Whether we're in dmenu mode (stdin pipe input)
is_dmenu_mode: bool,
} }
impl MainWindow { impl MainWindow {
@@ -60,6 +84,7 @@ impl MainWindow {
providers: Rc<RefCell<ProviderManager>>, providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>, frecency: Rc<RefCell<FrecencyStore>>,
filter: Rc<RefCell<ProviderFilter>>, filter: Rc<RefCell<ProviderFilter>>,
custom_prompt: Option<String>,
) -> Self { ) -> Self {
let cfg = config.borrow(); let cfg = config.borrow();
@@ -111,24 +136,21 @@ impl MainWindow {
.build(); .build();
filter_tabs.add_css_class("owlry-filter-tabs"); filter_tabs.add_css_class("owlry-filter-tabs");
// Parse tabs config to ProviderTypes // Get enabled providers from filter (which respects CLI --mode/--providers or config)
let tab_order: Vec<ProviderType> = cfg // This makes tabs dynamic based on what's actually enabled
.general let enabled = filter.borrow().enabled_providers();
.tabs let tab_strings: Vec<String> = enabled.iter().map(|p| p.to_string()).collect();
.iter() let tab_order = Rc::new(enabled);
.filter_map(|s| s.parse().ok())
.collect();
let tab_order = Rc::new(tab_order);
// Create toggle buttons for each provider (from config) // Create toggle buttons for each enabled provider
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &cfg.general.tabs); let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &tab_strings);
let filter_buttons = Rc::new(RefCell::new(filter_buttons)); let filter_buttons = Rc::new(RefCell::new(filter_buttons));
header_box.append(&mode_label); header_box.append(&mode_label);
header_box.append(&filter_tabs); header_box.append(&filter_tabs);
// Search entry with dynamic placeholder // Search entry with dynamic placeholder (or custom prompt if provided)
let placeholder = Self::build_placeholder(&filter.borrow()); let placeholder = custom_prompt.clone().unwrap_or_else(|| Self::build_placeholder(&filter.borrow()));
let search_entry = Entry::builder() let search_entry = Entry::builder()
.placeholder_text(&placeholder) .placeholder_text(&placeholder)
.hexpand(true) .hexpand(true)
@@ -175,6 +197,11 @@ impl MainWindow {
drop(cfg); drop(cfg);
let lazy_state = Rc::new(RefCell::new(LazyLoadState::default()));
// Check if we're in dmenu mode (stdin pipe input)
let is_dmenu_mode = providers.borrow().is_dmenu_mode();
let main_window = Self { let main_window = Self {
window, window,
search_entry, search_entry,
@@ -190,9 +217,14 @@ impl MainWindow {
filter_buttons, filter_buttons,
submenu_state: Rc::new(RefCell::new(SubmenuState::default())), submenu_state: Rc::new(RefCell::new(SubmenuState::default())),
tab_order, tab_order,
custom_prompt,
lazy_state,
debounce_source: Rc::new(RefCell::new(None)),
is_dmenu_mode,
}; };
main_window.setup_signals(); main_window.setup_signals();
main_window.setup_lazy_loading();
main_window.update_results(""); main_window.update_results("");
// Ensure search entry has focus when window is shown // Ensure search entry has focus when window is shown
@@ -232,11 +264,11 @@ impl MainWindow {
let mut results = current_results_for_auto.borrow_mut(); let mut results = current_results_for_auto.borrow_mut();
for type_id in &widget_ids { for type_id in &widget_ids {
if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id) { if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id)
if let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id) { && let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id)
existing.name = new_item.name; {
existing.description = new_item.description; existing.name = new_item.name;
} existing.description = new_item.description;
} }
} }
} }
@@ -263,11 +295,21 @@ impl MainWindow {
} }
}; };
let label = Self::provider_tab_label(&provider_type); let base_label = Self::provider_tab_label(&provider_type);
// Show number hint in the label for first 9 tabs (using superscript)
let label = if idx < 9 {
let superscript = match idx + 1 {
1 => "¹", 2 => "²", 3 => "³", 4 => "", 5 => "",
6 => "", 7 => "", 8 => "", 9 => "", _ => "",
};
format!("{}{}", base_label, superscript)
} else {
base_label.to_string()
};
let shortcut = format!("Ctrl+{}", idx + 1); let shortcut = format!("Ctrl+{}", idx + 1);
let button = ToggleButton::builder() let button = ToggleButton::builder()
.label(label) .label(&label)
.tooltip_text(&shortcut) .tooltip_text(&shortcut)
.active(filter.borrow().is_enabled(provider_type.clone())) .active(filter.borrow().is_enabled(provider_type.clone()))
.build(); .build();
@@ -284,48 +326,54 @@ impl MainWindow {
} }
/// Get display label for a provider tab /// Get display label for a provider tab
/// Core types have fixed labels; plugins derive labels from type_id
fn provider_tab_label(provider: &ProviderType) -> &'static str { fn provider_tab_label(provider: &ProviderType) -> &'static str {
match provider { match provider {
ProviderType::Application => "Apps", ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clip",
ProviderType::Command => "Cmds", ProviderType::Command => "Cmds",
ProviderType::Dmenu => "Dmenu", ProviderType::Dmenu => "Dmenu",
ProviderType::Emoji => "Emoji", ProviderType::Plugin(type_id) => match type_id.as_str() {
ProviderType::Files => "Files", "bookmarks" => "Bookmarks",
ProviderType::MediaPlayer => "Media", "calc" => "Calc",
ProviderType::Pomodoro => "Pomo", "clipboard" => "Clip",
ProviderType::Scripts => "Scripts", "emoji" => "Emoji",
ProviderType::Ssh => "SSH", "filesearch" => "Files",
ProviderType::System => "System", "media" => "Media",
ProviderType::Uuctl => "uuctl", "pomodoro" => "Pomo",
ProviderType::Weather => "Weather", "scripts" => "Scripts",
ProviderType::WebSearch => "Web", "ssh" => "SSH",
ProviderType::Plugin(_) => "Plugin", "system" => "System",
"uuctl" => "uuctl",
"weather" => "Weather",
"websearch" => "Web",
_ => "Plugin",
},
} }
} }
/// Get CSS class for a provider /// Get CSS class for a provider
/// Core types have fixed CSS classes; plugins derive from type_id
fn provider_css_class(provider: &ProviderType) -> &'static str { fn provider_css_class(provider: &ProviderType) -> &'static str {
match provider { match provider {
ProviderType::Application => "owlry-filter-app", 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::Command => "owlry-filter-cmd",
ProviderType::Dmenu => "owlry-filter-dmenu", ProviderType::Dmenu => "owlry-filter-dmenu",
ProviderType::Emoji => "owlry-filter-emoji", ProviderType::Plugin(type_id) => match type_id.as_str() {
ProviderType::Files => "owlry-filter-file", "bookmarks" => "owlry-filter-bookmark",
ProviderType::MediaPlayer => "owlry-filter-media", "calc" => "owlry-filter-calc",
ProviderType::Pomodoro => "owlry-filter-pomodoro", "clipboard" => "owlry-filter-clip",
ProviderType::Scripts => "owlry-filter-script", "emoji" => "owlry-filter-emoji",
ProviderType::Ssh => "owlry-filter-ssh", "filesearch" => "owlry-filter-file",
ProviderType::System => "owlry-filter-sys", "media" => "owlry-filter-media",
ProviderType::Uuctl => "owlry-filter-uuctl", "pomodoro" => "owlry-filter-pomodoro",
ProviderType::Weather => "owlry-filter-weather", "scripts" => "owlry-filter-script",
ProviderType::WebSearch => "owlry-filter-web", "ssh" => "owlry-filter-ssh",
ProviderType::Plugin(_) => "owlry-filter-plugin", "system" => "owlry-filter-sys",
"uuctl" => "owlry-filter-uuctl",
"weather" => "owlry-filter-weather",
"websearch" => "owlry-filter-web",
_ => "owlry-filter-plugin",
},
} }
} }
@@ -335,22 +383,24 @@ impl MainWindow {
.iter() .iter()
.map(|p| match p { .map(|p| match p {
ProviderType::Application => "applications", ProviderType::Application => "applications",
ProviderType::Bookmarks => "bookmarks",
ProviderType::Calculator => "calculator",
ProviderType::Clipboard => "clipboard",
ProviderType::Command => "commands", ProviderType::Command => "commands",
ProviderType::Dmenu => "options", ProviderType::Dmenu => "options",
ProviderType::Emoji => "emoji", ProviderType::Plugin(type_id) => match type_id.as_str() {
ProviderType::Files => "files", "bookmarks" => "bookmarks",
ProviderType::MediaPlayer => "media", "calc" => "calculator",
ProviderType::Pomodoro => "pomodoro", "clipboard" => "clipboard",
ProviderType::Scripts => "scripts", "emoji" => "emoji",
ProviderType::Ssh => "SSH hosts", "filesearch" => "files",
ProviderType::System => "system", "media" => "media",
ProviderType::Uuctl => "uuctl units", "pomodoro" => "pomodoro",
ProviderType::Weather => "weather", "scripts" => "scripts",
ProviderType::WebSearch => "web", "ssh" => "SSH hosts",
ProviderType::Plugin(_) => "plugins", "system" => "system",
"uuctl" => "uuctl units",
"weather" => "weather",
"websearch" => "web",
_ => "plugins",
},
}) })
.collect(); .collect();
@@ -515,7 +565,7 @@ impl MainWindow {
} }
fn setup_signals(&self) { fn setup_signals(&self) {
// Search input handling with prefix detection // Search input handling with prefix detection and debouncing
let providers = self.providers.clone(); let providers = self.providers.clone();
let results_list = self.results_list.clone(); let results_list = self.results_list.clone();
let config = self.config.clone(); let config = self.config.clone();
@@ -525,11 +575,13 @@ impl MainWindow {
let mode_label = self.mode_label.clone(); let mode_label = self.mode_label.clone();
let search_entry_for_change = self.search_entry.clone(); let search_entry_for_change = self.search_entry.clone();
let submenu_state = self.submenu_state.clone(); let submenu_state = self.submenu_state.clone();
let lazy_state = self.lazy_state.clone();
let debounce_source = self.debounce_source.clone();
self.search_entry.connect_changed(move |entry| { self.search_entry.connect_changed(move |entry| {
let raw_query = entry.text(); let raw_query = entry.text();
// If in submenu, filter the submenu items // If in submenu, filter immediately (no debounce needed for small local lists)
if submenu_state.borrow().active { if submenu_state.borrow().active {
let state = submenu_state.borrow(); let state = submenu_state.borrow();
let query = raw_query.to_lowercase(); let query = raw_query.to_lowercase();
@@ -567,7 +619,7 @@ impl MainWindow {
return; return;
} }
// Normal mode: parse prefix and search // Normal mode: update prefix/UI immediately for responsiveness
let parsed = ProviderFilter::parse_query(&raw_query); let parsed = ProviderFilter::parse_query(&raw_query);
{ {
@@ -580,63 +632,102 @@ impl MainWindow {
if let Some(ref prefix) = parsed.prefix { if let Some(ref prefix) = parsed.prefix {
let prefix_name = match prefix { let prefix_name = match prefix {
ProviderType::Application => "applications", ProviderType::Application => "applications",
ProviderType::Bookmarks => "bookmarks",
ProviderType::Calculator => "calculator",
ProviderType::Clipboard => "clipboard",
ProviderType::Command => "commands", ProviderType::Command => "commands",
ProviderType::Dmenu => "options", ProviderType::Dmenu => "options",
ProviderType::Emoji => "emoji", ProviderType::Plugin(type_id) => match type_id.as_str() {
ProviderType::Files => "files", "bookmarks" => "bookmarks",
ProviderType::MediaPlayer => "media", "calc" => "calculator",
ProviderType::Pomodoro => "pomodoro", "clipboard" => "clipboard",
ProviderType::Scripts => "scripts", "emoji" => "emoji",
ProviderType::Ssh => "SSH hosts", "filesearch" => "files",
ProviderType::System => "system", "media" => "media",
ProviderType::Uuctl => "uuctl units", "pomodoro" => "pomodoro",
ProviderType::Weather => "weather", "scripts" => "scripts",
ProviderType::WebSearch => "web", "ssh" => "SSH hosts",
ProviderType::Plugin(_) => "plugins", "system" => "system",
"uuctl" => "uuctl units",
"weather" => "weather",
"websearch" => "web",
_ => "plugins",
},
}; };
search_entry_for_change search_entry_for_change
.set_placeholder_text(Some(&format!("Search {}...", prefix_name))); .set_placeholder_text(Some(&format!("Search {}...", prefix_name)));
} }
let cfg = config.borrow(); // Cancel any pending debounced search
let max_results = cfg.general.max_results; if let Some(source_id) = debounce_source.borrow_mut().take() {
let frecency_weight = cfg.providers.frecency_weight; source_id.remove();
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);
} }
for item in &results { // Clone references for the debounced closure
let row = ResultRow::new(item); let providers = providers.clone();
results_list.append(&row); let results_list = results_list.clone();
} let config = config.clone();
let frecency = frecency.clone();
let current_results = current_results.clone();
let filter = filter.clone();
let lazy_state = lazy_state.clone();
let debounce_source_for_closure = debounce_source.clone();
if let Some(first_row) = results_list.row_at_index(0) { // Schedule debounced search
results_list.select_row(Some(&first_row)); let source_id = gtk4::glib::timeout_add_local_once(
} std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS),
move || {
// Clear the source ID since we're now executing
*debounce_source_for_closure.borrow_mut() = None;
*current_results.borrow_mut() = results; 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()
};
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
if let Some(first_row) = results_list.row_at_index(0) {
results_list.select_row(Some(&first_row));
}
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
},
);
*debounce_source.borrow_mut() = Some(source_id);
}); });
// Entry activate signal (Enter key in search entry) // Entry activate signal (Enter key in search entry)
@@ -650,12 +741,14 @@ impl MainWindow {
let mode_label_for_activate = self.mode_label.clone(); let mode_label_for_activate = self.mode_label.clone();
let hints_label_for_activate = self.hints_label.clone(); let hints_label_for_activate = self.hints_label.clone();
let search_entry_for_activate = self.search_entry.clone(); let search_entry_for_activate = self.search_entry.clone();
let is_dmenu_mode_for_activate = self.is_dmenu_mode;
self.search_entry.connect_activate(move |entry| { self.search_entry.connect_activate(move |entry| {
let selected = results_list_for_activate let selected = results_list_for_activate
.selected_row() .selected_row()
.or_else(|| results_list_for_activate.row_at_index(0)); .or_else(|| results_list_for_activate.row_at_index(0));
// Handle the case where we have a selected item
if let Some(row) = selected { if let Some(row) = selected {
let index = row.index() as usize; let index = row.index() as usize;
let results = current_results_for_activate.borrow(); let results = current_results_for_activate.borrow();
@@ -702,6 +795,10 @@ impl MainWindow {
&providers_for_activate, &providers_for_activate,
); );
if should_close { if should_close {
// In dmenu mode, exit with success code
if is_dmenu_mode_for_activate {
std::process::exit(0);
}
window_for_activate.close(); window_for_activate.close();
} else { } else {
// Trigger search refresh for updated widget state // Trigger search refresh for updated widget state
@@ -709,6 +806,16 @@ impl MainWindow {
} }
} }
} }
return;
}
}
// No item selected/matched - in dmenu mode, output the typed text
if is_dmenu_mode_for_activate {
let text = entry.text();
if !text.is_empty() {
println!("{}", text);
std::process::exit(0);
} }
} }
}); });
@@ -749,6 +856,7 @@ impl MainWindow {
let hints_label = self.hints_label.clone(); let hints_label = self.hints_label.clone();
let submenu_state = self.submenu_state.clone(); let submenu_state = self.submenu_state.clone();
let tab_order = self.tab_order.clone(); let tab_order = self.tab_order.clone();
let is_dmenu_mode = self.is_dmenu_mode;
key_controller.connect_key_pressed(move |_, key, _, modifiers| { key_controller.connect_key_pressed(move |_, key, _, modifiers| {
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK); let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
@@ -771,6 +879,10 @@ impl MainWindow {
); );
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
} else { } else {
// In dmenu mode, exit with cancel code (1)
if is_dmenu_mode {
std::process::exit(1);
}
window.close(); window.close();
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
} }
@@ -788,6 +900,10 @@ impl MainWindow {
); );
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
} else { } else {
// In dmenu mode, exit with cancel code (1)
if is_dmenu_mode {
std::process::exit(1);
}
window.close(); window.close();
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
} }
@@ -847,6 +963,7 @@ impl MainWindow {
// Ctrl+1-9 toggle specific providers based on tab order (only when not in submenu) // 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::_1 | Key::_2 | Key::_3 | Key::_4 | Key::_5 |
Key::_6 | Key::_7 | Key::_8 | Key::_9 if ctrl => { Key::_6 | Key::_7 | Key::_8 | Key::_9 if ctrl => {
info!("[UI] Ctrl+number detected: {:?}", key);
if !submenu_state.borrow().active { if !submenu_state.borrow().active {
let idx = match key { let idx = match key {
Key::_1 => 0, Key::_1 => 0,
@@ -860,7 +977,9 @@ impl MainWindow {
Key::_9 => 8, Key::_9 => 8,
_ => return gtk4::glib::Propagation::Proceed, _ => return gtk4::glib::Propagation::Proceed,
}; };
info!("[UI] Toggling tab at index {}", idx);
if let Some(provider) = tab_order.get(idx) { if let Some(provider) = tab_order.get(idx) {
info!("[UI] Found provider: {:?}", provider);
Self::toggle_provider_button( Self::toggle_provider_button(
provider.clone(), provider.clone(),
&filter, &filter,
@@ -868,6 +987,8 @@ impl MainWindow {
&search_entry, &search_entry,
&mode_label, &mode_label,
); );
} else {
info!("[UI] No provider at index {}, tab_order len={}", idx, tab_order.len());
} }
} }
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
@@ -955,25 +1076,65 @@ impl MainWindow {
} }
let current = filter.borrow().enabled_providers(); let current = filter.borrow().enabled_providers();
let all_enabled = current.len() == tab_order.len();
let next = if current.len() == 1 { // Cycle: All -> Provider1 -> Provider2 -> ... -> ProviderN -> All
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0); // In "All" mode (all providers enabled), we go to first provider (forward) or last (backward)
if forward { // In single-provider mode, we go to next provider or back to All at the boundary
tab_order[(idx + 1) % tab_order.len()].clone() if all_enabled {
// Currently showing all, go to first (forward) or last (backward) single provider
let next = if forward {
tab_order[0].clone()
} else { } else {
tab_order[(idx + tab_order.len() - 1) % tab_order.len()].clone() tab_order[tab_order.len() - 1].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
} else if current.len() == 1 {
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
let at_boundary = if forward { idx == tab_order.len() - 1 } else { idx == 0 };
if at_boundary {
// At boundary, go back to "All" mode
{
let mut f = filter.borrow_mut();
for provider in tab_order {
f.enable(provider.clone());
}
}
for (_, button) in buttons.borrow().iter() {
button.set_active(true);
}
} else {
// Move to next/previous provider
let next = if forward {
tab_order[idx + 1].clone()
} else {
tab_order[idx - 1].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
} }
} else { } else {
tab_order[0].clone() // Some but not all providers enabled - go to first provider
}; let next = tab_order[0].clone();
{
{ let mut f = filter.borrow_mut();
let mut f = filter.borrow_mut(); f.set_single_mode(next.clone());
f.set_single_mode(next.clone()); }
} for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
for (ptype, button) in buttons.borrow().iter() { }
button.set_active(ptype == &next);
} }
mode_label.set_label(filter.borrow().mode_display_name()); mode_label.set_label(filter.borrow().mode_display_name());
@@ -1009,6 +1170,7 @@ impl MainWindow {
let use_frecency = cfg.providers.frecency; let use_frecency = cfg.providers.frecency;
drop(cfg); drop(cfg);
// Fetch all matching results (up to max_results)
let results: Vec<LaunchItem> = if use_frecency { let results: Vec<LaunchItem> = if use_frecency {
self.providers self.providers
.borrow_mut() .borrow_mut()
@@ -1025,11 +1187,21 @@ impl MainWindow {
.collect() .collect()
}; };
// Clear existing results
while let Some(child) = self.results_list.first_child() { while let Some(child) = self.results_list.first_child() {
self.results_list.remove(&child); self.results_list.remove(&child);
} }
for item in &results { // Store all results for lazy loading
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = self.lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display initial batch only
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item); let row = ResultRow::new(item);
self.results_list.append(&row); self.results_list.append(&row);
} }
@@ -1038,7 +1210,74 @@ impl MainWindow {
self.results_list.select_row(Some(&first_row)); self.results_list.select_row(Some(&first_row));
} }
*self.current_results.borrow_mut() = results; // current_results holds what's currently displayed
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
}
/// Set up lazy loading scroll detection
fn setup_lazy_loading(&self) {
let vadj = self.scrolled.vadjustment();
let results_list = self.results_list.clone();
let lazy_state = self.lazy_state.clone();
let current_results = self.current_results.clone();
// Load more on scroll
vadj.connect_value_changed(move |adj| {
let value = adj.value();
let upper = adj.upper();
let page_size = adj.page_size();
// Load more when near bottom (within 50px)
let near_bottom = upper > page_size && (value + page_size >= upper - 50.0);
if near_bottom {
Self::load_more_items(&lazy_state, &results_list, &current_results);
}
});
// Also load more when selecting rows near the end (keyboard navigation)
let lazy_state2 = self.lazy_state.clone();
let results_list2 = self.results_list.clone();
let current_results2 = self.current_results.clone();
self.results_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let index = row.index();
let lazy = lazy_state2.borrow();
let displayed = lazy.displayed_count;
let all_count = lazy.all_results.len();
drop(lazy);
// Load more if within 3 items of the end
if displayed < all_count && (index as usize) >= displayed.saturating_sub(3) {
Self::load_more_items(&lazy_state2, &results_list2, &current_results2);
}
}
});
}
/// Load more items from lazy state
fn load_more_items(
lazy_state: &Rc<RefCell<LazyLoadState>>,
results_list: &ListBox,
current_results: &Rc<RefCell<Vec<LaunchItem>>>,
) {
let mut lazy = lazy_state.borrow_mut();
let all_count = lazy.all_results.len();
let displayed = lazy.displayed_count;
if displayed < all_count {
// Load next batch
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
for item in lazy.all_results[displayed..new_end].iter() {
let row = ResultRow::new(item);
results_list.append(&row);
}
lazy.displayed_count = new_end;
// Update current_results
let mut current = current_results.borrow_mut();
current.extend(lazy.all_results[displayed..new_end].iter().cloned());
}
} }
/// Handle item activation - returns true if window should close /// Handle item activation - returns true if window should close
@@ -1062,6 +1301,12 @@ impl MainWindow {
} }
fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) { fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) {
// dmenu mode: print selection to stdout instead of executing
if matches!(item.provider, ProviderType::Dmenu) {
println!("{}", item.name);
return;
}
// Record this launch for frecency tracking // Record this launch for frecency tracking
if config.providers.frecency { if config.providers.frecency {
frecency.borrow_mut().record_launch(&item.id); frecency.borrow_mut().record_launch(&item.id);
@@ -1072,17 +1317,89 @@ impl MainWindow {
info!("Launching: {} ({})", item.name, item.command); info!("Launching: {} ({})", item.name, item.command);
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider); debug!("[UI] Launch details: terminal={}, provider={:?}, id={}", item.terminal, item.provider, item.id);
let cmd = if item.terminal { // Check if this is a desktop application (has .desktop file as ID)
format!("{} -e {}", config.general.terminal_command, item.command) let is_desktop_app = matches!(item.provider, ProviderType::Application)
&& item.id.ends_with(".desktop");
// Desktop files should be launched via proper launchers that implement the
// freedesktop Desktop Entry spec (D-Bus activation, field codes, env vars, etc.)
// We delegate to: uwsm (if configured), gio launch, or gtk-launch as fallback.
//
// Non-desktop items (commands, plugins) use sh -c for shell execution.
let result = if is_desktop_app {
Self::launch_desktop_file(&item.id, config)
} else { } else {
item.command.clone() Self::launch_command(&item.command, item.terminal, config)
}; };
// Detect if this is a shell command vs an application launch if let Err(e) = result {
// Shell commands: playerctl, dbus-send, systemctl, journalctl, or anything with shell operators let msg = format!("Failed to launch '{}': {}", item.name, e);
let is_shell_command = cmd.starts_with("playerctl ") log::error!("{}", msg);
crate::notify::notify("Launch failed", &msg);
}
}
/// Launch a .desktop file.
///
/// When `use_uwsm` is enabled in config, launches via `uwsm app -- <file>`
/// which starts the app in a proper systemd user session.
///
/// Otherwise, uses `gio launch` which is always available (part of glib2/GTK4)
/// and handles D-Bus activation, field codes, Terminal flag, etc.
fn launch_desktop_file(desktop_path: &str, config: &Config) -> std::io::Result<std::process::Child> {
use std::path::Path;
// Check if desktop file exists
if !Path::new(desktop_path).exists() {
let msg = format!("Desktop file not found: {}", desktop_path);
log::error!("{}", msg);
crate::notify::notify("Launch failed", &msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
if config.general.use_uwsm {
// Check if uwsm is available
let uwsm_available = Command::new("which")
.arg("uwsm")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !uwsm_available {
let msg = "uwsm is enabled in config but not installed";
log::error!("{}", msg);
crate::notify::notify("Launch failed", msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
info!("Launching via uwsm: {}", desktop_path);
Command::new("uwsm")
.args(["app", "--", desktop_path])
.spawn()
} else {
info!("Launching via gio: {}", desktop_path);
Command::new("gio")
.args(["launch", desktop_path])
.spawn()
}
}
/// Launch a shell command (for non-desktop items like PATH commands, plugins, etc.)
fn launch_command(command: &str, terminal: bool, config: &Config) -> std::io::Result<std::process::Child> {
let cmd = if terminal {
let terminal_cmd = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal_cmd, command)
} else {
command.to_string()
};
// Shell/system commands run directly without uwsm wrapper
// (they're typically short-lived or system utilities)
let is_system_command = cmd.starts_with("playerctl ")
|| cmd.starts_with("dbus-send ") || cmd.starts_with("dbus-send ")
|| cmd.starts_with("systemctl ") || cmd.starts_with("systemctl ")
|| cmd.starts_with("journalctl ") || cmd.starts_with("journalctl ")
@@ -1092,28 +1409,14 @@ impl MainWindow {
|| cmd.contains(" > ") || cmd.contains(" > ")
|| cmd.contains(" < "); || cmd.contains(" < ");
// Use launch wrapper if configured (uwsm, hyprctl, etc.) // Use uwsm for regular commands if enabled (and not a system command)
// But skip wrapper for shell commands - they need sh -c if config.general.use_uwsm && !is_system_command {
let result = match &config.general.launch_wrapper { info!("Launching command via uwsm: {}", cmd);
Some(wrapper) if !wrapper.is_empty() && !is_shell_command => { Command::new("uwsm")
info!("Using launch wrapper: {}", wrapper); .args(["app", "--", "sh", "-c", &cmd])
// Split wrapper into command and args (e.g., "uwsm app --" -> ["uwsm", "app", "--"]) .spawn()
let mut wrapper_parts: Vec<&str> = wrapper.split_whitespace().collect(); } else {
if wrapper_parts.is_empty() { Command::new("sh").arg("-c").arg(&cmd).spawn()
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

@@ -7,6 +7,17 @@ pub struct ResultRow {
row: ListBoxRow, row: ListBoxRow,
} }
/// Check if a string looks like an emoji (starts with a non-ASCII character
/// and is very short - typically 1-4 chars for complex emojis with ZWJ)
fn is_emoji_icon(s: &str) -> bool {
if s.is_empty() {
return false;
}
// Emojis are non-ASCII and typically very short (1-8 chars for complex ZWJ sequences)
let first_char = s.chars().next().unwrap();
!first_char.is_ascii() && s.chars().count() <= 8
}
impl ResultRow { impl ResultRow {
#[allow(clippy::new_ret_no_self)] #[allow(clippy::new_ret_no_self)]
pub fn new(item: &LaunchItem) -> ListBoxRow { pub fn new(item: &LaunchItem) -> ListBoxRow {
@@ -26,46 +37,60 @@ impl ResultRow {
.margin_end(12) .margin_end(12)
.build(); .build();
// Icon - handle GResource paths, file paths, icon names, and fallbacks // Icon - handle GResource paths, file paths, icon names, emojis, and fallbacks
let icon_widget: Widget = if let Some(icon_path) = &item.icon { let icon_widget: Widget = if let Some(icon_path) = &item.icon {
let img = if icon_path.starts_with("/org/owlry/launcher/icons/") { if is_emoji_icon(icon_path) {
// Emoji character - display as text label
let emoji_label = Label::builder()
.label(icon_path)
.width_request(32)
.height_request(32)
.valign(gtk4::Align::Center)
.halign(gtk4::Align::Center)
.build();
emoji_label.add_css_class("owlry-result-icon");
emoji_label.add_css_class("owlry-emoji-icon");
emoji_label.upcast()
} else if icon_path.starts_with("/org/owlry/launcher/icons/") {
// GResource path - load from bundled resources // GResource path - load from bundled resources
Image::from_resource(icon_path) let img = Image::from_resource(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
// SVG icons from resources should be treated as symbolic for color inheritance
if icon_path.ends_with(".svg") {
img.add_css_class("owlry-symbolic-icon");
}
img.upcast()
} else if icon_path.starts_with('/') { } else if icon_path.starts_with('/') {
// Absolute file path // Absolute file path
Image::from_file(icon_path) let img = Image::from_file(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
} else { } else {
// Icon theme name // Icon theme name
Image::from_icon_name(icon_path) let img = Image::from_icon_name(icon_path);
}; img.set_pixel_size(32);
img.set_pixel_size(32); img.add_css_class("owlry-result-icon");
img.add_css_class("owlry-result-icon"); // Add symbolic class for icons ending with "-symbolic"
img.upcast() if icon_path.ends_with("-symbolic") {
img.add_css_class("owlry-symbolic-icon");
}
img.upcast()
}
} else { } else {
// Default icon based on provider type // Default icon based on provider type (only core types, plugins should provide icons)
let default_icon = match &item.provider { let default_icon = match &item.provider {
crate::providers::ProviderType::Application => "application-x-executable", crate::providers::ProviderType::Application => "application-x-executable-symbolic",
crate::providers::ProviderType::Bookmarks => "user-bookmarks", crate::providers::ProviderType::Command => "utilities-terminal-symbolic",
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::Dmenu => "view-list-symbolic",
crate::providers::ProviderType::Emoji => "face-smile", // Plugins should provide their own icon; fallback to generic addon icon
crate::providers::ProviderType::Files => "folder", crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
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",
// Widget providers now have icons set, but keep fallbacks
crate::providers::ProviderType::Weather => "weather-clear-symbolic",
crate::providers::ProviderType::MediaPlayer => "media-playback-start-symbolic",
crate::providers::ProviderType::Pomodoro => "alarm-symbolic",
crate::providers::ProviderType::Plugin(_) => "application-x-addon",
}; };
let img = Image::from_icon_name(default_icon); let img = Image::from_icon_name(default_icon);
img.set_pixel_size(32); img.set_pixel_size(32);
img.add_css_class("owlry-result-icon"); img.add_css_class("owlry-result-icon");
img.add_css_class("owlry-symbolic-icon");
img.upcast() img.upcast()
}; };

View File

@@ -17,22 +17,47 @@
# │ Runtimes: /usr/lib/owlry/runtimes/*.so Lua/Rune runtimes │ # │ Runtimes: /usr/lib/owlry/runtimes/*.so Lua/Rune runtimes │
# └─────────────────────────────────────────────────────────────────────┘ # └─────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════
# DMENU MODE
# ═══════════════════════════════════════════════════════════════════════
#
# Dmenu mode provides interactive selection from piped input.
# The selected item is printed to stdout (not executed), so pipe
# the output to execute it:
#
# ┌─────────────────────────────────────────────────────────────────────┐
# │ # Screenshot menu │
# │ printf '%s\n' \ │
# │ "grimblast --notify copy screen" \ │
# │ "grimblast --notify copy area" \ │
# │ | owlry -m dmenu -p "Screenshot" \ │
# │ | sh │
# │ │
# │ # Git branch checkout │
# │ git branch | owlry -m dmenu -p "checkout" | xargs git checkout │
# │ │
# │ # Package search │
# │ pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S │
# └─────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# GENERAL # GENERAL
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
[general] [general]
show_icons = true show_icons = true
max_results = 10 max_results = 100
# Terminal emulator for SSH, scripts, etc. # Terminal emulator for SSH, scripts, etc.
# Auto-detection order: $TERMINAL → xdg-terminal-exec → DE-native → Wayland → X11 → xterm # Auto-detection order: $TERMINAL → xdg-terminal-exec → DE-native → Wayland → X11 → xterm
# Uncomment to override: # Uncomment to override:
# terminal_command = "kitty" # terminal_command = "kitty"
# Launch wrapper for app execution (auto-detected for uwsm/Hyprland) # Enable uwsm (Universal Wayland Session Manager) for launching apps.
# Examples: "uwsm app --", "hyprctl dispatch exec --", "" # When enabled, apps are launched via "uwsm app --" which starts them
# launch_wrapper = "uwsm app --" # in a proper systemd user session for better process management.
# Requires: uwsm to be installed
# use_uwsm = true
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.) # 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 # Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
@@ -49,9 +74,8 @@ font_size = 14
border_radius = 12 border_radius = 12
# Theme name - loads ~/.config/owlry/themes/{name}.css # Theme name - loads ~/.config/owlry/themes/{name}.css
# Built-in: owl, catppuccin-mocha, dracula, gruvbox-dark, nord, # Built-in: owl
# one-dark, rose-pine, solarized-dark, tokyo-night # Or leave unset/empty for GTK default
# Or leave unset for GTK default
# theme = "owl" # theme = "owl"
# Color overrides (applied on top of theme) # Color overrides (applied on top of theme)
@@ -63,28 +87,62 @@ border_radius = 12
# text_secondary = "#565f89" # text_secondary = "#565f89"
# accent = "#7aa2f7" # accent = "#7aa2f7"
# accent_bright = "#89b4fa" # accent_bright = "#89b4fa"
#
# Provider badge colors (optional)
# badge_app = "#7aa2f7"
# badge_cmd = "#9ece6a"
# badge_bookmark = "#e0af68"
# badge_calc = "#bb9af7"
# badge_clip = "#7dcfff"
# badge_dmenu = "#c0caf5"
# badge_emoji = "#f7768e"
# badge_file = "#73daca"
# badge_script = "#ff9e64"
# badge_ssh = "#2ac3de"
# badge_sys = "#f7768e"
# badge_uuctl = "#9ece6a"
# badge_web = "#7aa2f7"
# badge_media = "#bb9af7"
# badge_weather = "#7dcfff"
# badge_pomo = "#f7768e"
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# PLUGINS # PLUGINS
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# #
# All installed plugins are loaded by default. Use 'disabled' to blacklist. # All installed plugins are loaded by default. Use 'disabled_plugins' to blacklist.
# Plugin IDs: calculator, system, ssh, clipboard, emoji, scripts, bookmarks, # Plugin IDs: calculator, system, ssh, clipboard, emoji, scripts, bookmarks,
# websearch, filesearch, systemd, weather, media, pomodoro # websearch, filesearch, systemd, weather, media, pomodoro
[plugins] [plugins]
enabled = true # Master switch for all plugins
# Plugins to disable (by ID) # Plugins to disable (by ID)
disabled = [] disabled_plugins = []
# Examples: # Examples:
# disabled = ["emoji", "pomodoro"] # Disable specific plugins # disabled_plugins = ["emoji", "pomodoro"] # Disable specific plugins
# disabled = ["weather", "media"] # Disable widget plugins # disabled_plugins = ["weather", "media"] # Disable widget plugins
# Custom plugin registry URL (defaults to official registry)
# registry_url = "https://my-registry.example.com/plugins.json"
# ─────────────────────────────────────────────────────────────────────────
# Sandbox settings (for Lua/Rune script plugins)
# ─────────────────────────────────────────────────────────────────────────
# [plugins.sandbox]
# allow_filesystem = false # Allow file system access beyond plugin dir
# allow_network = false # Allow network requests
# allow_commands = false # Allow shell command execution
# memory_limit = 67108864 # Memory limit in bytes (64 MB default)
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# CORE PROVIDERS # PROVIDERS
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# #
# These are built into the core binary, not plugins. # Enable/disable providers and configure their settings.
# Core providers (applications, commands) are built into the binary.
# Plugin providers require their .so to be installed.
[providers] [providers]
# Core providers (always available) # Core providers (always available)
@@ -96,36 +154,41 @@ commands = true # Executables from $PATH
frecency = true frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# ═══════════════════════════════════════════════════════════════════════ # ─────────────────────────────────────────────────────────────────────────
# PLUGIN SETTINGS # Plugin provider toggles (require corresponding plugin installed)
# ═══════════════════════════════════════════════════════════════════════ # ─────────────────────────────────────────────────────────────────────────
# uuctl = true # systemd user units
# Settings for specific plugins. Only applies if the plugin is installed. system = true # System commands (shutdown, reboot, etc.)
ssh = true # SSH hosts from ~/.ssh/config
clipboard = true # Clipboard history (requires cliphist)
bookmarks = true # Browser bookmarks
emoji = true # Emoji picker
scripts = true # Custom scripts from ~/.local/share/owlry/scripts/
files = true # File search (requires fd or mlocate)
calculator = true # Calculator (= expression)
websearch = true # Web search (? query)
# Web Search plugin # ─────────────────────────────────────────────────────────────────────────
[providers.websearch] # Widget providers (displayed at top of results)
search_engine = "duckduckgo" # ─────────────────────────────────────────────────────────────────────────
media = true # MPRIS media player controls
weather = false # Weather widget (disabled by default)
pomodoro = false # Pomodoro timer (disabled by default)
# ─────────────────────────────────────────────────────────────────────────
# Provider settings
# ─────────────────────────────────────────────────────────────────────────
# Web search engine
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia # Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Custom URL: "https://search.example.com/?q={query}" # Or custom URL: "https://search.example.com/?q={query}"
search_engine = "duckduckgo"
# File Search plugin # Weather settings (when weather = true)
[providers.filesearch] # weather_provider = "wttr.in" # Options: wttr.in, openweathermap, open-meteo
max_results = 50 # weather_location = "Berlin" # City name or coordinates
# search_paths = ["/home", "/etc"] # Custom paths (default: $HOME) # weather_api_key = "" # Required for openweathermap
# Weather widget plugin # Pomodoro settings (when pomodoro = true)
[providers.weather] # pomodoro_work_mins = 25 # Work session duration
enabled = true # pomodoro_break_mins = 5 # Break duration
provider = "wttr.in" # wttr.in (default), openweathermap, open-meteo
location = "" # City name, "lat,lon", or empty for auto-detect
# api_key = "" # Required for OpenWeatherMap
# Pomodoro timer plugin
[providers.pomodoro]
enabled = true
work_mins = 25 # Work session duration
break_mins = 5 # Break duration
# Media controls plugin
[providers.media]
enabled = true

344
data/themes/apex-neon.css Normal file
View File

@@ -0,0 +1,344 @@
/*
* Owlry - Apex Neon Theme
* "State over Decoration."
*
* A high-contrast dark theme built for focus and clinical clarity.
* Color exists to signal STATE, not to decorate space.
*
* Author: S0wlz (Owlibou)
*
* ─────────────────────────────────────────────────────────────────
* APEX DNA - Semantic Color Roles:
*
* RED is the Predator: Active intent, cursor, current location, critical errors
* CYAN is Informational: Technical data, links, neutral highlights
* PURPLE is Sacred: Root access, special modes, exceptional states
* GREEN is Success: Completion, OK states, positive feedback
* YELLOW is Warning: Caution, load states, attention needed
*
* Rule: If a UI element is not important, it does not glow.
* ─────────────────────────────────────────────────────────────────
*
* Core Palette:
* - Void Black: #050505 (absolute background)
* - Dark Surface: #141414 (inputs, inactive elements)
* - Light Surface: #262626 (separators, borders)
* - Stark White: #ededed (primary text)
* - Muted: #737373 (secondary text)
* - Razor Red: #ff0044 (THE accent - focus, cursor, selection)
* - Electric Cyan: #00eaff (info, links, technical)
* - Sacred Purple: #9d00ff (special, root, elevated)
* - Neon Green: #00ff99 (success, OK)
* - Warning Yellow: #ffb700 (warning, caution)
*
* Bright Escalations:
* - Alert Red: #ff8899 (distinguishable from cursor)
* - Active Cyan: #5af3ff (active info)
* - Active Green: #2bffb2 (active success)
* - Urgent Yellow: #ffd24d (urgent warning)
* - Elevated Purple:#c84dff (elevated special)
*
* Usage: Set theme = "apex-neon" in config.toml
*/
:root {
/* Core surfaces */
--owlry-bg: #050505;
--owlry-bg-secondary: #141414;
--owlry-border: #262626;
--owlry-text: #ededed;
--owlry-text-secondary: #737373;
/* The Predator - primary accent */
--owlry-accent: #ff0044;
--owlry-accent-bright: #ff8899;
/* Provider badges - mapped to Apex semantics */
--owlry-badge-app: #00eaff; /* Cyan: apps are informational */
--owlry-badge-bookmark: #ffb700; /* Yellow: bookmarks need attention */
--owlry-badge-calc: #ffd24d; /* Bright Yellow: calculator results */
--owlry-badge-clip: #9d00ff; /* Purple: clipboard is special */
--owlry-badge-cmd: #9d00ff; /* Purple: commands are elevated */
--owlry-badge-dmenu: #00ff99; /* Green: dmenu is success/pipe */
--owlry-badge-emoji: #c84dff; /* Bright Purple: emoji is special */
--owlry-badge-file: #5af3ff; /* Bright Cyan: file search is active info */
--owlry-badge-script: #2bffb2; /* Bright Green: scripts execute successfully */
--owlry-badge-ssh: #00eaff; /* Cyan: SSH is technical/info */
--owlry-badge-sys: #ff0044; /* Red: system actions are critical */
--owlry-badge-uuctl: #ffb700; /* Yellow: uuctl requires attention */
--owlry-badge-web: #00eaff; /* Cyan: web is informational */
/* Widget badges */
--owlry-badge-media: #c84dff; /* Bright Purple: media is special */
--owlry-badge-weather: #5af3ff; /* Bright Cyan: weather is active info */
--owlry-badge-pomo: #ff8899; /* Alert Red: pomodoro demands attention */
}
.owlry-main {
background-color: rgba(5, 5, 5, 0.98);
border: 1px solid rgba(38, 38, 38, 0.8);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(255, 0, 68, 0.1);
}
.owlry-search {
background-color: rgba(20, 20, 20, 0.9);
border: 2px solid rgba(38, 38, 38, 0.8);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(255, 0, 68, 0.3);
}
.owlry-result-row:hover {
background-color: rgba(20, 20, 20, 0.8);
}
.owlry-result-row:selected {
background-color: rgba(255, 0, 68, 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);
}
/* Provider badges - styled per Apex semantics */
.owlry-badge-app {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-app);
}
.owlry-badge-bookmark {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-bookmark);
}
.owlry-badge-calc {
background-color: rgba(255, 210, 77, 0.15);
color: var(--owlry-badge-calc);
}
.owlry-badge-clip {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-clip);
}
.owlry-badge-cmd {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(0, 255, 153, 0.15);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-emoji {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-emoji);
}
.owlry-badge-file {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-file);
}
.owlry-badge-script {
background-color: rgba(43, 255, 178, 0.15);
color: var(--owlry-badge-script);
}
.owlry-badge-ssh {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-ssh);
}
.owlry-badge-sys {
background-color: rgba(255, 0, 68, 0.15);
color: var(--owlry-badge-sys);
}
.owlry-badge-uuctl {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-uuctl);
}
.owlry-badge-web {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-web);
}
/* Widget badges */
.owlry-badge-media {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-media);
}
.owlry-badge-weather {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-weather);
}
.owlry-badge-pomo {
background-color: rgba(255, 136, 153, 0.15);
color: var(--owlry-badge-pomo);
}
/* Filter button - default uses The Predator */
.owlry-filter-button:checked {
background-color: rgba(255, 0, 68, 0.2);
color: var(--owlry-accent);
border-color: rgba(255, 0, 68, 0.5);
}
/* Provider-specific filter buttons - follow Apex semantics */
.owlry-filter-app:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-app);
border-color: rgba(0, 234, 255, 0.5);
}
.owlry-filter-bookmark:checked {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-bookmark);
border-color: rgba(255, 183, 0, 0.5);
}
.owlry-filter-calc:checked {
background-color: rgba(255, 210, 77, 0.15);
color: var(--owlry-badge-calc);
border-color: rgba(255, 210, 77, 0.5);
}
.owlry-filter-clip:checked {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-clip);
border-color: rgba(157, 0, 255, 0.5);
}
.owlry-filter-cmd:checked {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-cmd);
border-color: rgba(157, 0, 255, 0.5);
}
.owlry-filter-dmenu:checked {
background-color: rgba(0, 255, 153, 0.15);
color: var(--owlry-badge-dmenu);
border-color: rgba(0, 255, 153, 0.5);
}
.owlry-filter-emoji:checked {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-emoji);
border-color: rgba(200, 77, 255, 0.5);
}
.owlry-filter-file:checked {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-file);
border-color: rgba(90, 243, 255, 0.5);
}
.owlry-filter-script:checked {
background-color: rgba(43, 255, 178, 0.15);
color: var(--owlry-badge-script);
border-color: rgba(43, 255, 178, 0.5);
}
.owlry-filter-ssh:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-ssh);
border-color: rgba(0, 234, 255, 0.5);
}
.owlry-filter-sys:checked {
background-color: rgba(255, 0, 68, 0.15);
color: var(--owlry-badge-sys);
border-color: rgba(255, 0, 68, 0.5);
}
.owlry-filter-uuctl:checked {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-uuctl);
border-color: rgba(255, 183, 0, 0.5);
}
.owlry-filter-web:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-web);
border-color: rgba(0, 234, 255, 0.5);
}
/* Widget filter buttons */
.owlry-filter-media:checked {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-media);
border-color: rgba(200, 77, 255, 0.5);
}
.owlry-filter-weather:checked {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-weather);
border-color: rgba(90, 243, 255, 0.5);
}
.owlry-filter-pomodoro:checked {
background-color: rgba(255, 136, 153, 0.15);
color: var(--owlry-badge-pomo);
border-color: rgba(255, 136, 153, 0.5);
}
/* Scrollbar - subtle in Void, The Predator on active */
scrollbar slider {
background-color: rgba(38, 38, 38, 0.8);
}
scrollbar slider:hover {
background-color: rgba(64, 64, 64, 0.9);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}
/* Text selection - Apex Hard Rule: black text on red (target locked) */
selection {
background-color: var(--owlry-accent);
color: #050505;
}
/* Mode indicator - The Predator marks current mode */
.owlry-mode-indicator {
background-color: rgba(255, 0, 68, 0.2);
color: var(--owlry-accent);
border: 1px solid rgba(255, 0, 68, 0.3);
}
/* Hints bar */
.owlry-hints {
border-top: 1px solid rgba(38, 38, 38, 0.8);
}
.owlry-hints-label {
color: var(--owlry-text-secondary);
}
/* Tag badges in results */
.owlry-tag-badge {
background-color: rgba(38, 38, 38, 0.6);
color: var(--owlry-text-secondary);
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: rgba(255, 136, 153, 0.25);
color: var(--owlry-accent-bright);
}

View File

@@ -143,9 +143,10 @@ chmod +x ~/.local/share/owlry/scripts/backup.sh
**Prefix:** `:bm` **Prefix:** `:bm`
**Package:** `owlry-plugin-bookmarks` **Package:** `owlry-plugin-bookmarks`
Browser bookmarks from Chromium-based browsers. Browser bookmarks from Firefox and Chromium-based browsers.
**Supported browsers:** **Supported browsers:**
- Firefox (reads places.sqlite)
- Google Chrome - Google Chrome
- Brave - Brave
- Microsoft Edge - Microsoft Edge
@@ -236,13 +237,7 @@ Current weather displayed at the top of results.
- OpenWeatherMap (requires API key) - OpenWeatherMap (requires API key)
- Open-Meteo (no API key required) - Open-Meteo (no API key required)
**Configuration:** **Note:** Weather configuration is currently embedded in the plugin. Future versions will support runtime configuration.
```toml
[plugins.weather]
provider = "wttr.in" # or: openweathermap, open-meteo
location = "London" # city name or "lat,lon" (empty for auto-detect)
# api_key = "..." # Required for OpenWeatherMap
```
**Features:** **Features:**
- Temperature, condition, humidity, wind speed - Temperature, condition, humidity, wind speed
@@ -274,13 +269,6 @@ MPRIS media player controls.
Pomodoro timer with work/break cycles. Pomodoro timer with work/break cycles.
**Configuration:**
```toml
[plugins.pomodoro]
work_mins = 25 # Work session duration (default: 25)
break_mins = 5 # Break duration (default: 5)
```
**Features:** **Features:**
- Configurable work session duration - Configurable work session duration
- Configurable break duration - Configurable break duration
@@ -301,17 +289,17 @@ For convenience, plugins are available in bundle meta-packages:
| Bundle | Plugins | | Bundle | Plugins |
|--------|---------| |--------|---------|
| `owlry-essentials` | calculator, system, ssh, scripts, bookmarks | | `owlry-meta-essentials` | calculator, system, ssh, scripts, bookmarks |
| `owlry-widgets` | weather, media, pomodoro | | `owlry-meta-widgets` | weather, media, pomodoro |
| `owlry-tools` | clipboard, emoji, websearch, filesearch, systemd | | `owlry-meta-tools` | clipboard, emoji, websearch, filesearch, systemd |
| `owlry-full` | All of the above | | `owlry-meta-full` | All of the above |
```bash ```bash
# Install everything # Install everything
yay -S owlry-full yay -S owlry-meta-full
# Or pick a bundle # Or pick a bundle
yay -S owlry-essentials owlry-widgets yay -S owlry-meta-essentials owlry-meta-widgets
``` ```
--- ---

View File

@@ -23,7 +23,7 @@ Edit `Cargo.toml`:
[package] [package]
name = "owlry-plugin-myplugin" name = "owlry-plugin-myplugin"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib"]
@@ -38,7 +38,7 @@ Edit `src/lib.rs`:
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo,
ProviderKind, API_VERSION, ProviderKind, ProviderPosition, API_VERSION,
}; };
extern "C" fn plugin_info() -> PluginInfo { extern "C" fn plugin_info() -> PluginInfo {
@@ -59,6 +59,8 @@ extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
icon: RString::from("application-x-executable"), icon: RString::from("application-x-executable"),
provider_type: ProviderKind::Static, provider_type: ProviderKind::Static,
type_id: RString::from("myplugin"), type_id: RString::from("myplugin"),
position: ProviderPosition::Normal,
priority: 0, // Use frecency-based ordering
}].into() }].into()
} }
@@ -198,12 +200,19 @@ pub struct ProviderInfo {
pub icon: RString, // Default icon name pub icon: RString, // Default icon name
pub provider_type: ProviderKind, // Static or Dynamic pub provider_type: ProviderKind, // Static or Dynamic
pub type_id: RString, // Short ID for badges pub type_id: RString, // Short ID for badges
pub position: ProviderPosition, // Normal or Widget
pub priority: i32, // Result ordering (higher = first)
} }
pub enum ProviderKind { pub enum ProviderKind {
Static, // Items loaded at startup via refresh() Static, // Items loaded at startup via refresh()
Dynamic, // Items computed per-query via query() Dynamic, // Items computed per-query via query()
} }
pub enum ProviderPosition {
Normal, // Standard results (sorted by score/frecency)
Widget, // Displayed at top when query is empty
}
``` ```
### PluginItem ### PluginItem

186
justfile
View File

@@ -60,6 +60,18 @@ install-local:
sudo mkdir -p /usr/lib/owlry/plugins sudo mkdir -p /usr/lib/owlry/plugins
sudo mkdir -p /usr/lib/owlry/runtimes sudo mkdir -p /usr/lib/owlry/runtimes
echo "Cleaning up stale files..."
# Remove runtime files that may have ended up in plugins dir (from old installs)
sudo rm -f /usr/lib/owlry/plugins/libowlry_lua.so /usr/lib/owlry/plugins/libowlry_rune.so
# Remove old short-named plugin files (from old AUR packages before naming standardization)
sudo rm -f /usr/lib/owlry/plugins/libbookmarks.so /usr/lib/owlry/plugins/libcalculator.so \
/usr/lib/owlry/plugins/libclipboard.so /usr/lib/owlry/plugins/libemoji.so \
/usr/lib/owlry/plugins/libfilesearch.so /usr/lib/owlry/plugins/libmedia.so \
/usr/lib/owlry/plugins/libpomodoro.so /usr/lib/owlry/plugins/libscripts.so \
/usr/lib/owlry/plugins/libssh.so /usr/lib/owlry/plugins/libsystem.so \
/usr/lib/owlry/plugins/libsystemd.so /usr/lib/owlry/plugins/libweather.so \
/usr/lib/owlry/plugins/libwebsearch.so
echo "Installing core binary..." echo "Installing core binary..."
sudo install -Dm755 target/release/owlry /usr/bin/owlry sudo install -Dm755 target/release/owlry /usr/bin/owlry
@@ -152,6 +164,57 @@ bump-plugins new_version:
git commit -m "chore(plugins): bump all plugins to {{new_version}}" git commit -m "chore(plugins): bump all plugins to {{new_version}}"
echo "All plugins bumped to {{new_version}}" echo "All plugins bumped to {{new_version}}"
# Bump meta-packages (no crate, just AUR version)
bump-meta new_version:
#!/usr/bin/env bash
set -euo pipefail
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
file="aur/$pkg/PKGBUILD"
old=$(grep '^pkgver=' "$file" | sed 's/pkgver=//')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $pkg from $old to {{new_version}}"
sed -i 's/^pkgver=.*/pkgver={{new_version}}/' "$file"
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
fi
done
echo "Meta-packages bumped to {{new_version}}"
# Bump all crates (core + plugins + runtimes) to same version
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
# Bump core
toml="crates/owlry/Cargo.toml"
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping owlry from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
# Bump plugins (including plugin-api)
for toml in crates/owlry-plugin-*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
done
# Bump runtimes
for toml in crates/owlry-lua/Cargo.toml crates/owlry-rune/Cargo.toml; do
if [ -f "$toml" ]; then
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
fi
done
cargo check --workspace
git add crates/*/Cargo.toml Cargo.lock
git commit -m "chore: bump all crates to {{new_version}}"
echo "All crates bumped to {{new_version}}"
# Bump core version (usage: just bump 0.2.0) # Bump core version (usage: just bump 0.2.0)
bump new_version: bump new_version:
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -246,10 +309,12 @@ aur-update-pkg pkg:
exit 1 exit 1
fi fi
# Determine crate name (strip owlry- prefix for meta-packages) url="https://somegit.dev/Owlibou/owlry"
# Determine crate version (unified versioning: all crates share same version)
case "{{pkg}}" in case "{{pkg}}" in
owlry-essentials|owlry-tools|owlry-widgets|owlry-full) owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full)
# Meta-packages have no crate, use PKGBUILD version # Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes
crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//') crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
;; ;;
*) *)
@@ -264,13 +329,14 @@ aur-update-pkg pkg:
esac esac
cd "$aur_dir" cd "$aur_dir"
url="https://somegit.dev/Owlibou/owlry"
echo "Updating {{pkg}} PKGBUILD to version $crate_ver" echo "Updating {{pkg}} PKGBUILD:"
echo " pkgver=$crate_ver"
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums for packages that download source # Update checksums (unified versioning: all packages use same version)
if grep -q "^source=" PKGBUILD; then if grep -q "^source=" PKGBUILD; then
echo "Updating checksums..." echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1) b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
@@ -281,9 +347,9 @@ aur-update-pkg pkg:
echo "Generating .SRCINFO..." echo "Generating .SRCINFO..."
makepkg --printsrcinfo > .SRCINFO makepkg --printsrcinfo > .SRCINFO
git diff git diff --stat
echo "" echo ""
echo "{{pkg}} updated to $crate_ver. Run 'just aur-publish-pkg {{pkg}}' to publish." echo "{{pkg}} updated. Run 'just aur-publish-pkg {{pkg}}' to publish."
# Publish a specific AUR package # Publish a specific AUR package
aur-publish-pkg pkg: aur-publish-pkg pkg:
@@ -340,6 +406,16 @@ aur-publish-plugins:
echo "" echo ""
done done
# Publish all meta-packages
aur-publish-meta:
#!/usr/bin/env bash
set -euo pipefail
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
echo "=== Publishing $pkg ==="
just aur-publish-pkg "$pkg"
done
echo "All meta-packages published!"
# List all AUR packages with their versions # List all AUR packages with their versions
aur-status: aur-status:
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -357,8 +433,60 @@ aur-status:
fi fi
done done
# Full release workflow (bump + tag + aur) # Update ALL AUR packages (core + plugins + runtimes + meta)
release-full new_version: (bump new_version) aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Updating core ==="
just aur-update
echo ""
echo "=== Updating plugins ==="
for dir in aur/owlry-plugin-*/; do
pkg=$(basename "$dir")
echo "--- $pkg ---"
just aur-update-pkg "$pkg"
done
echo ""
echo "=== Updating runtimes ==="
just aur-update-pkg owlry-lua
just aur-update-pkg owlry-rune
echo ""
echo "=== Updating meta-packages ==="
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
echo "--- $pkg ---"
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
done
echo ""
echo "All AUR packages updated. Run 'just aur-publish-all' to publish."
# Publish ALL AUR packages
aur-publish-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Publishing core ==="
just aur-publish
echo ""
echo "=== Publishing plugins ==="
for dir in aur/owlry-plugin-*/; do
pkg=$(basename "$dir")
echo "--- $pkg ---"
just aur-publish-pkg "$pkg"
done
echo ""
echo "=== Publishing runtimes ==="
just aur-publish-pkg owlry-lua
just aur-publish-pkg owlry-rune
echo ""
echo "=== Publishing meta-packages ==="
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
echo "--- $pkg ---"
just aur-publish-pkg "$pkg"
done
echo ""
echo "All AUR packages published!"
# Full release workflow for core only (bump + tag + aur)
release-core new_version: (bump new_version)
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -376,5 +504,41 @@ release-full new_version: (bump new_version)
just aur-update just aur-update
echo "" echo ""
echo "Release v{{new_version}} prepared!" echo "Core release v{{new_version}} prepared!"
echo "Review AUR changes, then run 'just aur-publish'" echo "Review AUR changes, then run 'just aur-publish'"
# Full release workflow for everything (core + plugins + runtimes)
# Usage: just release-all 0.5.0 0.3.0
# First arg is core version, second is plugins/runtimes version
release-all core_version plugin_version:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Bumping versions ==="
just bump {{core_version}}
just bump-all {{plugin_version}}
echo ""
echo "=== Pushing to origin ==="
git push
echo ""
echo "=== Creating tag ==="
just tag
echo "Waiting for tag to propagate..."
sleep 2
echo ""
echo "=== Updating all AUR packages ==="
just aur-update-all
echo ""
echo "=========================================="
echo "Release prepared!"
echo " Core: v{{core_version}}"
echo " Plugins/Runtimes: v{{plugin_version}}"
echo ""
echo "Review changes with 'just aur-status'"
echo "Then publish with 'just aur-publish-all'"
echo "=========================================="