Compare commits

...

86 Commits

Author SHA1 Message Date
a920588df9 chore(aur): update owlry-lua 1.1.1, owlry-rune 1.1.1 2026-03-28 13:43:30 +01:00
c32b6c5456 chore(owlry-rune): bump version to 1.1.1 2026-03-28 13:43:06 +01:00
2a5f184230 chore(owlry-lua): bump version to 1.1.1 2026-03-28 13:43:04 +01:00
b2f068269a chore: remove unused builtin_type_ids method and test 2026-03-28 13:37:54 +01:00
e210a604f7 chore(aur): update owlry-core to 1.3.1 2026-03-28 13:30:28 +01:00
1adec7bf47 chore(owlry-core): bump version to 1.3.1 2026-03-28 13:30:23 +01:00
7f07a93dec fix(core): add :config and :conv to filter prefix tables
:config and :conv were not in the prefix lists, so typing them
showed 'Plugin' mode but didn't route to the config/converter
providers. Also added :settings, :converter aliases.
2026-03-28 13:30:10 +01:00
7351ba868e docs: revise README for current state
- Architecture diagram reflects owlryd binary name and built-in providers
- Add config editor, converter trigger (>) to prefix tables
- Add apex-neon to theme list (10 themes)
- Add --owlry-shadow CSS variable
- Fix build instructions (no deleted plugins)
- Add built-in provider toggles to example config
- Cross-reference :config throughout (Quick Start, Disabling Plugins, Theming)
2026-03-28 13:28:32 +01:00
44e1430ea5 chore(aur): update owlry-core to 1.3.0 2026-03-28 13:17:29 +01:00
80312a28f7 chore(owlry-core): bump version to 1.3.0 2026-03-28 13:17:11 +01:00
37abe98c9b docs: add config editor usage to README 2026-03-28 13:16:36 +01:00
d95b81bbcb feat(core): wire config editor into ProviderManager
Register ConfigProvider as built-in dynamic provider. Extend
execute_plugin_action to dispatch CONFIG:* commands via the
DynamicProvider::execute_action trait method.
2026-03-28 13:15:28 +01:00
562b38deba feat(core): add built-in config editor provider 2026-03-28 13:10:54 +01:00
2888677e38 docs: add config editor implementation plan 2026-03-28 13:05:57 +01:00
940ad58ee2 docs: add config editor design spec 2026-03-28 12:54:11 +01:00
18775d71fc chore(aur): update owlry 1.0.6, owlry-core 1.2.1 2026-03-28 12:40:33 +01:00
f189f4b1ce chore(owlry): bump version to 1.0.6 2026-03-28 12:40:20 +01:00
422ea6d816 chore(owlry-core): bump version to 1.2.1 2026-03-28 12:40:18 +01:00
8b444eec3b refactor: rename daemon binary from owlry-core to owlryd
- Binary: owlry-core → owlryd
- Systemd: owlry-core.service → owlryd.service, owlry-core.socket → owlryd.socket
- Client: systemctl start owlryd
- AUR package name stays owlry-core (installs owlryd binary)
2026-03-28 12:39:37 +01:00
6d0bf1c401 chore(aur): update owlry-core to 1.2.0 2026-03-28 12:26:40 +01:00
c8d8298274 chore(owlry-core): bump version to 1.2.0 2026-03-28 12:26:15 +01:00
62f6e1d4b0 docs: update README for built-in providers migration
Calculator, converter, and system are now built into owlry-core.
Remove meta package references. Update install instructions and
package tables.
2026-03-28 12:25:33 +01:00
bf1d759cb2 chore: remove retired meta package AUR dirs
owlry-meta-essentials, owlry-meta-full, owlry-meta-tools, and
owlry-meta-widgets have been deleted from AUR. Remove local dirs.
2026-03-28 12:24:27 +01:00
3f9f4bb112 feat(core): skip native plugins that conflict with built-in providers
When users upgrade owlry-core but still have old .so plugins installed,
the conflict detection skips the native plugin to prevent duplicate
results.
2026-03-28 12:22:13 +01:00
c5f1f35167 feat(core): register built-in providers in ProviderManager
Calculator and converter registered as built-in dynamic providers.
System registered as built-in static provider. All gated by config
toggles (calculator, converter, system — default true).
2026-03-28 12:19:12 +01:00
81626c33dd feat(core): add built-in converter provider 2026-03-28 12:14:31 +01:00
99d38a66b8 feat(core): add built-in system provider 2026-03-28 12:09:19 +01:00
8b4c704501 feat(core): add built-in calculator provider 2026-03-28 12:07:43 +01:00
27e296e333 feat(core): add DynamicProvider trait and builtin_dynamic support
Foundation for built-in calculator, converter, and system providers.
DynamicProvider trait for per-keystroke providers. ProviderManager
iterates builtin_dynamic alongside native dynamic plugins in search.
2026-03-28 12:03:45 +01:00
173d72ad43 docs: add built-in providers migration implementation plan 2026-03-28 11:59:00 +01:00
3eea902c7f docs: add built-in providers migration design spec 2026-03-28 11:52:58 +01:00
a12e850c94 fix(ui): remove periodic re-query that reset selection position
The 5-second timer emitted 'changed' on the search entry in daemon
mode, triggering a full re-query that rebuilt the result list and
selected row 0 — jumping the user back to the top while browsing.

Widget refresh is a daemon-side concern; the UI gets updated data
on the next user-initiated search. Only keep the timer for local
(dmenu) mode where the UI owns the providers directly.
2026-03-28 11:41:37 +01:00
eccfb217d4 chore(aur): update owlry 1.0.5, owlry-core 1.1.3 2026-03-28 11:35:57 +01:00
c3c35611fd chore(owlry-core): bump version to 1.1.3 2026-03-28 11:35:23 +01:00
5ecd0a6412 chore(owlry): bump version to 1.0.5 2026-03-28 11:35:22 +01:00
6fe7213b6f fix(core): group auto-detect plugin results together in ranking
Calculator and converter results now get a 10k grouping bonus so all
their results stay together above websearch/filesearch. Previously
websearch (priority 9000) would interleave with converter results
(9000, 8999, 8998...) since they had the same base priority.
2026-03-28 11:34:26 +01:00
b768bfd181 chore(ui): remove dead update_results method 2026-03-28 11:30:40 +01:00
c9a1ff28f4 fix(ui): only highlight calc and converter, not websearch/filesearch
Websearch is a generic fallback — it always shows a result, so
highlighting it adds no signal. Filesearch returns fuzzy matches,
not auto-detected conversions. Only calc and conv produce direct
answers that deserve highlighting.
2026-03-28 11:28:37 +01:00
623572ec14 fix: use git add -A in aur-publish-pkg 2026-03-28 11:20:57 +01:00
5196255594 chore(aur): update owlry 1.0.4, owlry-core 1.1.2 2026-03-28 11:19:05 +01:00
b87447156e chore(owlry-core): bump version to 1.1.2 2026-03-28 11:18:27 +01:00
12d554959a chore(owlry): bump version to 1.0.4 2026-03-28 11:18:26 +01:00
83fa22d84c feat(ui): add result highlighting and remove window shadow
Highlighting:
- Dynamic plugin results (calculator, converter, websearch, filesearch)
  get a subtle accent left-border + background tint when auto-detected
- Exact name matches (case-insensitive) are highlighted the same way
- Exact match on apps gets a higher score boost (50k) than other
  providers (30k), so apps rank first when names match exactly

Shadow:
- Removed hardcoded box-shadow from all theme CSS files
- Added --owlry-shadow variable in base.css (defaults to none)
- Themes can opt into shadow via --owlry-shadow if desired

CSS class: .owlry-result-highlight on ResultRow
2026-03-28 11:17:45 +01:00
ade5d3aeef fix(ui): check icon theme exists on disk before fallback
has_icon() returns true even for broken themes since it checks all
search paths. Instead, verify the theme directory actually exists
in the search path. Falls back to Adwaita only when the configured
theme is genuinely missing from disk.
2026-03-28 11:08:01 +01:00
617c943147 fix: aur-stage glob handling for packages without .install files 2026-03-28 10:51:46 +01:00
1b1e12124b chore(aur): update owlry PKGBUILD to 1.0.3 2026-03-28 10:49:57 +01:00
94556f1fe0 chore(owlry): bump version to 1.0.3 2026-03-28 10:48:55 +01:00
2b98f0651c fix(ui): fall back to Adwaita when system icon theme is broken
If the configured icon theme (e.g. Sweet-Blue) doesn't exist on disk,
GTK falls back to hicolor which has almost no icons. Detect this by
probing for a standard icon, and set Adwaita as the theme — it's
guaranteed to exist as a GTK4 dependency.

This replaces the broken add_search_path("/usr/share/icons/Adwaita")
approach which doesn't work because search paths are scoped to the
active theme name, not the directory name.
2026-03-28 10:48:39 +01:00
75fa770c94 chore: overhaul justfile for current deployment pipeline
Key fixes:
- aur-update-pkg uses correct per-crate tag URLs ({crate}-v{version})
- tag-crate creates per-crate tags instead of generic v{version}
- aur-stage handles embedded .git dirs in AUR subdirectories
- aur-commit stages all AUR files with .git workaround
- release-crate does full pipeline: bump → push → tag → AUR update → publish
- Removed stale release-core recipe that used wrong tag format
2026-03-28 10:31:32 +01:00
c6ba91f06d fix(aur): restrict owlry-core check() to unit tests
The integration test (server_test) loads native plugins which segfault
in the clean makepkg build environment. Use --lib to run only unit tests.
2026-03-28 10:06:07 +01:00
235103e854 fix(aur): correct b2sums for owlry and owlry-core tarballs 2026-03-28 09:54:12 +01:00
8ccaaf28c8 docs: update README for client/daemon package split
- Separate package tables for core, plugins, and meta bundles
- Add owlry-plugin-converter to plugin list and meta-essentials
- Fix build instructions: plugins are in owlry-plugins repo
- Update plugin count to 14
- Remove dead link to gitignored CLAUDE.md
2026-03-28 09:51:29 +01:00
cfd143fe4a chore: track AUR package files (PKGBUILD, .SRCINFO)
The aur/ directory was entirely gitignored, preventing PKGBUILD and
.SRCINFO files from being tracked. Fix .gitignore to only ignore
build artifacts and nested .git dirs, matching the owlry-plugins
repo convention.
2026-03-28 09:34:21 +01:00
10a685c62f chore(owlry): bump version to 1.0.2 2026-03-28 09:16:40 +01:00
34db33c75f chore(owlry-core): bump version to 1.1.1 2026-03-28 09:16:38 +01:00
4bff83b5e6 perf(ui): eliminate redundant results.clone() in search handlers
The full results Vec was cloned into lazy_state.all_results and then
separately consumed for current_results. Now we slice for current_results
and move the original into lazy_state, avoiding one full Vec allocation
per query.
2026-03-28 09:14:11 +01:00
8f7501038d perf(ui): move search IPC off the GTK main thread
Search queries in daemon mode now run on a background thread via
DaemonHandle::query_async(). Results are posted back to the main
thread via glib::spawn_future_local + futures_channel::oneshot.
The GTK event loop is never blocked by IPC, eliminating perceived
input lag.

Local mode (dmenu) continues to use synchronous search since it
has no IPC overhead.
2026-03-28 09:12:20 +01:00
4032205800 perf(ui): defer initial query to after window.present()
update_results('') was called inside MainWindow::new(), blocking the
window from appearing until the daemon responded. Move it to a
glib::idle_add_local_once callback scheduled after present() so the
window renders immediately.
2026-03-28 08:51:33 +01:00
99985c7f3b perf(ui): use tracked count in scroll_to_row instead of child walk
scroll_to_row walked all GTK children via first_child/next_sibling
to count rows. The count is already available in LazyLoadState, so
use that directly. Eliminates O(n) widget traversal per arrow key.
2026-03-28 08:48:52 +01:00
6113217f7b perf(core): sample Utc::now() once per search instead of per-item
get_score() called Utc::now() inside calculate_frecency() for every
item in the search loop. Added get_score_at() that accepts a pre-sampled
timestamp. Eliminates hundreds of unnecessary clock_gettime syscalls
per keystroke.
2026-03-28 08:45:21 +01:00
558d415e12 perf(config): replace which subprocesses with in-process PATH scan
detect_terminal() was spawning up to 17 'which' subprocesses sequentially
on every startup. Replace with std::env::split_paths + is_file() check.
Eliminates 200-500ms of fork+exec overhead on cold cache.
2026-03-28 08:40:22 +01:00
6bde1504b1 chore: add .worktrees/ to gitignore 2026-03-28 08:35:51 +01:00
ffe04f3c54 docs: add per-crate tagging convention to CLAUDE.md 2026-03-26 18:57:09 +01:00
5c0e63f94c chore(owlry-rune): bump version to 1.1.0 2026-03-26 18:51:20 +01:00
5441011d6b chore(owlry-lua): bump version to 1.1.0 2026-03-26 18:51:20 +01:00
317572634f chore(owlry): bump version to 1.0.1 2026-03-26 18:51:12 +01:00
449dc010db chore(owlry-core): bump version to 1.1.0 2026-03-26 18:51:04 +01:00
7273cd3ba7 chore(owlry-plugin-api): bump version to 1.0.1 2026-03-26 18:50:58 +01:00
f8388a4327 docs: update CLAUDE.md with script runtime loading, hot-reload, dynamic prefixes 2026-03-26 18:47:05 +01:00
fa671ebd77 feat: dynamic prefix fallback for user plugin prefixes (e.g. :hs for hyprshutdown) 2026-03-26 18:40:23 +01:00
d63c7d170b fix: build Rune provider registrations from manifest [[providers]] declarations 2026-03-26 18:37:33 +01:00
5f14ed2b3b fix: register Rune Item type under owlry crate path 2026-03-26 18:35:41 +01:00
83f551dd7f fix: use rune::function attribute for Item constructor and builder methods 2026-03-26 18:30:39 +01:00
9b1eada1ee fix: set accept_all when no CLI mode restriction so user plugins appear in default filter 2026-03-26 18:27:50 +01:00
677e6d7fa9 fix: send accept_all filter as None in IPC so runtime plugins appear in results 2026-03-26 18:21:32 +01:00
f0741f4128 fix: store Lua provider callbacks for refresh, fix Rune Item::new parameter types 2026-03-26 18:12:07 +01:00
7da8f3c249 fix: align Lua ProviderInfo ABI, implement Rune Item type and refresh/query 2026-03-26 18:07:46 +01:00
38dda8c44c fix: watcher startup grace period, defensive runtime drop on reload 2026-03-26 17:59:32 +01:00
ab2d3cfe55 feat: add filesystem watcher for automatic user plugin hot-reload
Watch ~/.config/owlry/plugins/ for changes using notify-debouncer-mini
(500ms debounce) and trigger a full runtime reload on file modifications.
Respects OWLRY_SKIP_RUNTIMES=1 to skip watcher in tests.
2026-03-26 17:48:15 +01:00
e2939e266c feat: wire script runtime loading into daemon ProviderManager 2026-03-26 17:44:33 +01:00
651166a9f3 feat: change default entry points to main.lua/main.rn, add entry_point alias 2026-03-26 17:33:54 +01:00
a2eb7d1b0d fix: align runtime ABI — shrink Lua RuntimeInfo, pass owlry_version to init
- Shrink Lua RuntimeInfo from 5 fields to 2 (name, version), matching
  core and Rune. The mismatch caused SIGSEGV across the ABI boundary.
- Add owlry_version parameter to vtable init in all three crates
  (core, Lua, Rune) so runtimes receive the version at init time
  instead of hardcoding it.
- Remove unused Lua constants (RUNTIME_ID, RUNTIME_NAME, etc.) and
  LUA_RUNTIME_API_VERSION.
- Update plugin_commands.rs call sites to pass CARGO_PKG_VERSION.
2026-03-26 17:31:23 +01:00
8073d27df2 docs: update LuaProvider safety comment for RwLock architecture 2026-03-26 16:51:55 +01:00
3349350bf6 fix: robustness — RwLock for concurrent reads, log malformed JSON requests
Replace Mutex with RwLock for ProviderManager and FrecencyStore in the
IPC server. Most request types (Query, Providers, Submenu, PluginAction)
only need read access and can now proceed concurrently. Only Launch
(frecency write) and Refresh (provider write) acquire exclusive locks.

Also adds a warn!() log for malformed JSON requests before sending the
error response, improving observability for debugging client issues.

Provider trait now requires Send + Sync to satisfy RwLock's Sync bound
on the inner type. RuntimeProvider and LuaProvider gain the
corresponding unsafe impl Sync.
2026-03-26 16:39:10 +01:00
3aaeafde8b fix: security — socket perms 0600, signal handler logging, client read timeout 2026-03-26 16:32:06 +01:00
7ce6de17aa fix: soundness — OnceLock for HOST_API, IPC size limits, mutex poisoning recovery 2026-03-26 16:29:47 +01:00
68 changed files with 7595 additions and 743 deletions

5
.gitignore vendored
View File

@@ -1,8 +1,10 @@
/target /target
CLAUDE.md CLAUDE.md
.worktrees/
media.md media.md
# AUR packages (each is its own git repo for aur.archlinux.org) # AUR packages (each is its own git repo for aur.archlinux.org)
# Track PKGBUILD and .SRCINFO, ignore build artifacts and sub-repo .git
aur/*/.git/ aur/*/.git/
aur/*/pkg/ aur/*/pkg/
aur/*/src/ aur/*/src/
@@ -10,6 +12,3 @@ aur/*/*.tar.zst
aur/*/*.tar.gz aur/*/*.tar.gz
aur/*/*.tar.xz aur/*/*.tar.xz
aur/*/*.pkg.tar.* aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

View File

@@ -166,6 +166,23 @@ just bump 0.5.1
# Create and push release tag # Create and push release tag
git push && just tag git push && just tag
# Tagging convention: every crate gets its own tag
# Format: {crate-name}-v{version}
# Examples:
# owlry-v1.0.1
# owlry-core-v1.1.0
# owlry-lua-v1.1.0
# owlry-rune-v1.1.0
# plugin-api-v1.0.1
#
# The plugins repo uses the same convention:
# owlry-plugin-bookmarks-v1.0.1
# owlry-plugin-calculator-v1.0.1
# etc.
#
# IMPORTANT: After bumping versions, tag EVERY changed crate individually.
# The plugin-api tag is referenced by owlry-plugins Cargo.toml as a git dependency.
# AUR package management # AUR package management
just aur-update # Update core UI PKGBUILD just aur-update # Update core UI PKGBUILD
just aur-update-pkg NAME # Update specific package (owlry-core, owlry-lua, etc.) just aur-update-pkg NAME # Update specific package (owlry-core, owlry-lua, etc.)
@@ -299,10 +316,18 @@ CoreClient ──── IPC ────→ ProviderManager ProviderFi
All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`). All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`).
**User plugins** (script-based, in `~/.config/owlry/plugins/`):
- **Lua plugins**: Loaded by `owlry-lua` runtime from `/usr/lib/owlry/runtimes/liblua.so`
- **Rune plugins**: Loaded by `owlry-rune` runtime from `/usr/lib/owlry/runtimes/librune.so`
- User plugins are **hot-reloaded** automatically when files change (no daemon restart needed)
- Custom prefixes (e.g., `:hs`) are resolved dynamically for user plugins
`ProviderManager` (in `owlry-core`) orchestrates providers and handles: `ProviderManager` (in `owlry-core`) orchestrates providers and handles:
- Fuzzy matching via `SkimMatcherV2` - Fuzzy matching via `SkimMatcherV2`
- Frecency score boosting - Frecency score boosting
- Native plugin loading from `/usr/lib/owlry/plugins/` - Native plugin loading from `/usr/lib/owlry/plugins/`
- Script runtime loading from `/usr/lib/owlry/runtimes/` for user plugins
- Filesystem watching for automatic user plugin hot-reload
**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions). **Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions).
@@ -340,6 +365,7 @@ Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup.
- Profile-based mode selection (`--profile dev`) - Profile-based mode selection (`--profile dev`)
- Provider toggling (Ctrl+1/2/3) - Provider toggling (Ctrl+1/2/3)
- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.) - Prefix parsing (`:app`, `:cmd`, `:sys`, etc.)
- Dynamic prefix fallback for user plugins (any `:word` prefix maps to `Plugin(word)`)
Query parsing extracts prefix and forwards clean query to providers. Query parsing extracts prefix and forwards clean query to providers.
@@ -395,6 +421,8 @@ Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins`
- **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state - **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state
- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]` - **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]`
- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary - **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary
- **Script runtimes**: External `.so` runtimes loaded from `/usr/lib/owlry/runtimes/` — Lua and Rune user plugins loaded from `~/.config/owlry/plugins/`
- **Hot-reload**: Filesystem watcher (`notify` crate) monitors user plugins dir and reloads runtimes on file changes
- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin - **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin
- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json` - **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json`
- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking - **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking

179
Cargo.lock generated
View File

@@ -337,6 +337,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
@@ -400,7 +406,7 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"cairo-sys-rs", "cairo-sys-rs",
"glib 0.21.5", "glib 0.21.5",
"libc", "libc",
@@ -699,7 +705,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -851,6 +857,17 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -909,6 +926,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@@ -1231,7 +1257,7 @@ version = "0.20.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor", "futures-executor",
@@ -1252,7 +1278,7 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor", "futures-executor",
@@ -1425,7 +1451,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d422cce9367945916b7a5083eedf67b0a5380d326af1943a0b5cef9afb6e48" checksum = "c1d422cce9367945916b7a5083eedf67b0a5380d326af1943a0b5cef9afb6e48"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"gdk4", "gdk4",
"glib 0.21.5", "glib 0.21.5",
"glib-sys 0.21.5", "glib-sys 0.21.5",
@@ -1777,6 +1803,35 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "inotify"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -1899,6 +1954,26 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1943,7 +2018,10 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [ dependencies = [
"bitflags 2.11.0",
"libc", "libc",
"plain",
"redox_syscall 0.7.3",
] ]
[[package]] [[package]]
@@ -2102,6 +2180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2186,7 +2265,7 @@ version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@@ -2198,6 +2277,37 @@ version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
[[package]]
name = "notify"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [
"bitflags 2.11.0",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
]
[[package]]
name = "notify-debouncer-mini"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799"
dependencies = [
"log",
"notify",
"notify-types",
"tempfile",
]
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.12.0" version = "4.12.0"
@@ -2212,6 +2322,15 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "notify-types"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
dependencies = [
"instant",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -2335,7 +2454,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"dispatch2", "dispatch2",
"objc2", "objc2",
] ]
@@ -2352,7 +2471,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -2417,12 +2536,13 @@ dependencies = [
[[package]] [[package]]
name = "owlry" name = "owlry"
version = "1.0.0" version = "1.0.6"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
"dirs", "dirs",
"env_logger", "env_logger",
"futures-channel",
"glib-build-tools", "glib-build-tools",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
@@ -2437,7 +2557,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-core" name = "owlry-core"
version = "1.0.0" version = "1.3.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"ctrlc", "ctrlc",
@@ -2449,6 +2569,8 @@ dependencies = [
"log", "log",
"meval", "meval",
"mlua", "mlua",
"notify",
"notify-debouncer-mini",
"notify-rust", "notify-rust",
"owlry-plugin-api", "owlry-plugin-api",
"reqwest 0.13.2", "reqwest 0.13.2",
@@ -2462,7 +2584,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-lua" name = "owlry-lua"
version = "1.0.0" version = "1.1.1"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"chrono", "chrono",
@@ -2480,7 +2602,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-plugin-api" name = "owlry-plugin-api"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"serde", "serde",
@@ -2488,7 +2610,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-rune" name = "owlry-rune"
version = "1.0.0" version = "1.1.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs", "dirs",
@@ -2553,7 +2675,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall 0.5.18",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -2619,6 +2741,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -2821,7 +2949,16 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
] ]
[[package]] [[package]]
@@ -3096,7 +3233,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@@ -3227,7 +3364,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -3475,7 +3612,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"system-configuration-sys", "system-configuration-sys",
] ]
@@ -3803,7 +3940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"async-compression", "async-compression",
"bitflags", "bitflags 2.11.0",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
@@ -4161,7 +4298,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap", "indexmap",
"semver", "semver",
@@ -4786,7 +4923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags", "bitflags 2.11.0",
"indexmap", "indexmap",
"log", "log",
"serde", "serde",

147
README.md
View File

@@ -11,16 +11,18 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
## Features ## Features
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory - **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
- **Modular plugin architecture** — Install only what you need - **Built-in providers** — Calculator, unit/currency converter, and system actions out of the box
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags - **Built-in settings editor** — Configure everything from within the launcher (`:config`)
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more - **11 optional plugins** — Clipboard, emoji, weather, media, bookmarks, and more
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results - **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
- **Config profiles** — Named mode presets for different workflows - **Config profiles** — Named mode presets for different workflows
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc. - **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:config`, `:tag:X`, etc.
- **Frecency ranking** — Frequently/recently used items rank higher - **Frecency ranking** — Frequently/recently used items rank higher
- **Toggle behavior** — Bind one key to open/close the launcher - **Toggle behavior** — Bind one key to open/close the launcher
- **GTK4 theming** — System theme by default, with 9 built-in themes - **GTK4 theming** — System theme by default, with 10 built-in themes
- **Wayland native** — Uses Layer Shell for proper overlay behavior - **Wayland native** — Uses Layer Shell for proper overlay behavior
- **dmenu compatible** — Pipe-based selection mode, no daemon required
- **Extensible** — Create custom plugins in Lua or Rune - **Extensible** — Create custom plugins in Lua or Rune
## Installation ## Installation
@@ -28,41 +30,45 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
### Arch Linux (AUR) ### Arch Linux (AUR)
```bash ```bash
# Minimal core (applications + commands only) # Core (includes calculator, converter, system actions, settings editor)
yay -S owlry yay -S owlry
# Add individual plugins # Add individual plugins as needed
yay -S owlry-plugin-calculator owlry-plugin-weather yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
# Or install bundles: # For custom Lua/Rune user plugins
yay -S owlry-meta-essentials # calculator, system, ssh, scripts, bookmarks
yay -S owlry-meta-widgets # weather, media, pomodoro
yay -S owlry-meta-tools # clipboard, emoji, websearch, filesearch, systemd
yay -S owlry-meta-full # everything
# For custom Lua/Rune plugins
yay -S owlry-lua # Lua 5.4 runtime yay -S owlry-lua # Lua 5.4 runtime
yay -S owlry-rune # Rune runtime yay -S owlry-rune # Rune runtime
``` ```
### Available Packages ### Available Packages
**Core packages** (this repo):
| Package | Description | | Package | Description |
|---------|-------------| |---------|-------------|
| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) | | `owlry` | GTK4 UI client |
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) | | `owlry-core` | Daemon (`owlryd`) with built-in calculator, converter, system, and settings providers |
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock | | `owlry-lua` | Lua 5.4 script runtime for user plugins |
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` | | `owlry-rune` | Rune script runtime for user plugins |
**Plugin packages** ([owlry-plugins](https://somegit.dev/Owlibou/owlry-plugins) repo):
| Package | Description |
|---------|-------------|
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
| `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-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
| `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-weather` | Weather widget |
| `owlry-plugin-media` | MPRIS media controls | | `owlry-plugin-media` | MPRIS media controls |
| `owlry-plugin-pomodoro` | Pomodoro timer widget | | `owlry-plugin-pomodoro` | Pomodoro timer widget |
| `owlry-plugin-scripts` | User scripts |
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
| `owlry-plugin-systemd` | User services with actions |
| `owlry-plugin-weather` | Weather widget |
| `owlry-plugin-websearch` | Web search (`? query`) |
> **Note:** Calculator, converter, and system actions are built into `owlry-core` and do not require separate packages.
### Build from Source ### Build from Source
@@ -83,26 +89,33 @@ sudo dnf install gtk4-devel gtk4-layer-shell-devel
git clone https://somegit.dev/Owlibou/owlry.git git clone https://somegit.dev/Owlibou/owlry.git
cd owlry cd owlry
# Build core only (daemon + UI) # Build daemon + UI
cargo build --release -p owlry -p owlry-core cargo build --release -p owlry -p owlry-core
# Build specific plugin # Build runtimes (for user plugins)
cargo build --release -p owlry-plugin-calculator cargo build --release -p owlry-lua -p owlry-rune
# Build everything # Build everything in this workspace
cargo build --release --workspace cargo build --release --workspace
``` ```
**Plugins** are in a [separate repo](https://somegit.dev/Owlibou/owlry-plugins):
```bash
git clone https://somegit.dev/Owlibou/owlry-plugins.git
cd owlry-plugins
cargo build --release -p owlry-plugin-bookmarks # or any plugin
```
**Install locally:** **Install locally:**
```bash ```bash
just install-local just install-local
``` ```
This installs both binaries, all plugins, runtimes, and the systemd service files. This installs the UI (`owlry`), daemon (`owlryd`), runtimes, and systemd service files.
## Getting Started ## Getting Started
Owlry uses a client/daemon architecture. The daemon (`owlry-core`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results. Owlry uses a client/daemon architecture. The daemon (`owlryd`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results.
### Starting the Daemon ### Starting the Daemon
@@ -114,25 +127,25 @@ Add to your compositor config:
```bash ```bash
# Hyprland (~/.config/hypr/hyprland.conf) # Hyprland (~/.config/hypr/hyprland.conf)
exec-once = owlry-core exec-once = owlryd
# Sway (~/.config/sway/config) # Sway (~/.config/sway/config)
exec owlry-core exec owlryd
``` ```
**2. Systemd user service** **2. Systemd user service**
```bash ```bash
systemctl --user enable --now owlry-core.service systemctl --user enable --now owlryd.service
``` ```
**3. Socket activation (auto-start on first use)** **3. Socket activation (auto-start on first use)**
```bash ```bash
systemctl --user enable owlry-core.socket systemctl --user enable owlryd.socket
``` ```
The daemon starts automatically when the UI client first connects. No manual startup needed. The daemon starts automatically when the UI client first connects.
### Launching the UI ### Launching the UI
@@ -146,7 +159,7 @@ bind = SUPER, Space, exec, owlry
bindsym $mod+space exec owlry bindsym $mod+space exec owlry
``` ```
Running `owlry` a second time while it is already open sends a toggle command — the window closes. This means a single keybind acts as open/close. Running `owlry` a second time while it is already open sends a toggle command — the window closes. A single keybind acts as open/close.
If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically. If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically.
@@ -156,7 +169,7 @@ If the daemon is not running when the UI launches, it will attempt to start it v
owlry # Launch with all providers owlry # Launch with all providers
owlry -m app # Applications only owlry -m app # Applications only
owlry -m cmd # PATH commands only owlry -m cmd # PATH commands only
owlry -m calc # Calculator plugin only (if installed) owlry -m calc # Calculator only
owlry --profile dev # Use a named profile from config owlry --profile dev # Use a named profile from config
owlry --help # Show all options with examples owlry --help # Show all options with examples
``` ```
@@ -191,14 +204,16 @@ bind = SUPER, D, exec, owlry --profile dev
bind = SUPER, M, exec, owlry --profile media bind = SUPER, M, exec, owlry --profile media
``` ```
Profiles can also be managed from the launcher itself — see [Settings Editor](#settings-editor).
### dmenu Mode ### 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. 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.
dmenu mode is self-contained: it does not use the daemon and works without `owlry-core` running. dmenu mode is self-contained: it does not use the daemon and works without `owlryd` running.
```bash ```bash
# Screenshot menu (execute selected command) # Screenshot menu
printf '%s\n' \ printf '%s\n' \
"grimblast --notify copy screen" \ "grimblast --notify copy screen" \
"grimblast --notify copy area" \ "grimblast --notify copy area" \
@@ -217,9 +232,6 @@ find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
# Package manager search # Package manager search
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S 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. The `-p` / `--prompt` flag sets a custom label for the search input.
@@ -235,6 +247,24 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
| `Shift+Tab` | Cycle filter tabs (reverse) | | `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position | | `Ctrl+1..9` | Toggle tab by position |
### Settings Editor
Type `:config` to browse and modify settings without editing files:
| Command | What it does |
|---------|-------------|
| `:config` | Show all setting categories |
| `:config providers` | Toggle providers on/off |
| `:config theme` | Select color theme |
| `:config engine` | Select web search engine |
| `:config frecency` | Toggle frecency, set weight |
| `:config fontsize 16` | Set font size (restart to apply) |
| `:config profiles` | List profiles |
| `:config profile create dev` | Create a new profile |
| `:config profile dev modes` | Edit which modes a profile includes |
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
### Search Prefixes ### Search Prefixes
| Prefix | Provider | Example | | Prefix | Provider | Example |
@@ -251,6 +281,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
| `:calc` | Calculator | `:calc sqrt(16)` | | `:calc` | Calculator | `:calc sqrt(16)` |
| `:web` | Web search | `:web rust docs` | | `:web` | Web search | `:web rust docs` |
| `:uuctl` | systemd | `:uuctl docker` | | `:uuctl` | systemd | `:uuctl docker` |
| `:config` | Settings | `:config theme` |
| `:tag:X` | Filter by tag | `:tag:development` | | `:tag:X` | Filter by tag | `:tag:development` |
### Trigger Prefixes ### Trigger Prefixes
@@ -259,6 +290,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|---------|----------|---------| |---------|----------|---------|
| `=` | Calculator | `= 5+3` | | `=` | Calculator | `= 5+3` |
| `calc ` | Calculator | `calc sqrt(16)` | | `calc ` | Calculator | `calc sqrt(16)` |
| `>` | Converter | `> 20 km to mi` |
| `?` | Web search | `? rust programming` | | `?` | Web search | `? rust programming` |
| `web ` | Web search | `web linux tips` | | `web ` | Web search | `web linux tips` |
| `/` | File search | `/ .bashrc` | | `/` | File search | `/ .bashrc` |
@@ -278,6 +310,7 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| `~/.local/share/owlry/frecency.json` | Usage history | | `~/.local/share/owlry/frecency.json` | Usage history |
System locations: System locations:
| Path | Purpose | | Path | Purpose |
|------|---------| |------|---------|
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins | | `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
@@ -292,6 +325,8 @@ mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
``` ```
Or configure from within the launcher: type `:config` to interactively change settings.
### Example Configuration ### Example Configuration
```toml ```toml
@@ -315,6 +350,9 @@ disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
[providers] [providers]
applications = true # .desktop files applications = true # .desktop files
commands = true # PATH executables commands = true # PATH executables
calculator = true # Built-in math expressions
converter = true # Built-in unit/currency conversion
system = true # Built-in shutdown/reboot/lock actions
frecency = true # Boost frequently used items frecency = true # Boost frequently used items
frecency_weight = 0.3 # 0.0-1.0 frecency_weight = 0.3 # 0.0-1.0
@@ -333,7 +371,7 @@ See `/usr/share/doc/owlry/config.example.toml` for all options with documentatio
## Plugin System ## Plugin System
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from: Owlry uses a modular plugin architecture. Plugins are loaded by the daemon from:
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages) - `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`) - `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
@@ -347,6 +385,8 @@ Add plugin IDs to the disabled list in your config:
disabled = ["emoji", "pomodoro"] disabled = ["emoji", "pomodoro"]
``` ```
Or toggle providers interactively: type `:config providers` in the launcher.
### Plugin Management CLI ### Plugin Management CLI
```bash ```bash
@@ -402,12 +442,15 @@ See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
| `tokyo-night` | Tokyo city lights | | `tokyo-night` | Tokyo city lights |
| `solarized-dark` | Precision colors | | `solarized-dark` | Precision colors |
| `one-dark` | Atom's One Dark | | `one-dark` | Atom's One Dark |
| `apex-neon` | Neon cyberpunk |
```toml ```toml
[appearance] [appearance]
theme = "catppuccin-mocha" theme = "catppuccin-mocha"
``` ```
Or select interactively: type `:config theme` in the launcher.
### Custom Theme ### Custom Theme
Create `~/.config/owlry/themes/mytheme.css`: Create `~/.config/owlry/themes/mytheme.css`:
@@ -435,18 +478,24 @@ Create `~/.config/owlry/themes/mytheme.css`:
| `--owlry-text-secondary` | Muted text | | `--owlry-text-secondary` | Muted text |
| `--owlry-accent` | Accent color | | `--owlry-accent` | Accent color |
| `--owlry-accent-bright` | Bright accent | | `--owlry-accent-bright` | Bright accent |
| `--owlry-shadow` | Window shadow (default: none) |
## Architecture ## Architecture
Owlry uses a client/daemon split: Owlry uses a client/daemon split:
``` ```
owlry-core (daemon) owlry (GTK4 UI client) owlryd (daemon) owlry (GTK4 UI client)
├── Loads config + plugins ├── Connects to daemon via Unix socket ├── Loads config + plugins ├── Connects to daemon via Unix socket
├── Applications provider ├── Renders results in GTK4 window ├── Built-in providers ├── Renders results in GTK4 window
├── Commands provider ├── Handles keyboard input │ ├── Applications (.desktop) ├── Handles keyboard input
├── Plugin loader ├── Toggle: second launch closes window │ ├── Commands (PATH) ├── Toggle: second launch closes window
│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon) │ ├── Calculator (math) └── dmenu mode (self-contained, no daemon)
│ ├── Converter (units/currency)
│ ├── System (power/session)
│ └── Config editor (settings)
├── Plugin loader
│ ├── /usr/lib/owlry/plugins/*.so
│ ├── /usr/lib/owlry/runtimes/ │ ├── /usr/lib/owlry/runtimes/
│ └── ~/.config/owlry/plugins/ │ └── ~/.config/owlry/plugins/
├── Frecency tracking ├── Frecency tracking
@@ -457,8 +506,6 @@ owlry-core (daemon) owlry (GTK4 UI client)
The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket. The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket.
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
## License ## License
GNU General Public License v3.0 — see [LICENSE](LICENSE). GNU General Public License v3.0 — see [LICENSE](LICENSE).

13
aur/owlry-core/.SRCINFO Normal file
View File

@@ -0,0 +1,13 @@
pkgbase = owlry-core
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
pkgver = 1.3.1
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = gcc-libs
source = owlry-core-1.3.1.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.3.1.tar.gz
b2sums = e37383fd650a3bf9a2c554eb37676037e3ae72bbc2e1aad7c316809094254173b6fcd5ac87907c2f38ce5506e9f26201ec62f82446bc789153c280373e31fc9e
pkgname = owlry-core

41
aur/owlry-core/PKGBUILD Normal file
View File

@@ -0,0 +1,41 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-core
pkgver=1.3.1
pkgrel=1
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
arch=('x86_64')
url='https://somegit.dev/Owlibou/owlry'
license=('GPL-3.0-or-later')
depends=('gcc-libs')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
b2sums=('e37383fd650a3bf9a2c554eb37676037e3ae72bbc2e1aad7c316809094254173b6fcd5ac87907c2f38ce5506e9f26201ec62f82446bc789153c280373e31fc9e')
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p owlry-core --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p owlry-core --frozen --lib
}
package() {
cd "owlry"
install -Dm755 "target/release/owlryd" "$pkgdir/usr/bin/owlryd"
install -Dm644 "systemd/owlryd.service" "$pkgdir/usr/lib/systemd/user/owlryd.service"
install -Dm644 "systemd/owlryd.socket" "$pkgdir/usr/lib/systemd/user/owlryd.socket"
install -dm755 "$pkgdir/usr/lib/owlry/plugins"
install -dm755 "$pkgdir/usr/lib/owlry/runtimes"
}

13
aur/owlry-lua/.SRCINFO Normal file
View File

@@ -0,0 +1,13 @@
pkgbase = owlry-lua
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
pkgver = 1.1.1
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
source = owlry-lua-1.1.1.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.1.tar.gz
b2sums = a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8
pkgname = owlry-lua

40
aur/owlry-lua/PKGBUILD Normal file
View File

@@ -0,0 +1,40 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-lua
pkgver=1.1.1
pkgrel=1
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('owlry-core')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
b2sums=('a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8')
_cratename=owlry-lua
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p $_cratename --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --release
}
package() {
cd "owlry"
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
"$pkgdir/usr/lib/owlry/runtimes/liblua.so"
}

13
aur/owlry-rune/.SRCINFO Normal file
View File

@@ -0,0 +1,13 @@
pkgbase = owlry-rune
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
pkgver = 1.1.1
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
source = owlry-rune-1.1.1.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.1.tar.gz
b2sums = a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8
pkgname = owlry-rune

40
aur/owlry-rune/PKGBUILD Normal file
View File

@@ -0,0 +1,40 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-rune
pkgver=1.1.1
pkgrel=1
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('owlry-core')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
b2sums=('a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8')
_cratename=owlry-rune
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p $_cratename --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --release
}
package() {
cd "owlry"
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
"$pkgdir/usr/lib/owlry/runtimes/librune.so"
}

34
aur/owlry/.SRCINFO Normal file
View File

@@ -0,0 +1,34 @@
pkgbase = owlry
pkgdesc = Lightweight Wayland application launcher with plugin support
pkgver = 1.0.6
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
depends = gcc-libs
depends = gtk4
depends = gtk4-layer-shell
optdepends = cliphist: clipboard provider support
optdepends = wl-clipboard: clipboard and emoji copy support
optdepends = fd: fast file search
optdepends = owlry-plugin-calculator: calculator provider
optdepends = owlry-plugin-clipboard: clipboard provider
optdepends = owlry-plugin-emoji: emoji picker
optdepends = owlry-plugin-bookmarks: browser bookmarks
optdepends = owlry-plugin-ssh: SSH host launcher
optdepends = owlry-plugin-scripts: custom scripts provider
optdepends = owlry-plugin-system: system actions (shutdown, reboot, etc.)
optdepends = owlry-plugin-websearch: web search provider
optdepends = owlry-plugin-filesearch: file search provider
optdepends = owlry-plugin-systemd: systemd service management
optdepends = owlry-plugin-weather: weather widget
optdepends = owlry-plugin-media: media player controls
optdepends = owlry-plugin-pomodoro: pomodoro timer widget
optdepends = owlry-lua: Lua runtime for user plugins
optdepends = owlry-rune: Rune runtime for user plugins
source = owlry-1.0.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.6.tar.gz
b2sums = 8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05
pkgname = owlry

76
aur/owlry/PKGBUILD Normal file
View File

@@ -0,0 +1,76 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry
pkgver=1.0.6
pkgrel=1
pkgdesc="Lightweight Wayland application launcher with plugin support"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('owlry-core' 'gcc-libs' 'gtk4' 'gtk4-layer-shell')
makedepends=('cargo')
optdepends=(
'cliphist: clipboard provider support'
'wl-clipboard: clipboard and emoji copy support'
'fd: fast file search'
'owlry-plugin-calculator: calculator provider'
'owlry-plugin-clipboard: clipboard provider'
'owlry-plugin-emoji: emoji picker'
'owlry-plugin-bookmarks: browser bookmarks'
'owlry-plugin-ssh: SSH host launcher'
'owlry-plugin-scripts: custom scripts provider'
'owlry-plugin-system: system actions (shutdown, reboot, etc.)'
'owlry-plugin-websearch: web search provider'
'owlry-plugin-filesearch: file search provider'
'owlry-plugin-systemd: systemd service management'
'owlry-plugin-weather: weather widget'
'owlry-plugin-media: media player controls'
'owlry-plugin-pomodoro: pomodoro timer widget'
'owlry-lua: Lua runtime for user plugins'
'owlry-rune: Rune runtime for user plugins'
)
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
b2sums=('8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05')
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
# Build only the core binary without embedded Lua (Lua runtime is separate package)
cargo build -p owlry --frozen --release --no-default-features
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p owlry --frozen --no-default-features
}
package() {
cd "owlry"
# Core binary
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
# Documentation
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
# Example configuration files
install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css"
install -Dm755 data/scripts/example.sh "$pkgdir/usr/share/doc/$pkgname/scripts/example.sh"
# Install themes
install -d "$pkgdir/usr/share/$pkgname/themes"
install -Dm644 data/themes/*.css "$pkgdir/usr/share/$pkgname/themes/"
# Example plugins (for user plugin development)
install -d "$pkgdir/usr/share/$pkgname/examples/plugins"
cp -r examples/plugins/* "$pkgdir/usr/share/$pkgname/examples/plugins/"
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-core" name = "owlry-core"
version = "1.0.0" version = "1.3.1"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
@@ -12,7 +12,7 @@ name = "owlry_core"
path = "src/lib.rs" path = "src/lib.rs"
[[bin]] [[bin]]
name = "owlry-core" name = "owlryd"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
@@ -36,6 +36,10 @@ dirs = "5"
# Error handling # Error handling
thiserror = "2" thiserror = "2"
# Filesystem watching (plugin hot-reload)
notify = "7"
notify-debouncer-mini = "0.5"
# Signal handling # Signal handling
ctrlc = { version = "3", features = ["termination"] } ctrlc = { version = "3", features = ["termination"] }
@@ -44,15 +48,17 @@ log = "0.4"
env_logger = "0.11" env_logger = "0.11"
notify-rust = "4" notify-rust = "4"
# Built-in providers
meval = "0.2"
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
# Optional: embedded Lua runtime # Optional: embedded Lua runtime
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true } mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
meval = { version = "0.2", optional = true }
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
[features] [features]
default = [] default = []
lua = ["dep:mlua", "dep:meval", "dep:reqwest"] lua = ["dep:mlua"]
dev-logging = [] dev-logging = []

View File

@@ -2,7 +2,6 @@ use log::{debug, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
use crate::paths; use crate::paths;
@@ -163,6 +162,9 @@ pub struct ProvidersConfig {
/// Enable calculator provider (= expression or calc expression) /// Enable calculator provider (= expression or calc expression)
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub calculator: bool, pub calculator: bool,
/// Enable converter provider (> expression or auto-detect)
#[serde(default = "default_true")]
pub converter: bool,
/// Enable frecency-based result ranking /// Enable frecency-based result ranking
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub frecency: bool, pub frecency: bool,
@@ -240,6 +242,7 @@ impl Default for ProvidersConfig {
commands: true, commands: true,
uuctl: true, uuctl: true,
calculator: true, calculator: true,
converter: true,
frecency: true, frecency: true,
frecency_weight: 0.3, frecency_weight: 0.3,
websearch: true, websearch: true,
@@ -522,12 +525,15 @@ fn detect_de_terminal() -> Option<String> {
None None
} }
/// Check if a command exists in PATH /// Check if a command exists in PATH (in-process, no subprocess spawning)
fn command_exists(cmd: &str) -> bool { fn command_exists(cmd: &str) -> bool {
Command::new("which") std::env::var_os("PATH")
.arg(cmd) .map(|paths| {
.output() std::env::split_paths(&paths).any(|dir| {
.map(|o| o.status.success()) let full = dir.join(cmd);
full.is_file()
})
})
.unwrap_or(false) .unwrap_or(false)
} }
@@ -591,3 +597,17 @@ impl Config {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
#[test]
fn command_exists_finds_sh() {
// /bin/sh exists on every Unix system
assert!(super::command_exists("sh"));
}
#[test]
fn command_exists_rejects_nonexistent() {
assert!(!super::command_exists("owlry_nonexistent_binary_abc123"));
}
}

View File

@@ -131,23 +131,36 @@ impl FrecencyStore {
} }
} }
/// Calculate frecency score using a pre-sampled timestamp.
/// Use this in hot loops to avoid repeated Utc::now() syscalls.
pub fn get_score_at(&self, item_id: &str, now: DateTime<Utc>) -> f64 {
match self.data.entries.get(item_id) {
Some(entry) => Self::calculate_frecency_at(entry.launch_count, entry.last_launch, now),
None => 0.0,
}
}
/// Calculate frecency using Firefox-style algorithm /// Calculate frecency using Firefox-style algorithm
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 { fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
let now = Utc::now(); let now = Utc::now();
Self::calculate_frecency_at(launch_count, last_launch, now)
}
/// Calculate frecency using a caller-provided timestamp.
fn calculate_frecency_at(launch_count: u32, last_launch: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
let age = now.signed_duration_since(last_launch); let age = now.signed_duration_since(last_launch);
let age_days = age.num_hours() as f64 / 24.0; let age_days = age.num_hours() as f64 / 24.0;
// Recency weight based on how recently the item was used
let recency_weight = if age_days < 1.0 { let recency_weight = if age_days < 1.0 {
100.0 // Today 100.0
} else if age_days < 7.0 { } else if age_days < 7.0 {
70.0 // This week 70.0
} else if age_days < 30.0 { } else if age_days < 30.0 {
50.0 // This month 50.0
} else if age_days < 90.0 { } else if age_days < 90.0 {
30.0 // This quarter 30.0
} else { } else {
10.0 // Older 10.0
}; };
launch_count as f64 * recency_weight launch_count as f64 * recency_weight
@@ -206,6 +219,32 @@ mod tests {
assert!(score_month < score_week); assert!(score_month < score_week);
} }
#[test]
fn get_score_at_matches_get_score() {
let mut store = FrecencyStore {
data: FrecencyData {
version: 1,
entries: HashMap::new(),
},
path: PathBuf::from("/dev/null"),
dirty: false,
};
store.data.entries.insert(
"test".to_string(),
FrecencyEntry {
launch_count: 5,
last_launch: Utc::now(),
},
);
let now = Utc::now();
let score_at = store.get_score_at("test", now);
let score = store.get_score("test");
// Both should be very close (same timestamp, within rounding)
assert!((score_at - score).abs() < 1.0);
}
#[test] #[test]
fn test_launch_count_matters() { fn test_launch_count_matters() {
let now = Utc::now(); let now = Utc::now();

View File

@@ -32,6 +32,8 @@ impl ProviderFilter {
cli_providers: Option<Vec<ProviderType>>, cli_providers: Option<Vec<ProviderType>>,
config_providers: &ProvidersConfig, config_providers: &ProvidersConfig,
) -> Self { ) -> Self {
let accept_all = cli_mode.is_none() && cli_providers.is_none();
let enabled = if let Some(mode) = cli_mode { let enabled = if let Some(mode) = cli_mode {
// --mode overrides everything: single provider // --mode overrides everything: single provider
HashSet::from([mode]) HashSet::from([mode])
@@ -90,7 +92,7 @@ impl ProviderFilter {
let filter = Self { let filter = Self {
enabled, enabled,
active_prefix: None, active_prefix: None,
accept_all: false, accept_all,
}; };
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
@@ -184,6 +186,11 @@ impl ProviderFilter {
self.accept_all || self.enabled.contains(&provider) self.accept_all || self.enabled.contains(&provider)
} }
/// Whether this filter accepts all provider types
pub fn is_accept_all(&self) -> bool {
self.accept_all
}
/// Get current active prefix if any /// Get current active prefix if any
#[allow(dead_code)] #[allow(dead_code)]
pub fn active_prefix(&self) -> Option<ProviderType> { pub fn active_prefix(&self) -> Option<ProviderType> {
@@ -254,6 +261,10 @@ impl ProviderFilter {
(":systemd ", "uuctl"), (":systemd ", "uuctl"),
(":web ", "websearch"), (":web ", "websearch"),
(":search ", "websearch"), (":search ", "websearch"),
(":config ", "config"),
(":settings ", "config"),
(":conv ", "conv"),
(":converter ", "conv"),
]; ];
// Check core prefixes // Check core prefixes
@@ -320,6 +331,10 @@ impl ProviderFilter {
(":systemd", "uuctl"), (":systemd", "uuctl"),
(":web", "websearch"), (":web", "websearch"),
(":search", "websearch"), (":search", "websearch"),
(":config", "config"),
(":settings", "config"),
(":conv", "conv"),
(":converter", "conv"),
]; ];
for (prefix_str, provider) in partial_core { for (prefix_str, provider) in partial_core {
@@ -353,6 +368,28 @@ impl ProviderFilter {
} }
} }
// Dynamic plugin prefix fallback: ":word " or ":word" where word is unknown
// Maps to Plugin(word) so user plugins with custom prefixes work
if let Some(rest) = trimmed.strip_prefix(':') {
if let Some(space_idx) = rest.find(' ') {
let prefix_word = &rest[..space_idx];
if !prefix_word.is_empty() && prefix_word.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return ParsedQuery {
prefix: Some(ProviderType::Plugin(prefix_word.to_string())),
tag_filter: None,
query: rest[space_idx + 1..].to_string(),
};
}
} else if !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
// Partial prefix (no space yet)
return ParsedQuery {
prefix: Some(ProviderType::Plugin(rest.to_string())),
tag_filter: None,
query: String::new(),
};
}
}
let result = ParsedQuery { let result = ParsedQuery {
prefix: None, prefix: None,
tag_filter: None, tag_filter: None,

View File

@@ -1,4 +1,4 @@
use log::info; use log::{info, warn};
use owlry_core::paths; use owlry_core::paths;
use owlry_core::server::Server; use owlry_core::server::Server;
@@ -7,7 +7,7 @@ fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let sock = paths::socket_path(); let sock = paths::socket_path();
info!("Starting owlry-core daemon..."); info!("Starting owlryd daemon...");
// Ensure the socket parent directory exists // Ensure the socket parent directory exists
if let Err(e) = paths::ensure_parent_dir(&sock) { if let Err(e) = paths::ensure_parent_dir(&sock) {
@@ -18,18 +18,19 @@ fn main() {
let server = match Server::bind(&sock) { let server = match Server::bind(&sock) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
eprintln!("Failed to start owlry-core: {e}"); eprintln!("Failed to start owlryd: {e}");
std::process::exit(1); std::process::exit(1);
} }
}; };
// Graceful shutdown on SIGTERM/SIGINT // Graceful shutdown on SIGTERM/SIGINT
let sock_cleanup = sock.clone(); let sock_cleanup = sock.clone();
ctrlc::set_handler(move || { if let Err(e) = ctrlc::set_handler(move || {
let _ = std::fs::remove_file(&sock_cleanup); let _ = std::fs::remove_file(&sock_cleanup);
std::process::exit(0); std::process::exit(0);
}) }) {
.ok(); warn!("Failed to set signal handler: {}", e);
}
if let Err(e) = server.run() { if let Err(e) = server.run() {
eprintln!("Server error: {e}"); eprintln!("Server error: {e}");

View File

@@ -26,6 +26,7 @@ pub mod manifest;
pub mod native_loader; pub mod native_loader;
pub mod registry; pub mod registry;
pub mod runtime_loader; pub mod runtime_loader;
pub mod watcher;
// Lua-specific modules (require mlua) // Lua-specific modules (require mlua)
#[cfg(feature = "lua")] #[cfg(feature = "lua")]

View File

@@ -10,8 +10,6 @@
//! Note: This module is infrastructure for the runtime architecture. Full integration //! Note: This module is infrastructure for the runtime architecture. Full integration
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available. //! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
#![allow(dead_code)]
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -56,7 +54,7 @@ pub struct RuntimeHandle(pub *mut ());
#[repr(C)] #[repr(C)]
pub struct ScriptRuntimeVTable { pub struct ScriptRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo, pub info: extern "C" fn() -> RuntimeInfo,
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle, pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>, pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>, pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn( pub query: extern "C" fn(
@@ -83,12 +81,13 @@ pub struct LoadedRuntime {
impl LoadedRuntime { impl LoadedRuntime {
/// Load the Lua runtime from the system directory /// Load the Lua runtime from the system directory
pub fn load_lua(plugins_dir: &Path) -> PluginResult<Self> { pub fn load_lua(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
Self::load_from_path( Self::load_from_path(
"Lua", "Lua",
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"), &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
b"owlry_lua_runtime_vtable", b"owlry_lua_runtime_vtable",
plugins_dir, plugins_dir,
owlry_version,
) )
} }
@@ -98,6 +97,7 @@ impl LoadedRuntime {
library_path: &Path, library_path: &Path,
vtable_symbol: &[u8], vtable_symbol: &[u8],
plugins_dir: &Path, plugins_dir: &Path,
owlry_version: &str,
) -> PluginResult<Self> { ) -> PluginResult<Self> {
if !library_path.exists() { if !library_path.exists() {
return Err(PluginError::NotFound(library_path.display().to_string())); return Err(PluginError::NotFound(library_path.display().to_string()));
@@ -124,7 +124,7 @@ impl LoadedRuntime {
// Initialize the runtime // Initialize the runtime
let plugins_dir_str = plugins_dir.to_string_lossy(); let plugins_dir_str = plugins_dir.to_string_lossy();
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str)); let handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
// Get provider information // Get provider information
let providers_rvec = (vtable.providers)(handle); let providers_rvec = (vtable.providers)(handle);
@@ -169,6 +169,14 @@ impl Drop for LoadedRuntime {
} }
} }
// LoadedRuntime needs to be Send + Sync because ProviderManager is shared across
// threads via Arc<RwLock<ProviderManager>>.
// Safety: RuntimeHandle is an opaque FFI handle accessed only through extern "C"
// vtable functions. The same safety argument that applies to RuntimeProvider applies
// here — all access is mediated by the vtable, and the runtime itself serializes access.
unsafe impl Send for LoadedRuntime {}
unsafe impl Sync for LoadedRuntime {}
/// A provider backed by a dynamically loaded runtime /// A provider backed by a dynamically loaded runtime
pub struct RuntimeProvider { pub struct RuntimeProvider {
/// Runtime name (for logging) /// Runtime name (for logging)
@@ -243,8 +251,12 @@ impl Provider for RuntimeProvider {
} }
} }
// RuntimeProvider needs to be Send for the Provider trait // RuntimeProvider needs to be Send + Sync for the Provider trait.
// Safety: RuntimeHandle is an opaque FFI handle accessed only through
// extern "C" vtable functions. The same safety argument that justifies
// Send applies to Sync — all access is mediated by the vtable.
unsafe impl Send for RuntimeProvider {} unsafe impl Send for RuntimeProvider {}
unsafe impl Sync for RuntimeProvider {}
/// Check if the Lua runtime is available /// Check if the Lua runtime is available
pub fn lua_runtime_available() -> bool { pub fn lua_runtime_available() -> bool {
@@ -262,12 +274,13 @@ pub fn rune_runtime_available() -> bool {
impl LoadedRuntime { impl LoadedRuntime {
/// Load the Rune runtime from the system directory /// Load the Rune runtime from the system directory
pub fn load_rune(plugins_dir: &Path) -> PluginResult<Self> { pub fn load_rune(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
Self::load_from_path( Self::load_from_path(
"Rune", "Rune",
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"), &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
b"owlry_rune_runtime_vtable", b"owlry_rune_runtime_vtable",
plugins_dir, plugins_dir,
owlry_version,
) )
} }
} }

View File

@@ -0,0 +1,104 @@
//! Filesystem watcher for user plugin hot-reload
//!
//! Watches `~/.config/owlry/plugins/` for changes and triggers
//! runtime reload when plugin files are modified.
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use log::{info, warn};
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use crate::providers::ProviderManager;
/// Start watching the user plugins directory for changes.
///
/// Spawns a background thread that monitors the directory and triggers
/// a full runtime reload on any file change. Returns immediately.
///
/// Respects `OWLRY_SKIP_RUNTIMES=1` — returns early if set.
pub fn start_watching(pm: Arc<RwLock<ProviderManager>>) {
if std::env::var("OWLRY_SKIP_RUNTIMES").is_ok() {
info!("OWLRY_SKIP_RUNTIMES set, skipping file watcher");
return;
}
let plugins_dir = match crate::paths::plugins_dir() {
Some(d) => d,
None => {
info!("No plugins directory configured, skipping file watcher");
return;
}
};
if !plugins_dir.exists()
&& std::fs::create_dir_all(&plugins_dir).is_err()
{
warn!(
"Failed to create plugins directory: {}",
plugins_dir.display()
);
return;
}
info!(
"Plugin file watcher started for {}",
plugins_dir.display()
);
thread::spawn(move || {
if let Err(e) = watch_loop(&plugins_dir, &pm) {
warn!("Plugin watcher stopped: {}", e);
}
});
}
fn watch_loop(
plugins_dir: &PathBuf,
pm: &Arc<RwLock<ProviderManager>>,
) -> Result<(), Box<dyn std::error::Error>> {
let (tx, rx) = std::sync::mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
debouncer
.watcher()
.watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?;
info!("Watching {} for plugin changes", plugins_dir.display());
// Skip events during initial startup grace period (watcher setup triggers events)
let startup = std::time::Instant::now();
let grace_period = Duration::from_secs(2);
loop {
match rx.recv() {
Ok(Ok(events)) => {
if startup.elapsed() < grace_period {
continue;
}
let has_relevant_change = events.iter().any(|e| {
matches!(
e.kind,
DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
)
});
if has_relevant_change {
info!("Plugin file change detected, reloading runtimes...");
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.reload_runtimes();
}
}
Ok(Err(error)) => {
warn!("File watcher error: {}", error);
}
Err(e) => {
return Err(Box::new(e));
}
}
}
}

View File

@@ -0,0 +1,237 @@
use super::{DynamicProvider, LaunchItem, ProviderType};
/// Built-in calculator provider. Evaluates mathematical expressions via `meval`.
///
/// Triggered by:
/// - `= expr` / `=expr` / `calc expr` (explicit prefix)
/// - Raw math expressions containing operators or known functions (auto-detect)
pub(crate) struct CalculatorProvider;
impl DynamicProvider for CalculatorProvider {
fn name(&self) -> &str {
"Calculator"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("calc".into())
}
fn priority(&self) -> u32 {
10_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let expr = match extract_expression(query) {
Some(e) if !e.is_empty() => e,
_ => return Vec::new(),
};
match meval::eval_str(expr) {
Ok(result) => {
let display = format_result(result);
let copy_cmd = format!(
"printf '%s' '{}' | wl-copy",
display.replace('\'', "'\\''")
);
vec![LaunchItem {
id: format!("calc:{}", expr),
name: display.clone(),
description: Some(format!("= {}", expr)),
icon: Some("accessories-calculator".into()),
provider: ProviderType::Plugin("calc".into()),
command: copy_cmd,
terminal: false,
tags: vec!["math".into(), "calculator".into()],
}]
}
Err(_) => Vec::new(),
}
}
}
/// Extract the math expression from a query string.
///
/// Handles:
/// - `= expr` and `=expr` (explicit calculator prefix)
/// - `calc expr` (word prefix)
/// - Raw expressions if they look like math (auto-detect)
///
/// Returns `None` only when input is empty after trimming.
fn extract_expression(query: &str) -> Option<&str> {
let trimmed = query.trim();
if trimmed.is_empty() {
return None;
}
// Explicit prefixes
if let Some(rest) = trimmed.strip_prefix("= ") {
return Some(rest.trim());
}
if let Some(rest) = trimmed.strip_prefix('=') {
return Some(rest.trim());
}
if let Some(rest) = trimmed.strip_prefix("calc ") {
return Some(rest.trim());
}
// Auto-detect: only forward if the expression looks like math.
// Plain words like "firefox" should not reach meval.
if looks_like_math(trimmed) {
Some(trimmed)
} else {
None
}
}
/// Heuristic: does this string look like a math expression?
///
/// Returns true when the string contains binary operators, digits mixed with
/// operators, or known function names. Plain alphabetic words return false.
fn looks_like_math(s: &str) -> bool {
// Must contain at least one digit or a known constant/function name
let has_digit = s.chars().any(|c| c.is_ascii_digit());
let has_operator = s.contains('+')
|| s.contains('*')
|| s.contains('/')
|| s.contains('^')
|| s.contains('%');
// Subtraction/negation is ambiguous; only count it as an operator when
// there are already digits present to avoid matching bare words with hyphens.
let has_minus_operator = has_digit && s.contains('-');
// Known math functions that are safe to auto-evaluate
const MATH_FUNCTIONS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "log", "ln", "abs", "floor", "ceil", "round",
];
let has_function = MATH_FUNCTIONS.iter().any(|f| s.contains(f));
has_digit && (has_operator || has_minus_operator) || has_function
}
/// Format a floating-point result for display.
///
/// Integer-valued results are shown as integers with thousands separators.
/// Non-integer results are shown with up to 10 decimal places, trailing zeros trimmed.
fn format_result(result: f64) -> String {
if result.fract() == 0.0 && result.abs() < 1e15 {
format_integer_with_separators(result as i64)
} else {
let formatted = format!("{:.10}", result);
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
fn format_integer_with_separators(n: i64) -> String {
let s = n.unsigned_abs().to_string();
let with_commas = s
.as_bytes()
.rchunks(3)
.rev()
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<_>>()
.join(",");
if n < 0 {
format!("-{}", with_commas)
} else {
with_commas
}
}
#[cfg(test)]
mod tests {
use super::*;
fn query(q: &str) -> Vec<LaunchItem> {
CalculatorProvider.query(q)
}
// --- Trigger prefix tests ---
#[test]
fn equals_prefix_addition() {
let results = query("= 5+3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "8");
}
#[test]
fn calc_prefix_multiplication() {
let results = query("calc 10*2");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "20");
}
// --- Auto-detect tests ---
#[test]
fn auto_detect_addition() {
let results = query("5+3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "8");
}
#[test]
fn equals_prefix_complex_expression() {
let results = query("= sqrt(16) + 2^3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "12");
}
#[test]
fn decimal_result() {
let results = query("= 10/3");
assert_eq!(results.len(), 1);
assert!(
results[0].name.starts_with("3.333"),
"expected result starting with 3.333, got: {}",
results[0].name
);
}
#[test]
fn large_integer_thousands_separators() {
let results = query("= 1000000");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "1,000,000");
}
// --- Invalid / non-math input ---
#[test]
fn invalid_expression_returns_empty() {
let results = query("= 5 +");
assert!(results.is_empty());
}
#[test]
fn plain_text_returns_empty() {
let results = query("firefox");
assert!(results.is_empty());
}
// --- Metadata tests ---
#[test]
fn provider_type_is_calc_plugin() {
assert_eq!(
CalculatorProvider.provider_type(),
ProviderType::Plugin("calc".into())
);
}
#[test]
fn description_shows_expression() {
let results = query("= 5+3");
assert_eq!(results[0].description.as_deref(), Some("= 5+3"));
}
#[test]
fn copy_command_contains_wl_copy() {
let results = query("= 5+3");
assert!(results[0].command.contains("wl-copy"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
const ECB_URL: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
const CACHE_MAX_AGE_SECS: u64 = 86400; // 24 hours
static CACHED_RATES: Mutex<Option<CurrencyRates>> = Mutex::new(None);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrencyRates {
pub date: String,
pub rates: HashMap<String, f64>,
}
struct CurrencyAlias {
code: &'static str,
aliases: &'static [&'static str],
}
static CURRENCY_ALIASES: &[CurrencyAlias] = &[
CurrencyAlias {
code: "EUR",
aliases: &["eur", "euro", "euros", ""],
},
CurrencyAlias {
code: "USD",
aliases: &["usd", "dollar", "dollars", "$", "us_dollar"],
},
CurrencyAlias {
code: "GBP",
aliases: &["gbp", "pound_sterling", "£", "british_pound", "pounds"],
},
CurrencyAlias {
code: "JPY",
aliases: &["jpy", "yen", "¥", "japanese_yen"],
},
CurrencyAlias {
code: "CHF",
aliases: &["chf", "swiss_franc", "francs"],
},
CurrencyAlias {
code: "CAD",
aliases: &["cad", "canadian_dollar", "c$"],
},
CurrencyAlias {
code: "AUD",
aliases: &["aud", "australian_dollar", "a$"],
},
CurrencyAlias {
code: "CNY",
aliases: &["cny", "yuan", "renminbi", "rmb"],
},
CurrencyAlias {
code: "SEK",
aliases: &["sek", "swedish_krona", "kronor"],
},
CurrencyAlias {
code: "NOK",
aliases: &["nok", "norwegian_krone"],
},
CurrencyAlias {
code: "DKK",
aliases: &["dkk", "danish_krone"],
},
CurrencyAlias {
code: "PLN",
aliases: &["pln", "zloty", "złoty"],
},
CurrencyAlias {
code: "CZK",
aliases: &["czk", "czech_koruna"],
},
CurrencyAlias {
code: "HUF",
aliases: &["huf", "forint"],
},
CurrencyAlias {
code: "TRY",
aliases: &["try", "turkish_lira", "lira"],
},
];
pub fn resolve_currency_code(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
// Check aliases
for ca in CURRENCY_ALIASES {
if ca.aliases.contains(&lower.as_str()) {
return Some(ca.code); // ca.code is already &'static str
}
}
// Check if it's a raw 3-letter ISO code we know about
let upper = alias.to_uppercase();
if upper.len() == 3 {
if upper == "EUR" {
return Some("EUR");
}
if let Some(rates) = get_rates()
&& rates.rates.contains_key(&upper)
{
for ca in CURRENCY_ALIASES {
if ca.code == upper {
return Some(ca.code);
}
}
}
}
None
}
#[allow(dead_code)]
pub fn is_currency_alias(alias: &str) -> bool {
resolve_currency_code(alias).is_some()
}
pub fn get_rates() -> Option<CurrencyRates> {
// Check memory cache first
{
let cache = CACHED_RATES.lock().ok()?;
if let Some(ref rates) = *cache {
return Some(rates.clone());
}
}
// Try disk cache
if let Some(rates) = load_cache()
&& !is_stale(&rates)
{
let mut cache = CACHED_RATES.lock().ok()?;
*cache = Some(rates.clone());
return Some(rates);
}
// Fetch fresh rates
if let Some(rates) = fetch_rates() {
save_cache(&rates);
let mut cache = CACHED_RATES.lock().ok()?;
*cache = Some(rates.clone());
return Some(rates);
}
// Fall back to stale cache
load_cache()
}
fn cache_path() -> Option<PathBuf> {
let cache_dir = dirs::cache_dir()?.join("owlry");
Some(cache_dir.join("ecb_rates.json"))
}
fn load_cache() -> Option<CurrencyRates> {
let path = cache_path()?;
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_cache(rates: &CurrencyRates) {
if let Some(path) = cache_path() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}
if let Ok(json) = serde_json::to_string_pretty(rates) {
fs::write(path, json).ok();
}
}
}
fn is_stale(_rates: &CurrencyRates) -> bool {
let path = match cache_path() {
Some(p) => p,
None => return true,
};
let metadata = match fs::metadata(path) {
Ok(m) => m,
Err(_) => return true,
};
let modified = match metadata.modified() {
Ok(t) => t,
Err(_) => return true,
};
match SystemTime::now().duration_since(modified) {
Ok(age) => age.as_secs() > CACHE_MAX_AGE_SECS,
Err(_) => true,
}
}
fn fetch_rates() -> Option<CurrencyRates> {
let response = reqwest::blocking::get(ECB_URL).ok()?;
let body = response.text().ok()?;
parse_ecb_xml(&body)
}
fn parse_ecb_xml(xml: &str) -> Option<CurrencyRates> {
let mut rates = HashMap::new();
let mut date = String::new();
for line in xml.lines() {
let trimmed = line.trim();
// Extract date: <Cube time='2026-03-26'>
if trimmed.contains("time=")
&& let Some(start) = trimmed.find("time='")
{
let rest = &trimmed[start + 6..];
if let Some(end) = rest.find('\'') {
date = rest[..end].to_string();
}
}
// Extract rate: <Cube currency='USD' rate='1.0832'/>
if trimmed.contains("currency=") && trimmed.contains("rate=") {
let currency = extract_attr(trimmed, "currency")?;
let rate_str = extract_attr(trimmed, "rate")?;
let rate: f64 = rate_str.parse().ok()?;
rates.insert(currency, rate);
}
}
if rates.is_empty() {
return None;
}
Some(CurrencyRates { date, rates })
}
fn extract_attr(line: &str, attr: &str) -> Option<String> {
let needle = format!("{}='", attr);
let start = line.find(&needle)? + needle.len();
let rest = &line[start..];
let end = rest.find('\'')?;
Some(rest[..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_currency_code_iso() {
assert_eq!(resolve_currency_code("usd"), Some("USD"));
assert_eq!(resolve_currency_code("EUR"), Some("EUR"));
}
#[test]
fn test_resolve_currency_code_name() {
assert_eq!(resolve_currency_code("dollar"), Some("USD"));
assert_eq!(resolve_currency_code("euro"), Some("EUR"));
assert_eq!(resolve_currency_code("pounds"), Some("GBP"));
}
#[test]
fn test_resolve_currency_code_symbol() {
assert_eq!(resolve_currency_code("$"), Some("USD"));
assert_eq!(resolve_currency_code(""), Some("EUR"));
assert_eq!(resolve_currency_code("£"), Some("GBP"));
}
#[test]
fn test_resolve_currency_unknown() {
assert_eq!(resolve_currency_code("xyz"), None);
}
#[test]
fn test_is_currency_alias() {
assert!(is_currency_alias("usd"));
assert!(is_currency_alias("euro"));
assert!(is_currency_alias("$"));
assert!(!is_currency_alias("km"));
}
#[test]
fn test_parse_ecb_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
<gesmes:subject>Reference rates</gesmes:subject>
<Cube>
<Cube time='2026-03-26'>
<Cube currency='USD' rate='1.0832'/>
<Cube currency='JPY' rate='161.94'/>
<Cube currency='GBP' rate='0.83450'/>
</Cube>
</Cube>
</gesmes:Envelope>"#;
let rates = parse_ecb_xml(xml).unwrap();
assert!((rates.rates["USD"] - 1.0832).abs() < 0.001);
assert!((rates.rates["GBP"] - 0.8345).abs() < 0.001);
assert!((rates.rates["JPY"] - 161.94).abs() < 0.01);
}
#[test]
fn test_cache_roundtrip() {
let rates = CurrencyRates {
date: "2026-03-26".to_string(),
rates: {
let mut m = HashMap::new();
m.insert("USD".to_string(), 1.0832);
m.insert("GBP".to_string(), 0.8345);
m
},
};
let json = serde_json::to_string(&rates).unwrap();
let parsed: CurrencyRates = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.rates["USD"], 1.0832);
}
}

View File

@@ -0,0 +1,183 @@
mod currency;
mod parser;
mod units;
use super::{DynamicProvider, LaunchItem, ProviderType};
const PROVIDER_TYPE_ID: &str = "conv";
const PROVIDER_ICON: &str = "edit-find-replace-symbolic";
pub struct ConverterProvider;
impl ConverterProvider {
pub fn new() -> Self {
Self
}
}
impl DynamicProvider for ConverterProvider {
fn name(&self) -> &str {
"Converter"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
}
fn priority(&self) -> u32 {
9_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let query_str = query.trim();
// Strip prefix
let input = if let Some(rest) = query_str.strip_prefix('>') {
rest.trim()
} else {
query_str
};
let parsed = match parser::parse_conversion(input) {
Some(p) => p,
None => return Vec::new(),
};
let results = if let Some(ref target) = parsed.target_unit {
units::convert_to(&parsed.value, &parsed.from_unit, target)
.into_iter()
.collect()
} else {
units::convert_common(&parsed.value, &parsed.from_unit)
};
results
.into_iter()
.map(|r| LaunchItem {
id: format!("conv:{}:{}:{}", parsed.from_unit, r.target_symbol, r.value),
name: r.display_value.clone(),
description: Some(format!(
"{} {} = {}",
format_number(parsed.value),
parsed.from_symbol,
r.display_value,
)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!(
"printf '%s' '{}' | wl-copy",
r.raw_value.replace('\'', "'\\''")
),
terminal: false,
tags: vec!["converter".into(), "units".into()],
})
.collect()
}
}
fn format_number(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
let i = n as i64;
if i.abs() >= 1000 {
format_with_separators(i)
} else {
format!("{}", i)
}
} else {
format!("{:.4}", n)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
pub(crate) fn format_with_separators(n: i64) -> String {
let s = n.abs().to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
if n < 0 {
result.push('-');
}
result.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn query(input: &str) -> Vec<LaunchItem> {
ConverterProvider::new().query(input)
}
#[test]
fn test_prefix_trigger() {
let r = query("> 100 km to mi");
assert!(!r.is_empty());
}
#[test]
fn test_auto_detect() {
let r = query("100 km to mi");
assert!(!r.is_empty());
}
#[test]
fn test_common_conversions() {
let r = query("> 100 km");
assert!(r.len() > 1);
}
#[test]
fn test_temperature() {
let r = query("102F to C");
assert!(!r.is_empty());
}
#[test]
fn test_nonsense_returns_empty() {
assert!(query("hello world").is_empty());
}
#[test]
fn test_provider_type() {
assert_eq!(
ConverterProvider::new().provider_type(),
ProviderType::Plugin("conv".into())
);
}
#[test]
fn test_no_double_unit() {
let r = query("100 km to mi");
if let Some(item) = r.first() {
let desc = item.description.as_deref().unwrap();
assert!(!desc.ends_with(" mi mi"), "double unit in: {}", desc);
}
}
#[test]
fn test_format_number_integer() {
assert_eq!(format_number(42.0), "42");
}
#[test]
fn test_format_number_large_integer() {
assert_eq!(format_number(1000000.0), "1,000,000");
}
#[test]
fn test_format_number_decimal() {
assert_eq!(format_number(3.14), "3.14");
}
#[test]
fn test_format_with_separators() {
assert_eq!(format_with_separators(1234567), "1,234,567");
assert_eq!(format_with_separators(999), "999");
assert_eq!(format_with_separators(-1234), "-1,234");
}
}

View File

@@ -0,0 +1,235 @@
use super::units;
pub struct ParsedQuery {
pub value: f64,
pub from_unit: String,
pub from_symbol: String,
pub target_unit: Option<String>,
}
pub fn parse_conversion(input: &str) -> Option<ParsedQuery> {
let input = input.trim();
if input.is_empty() {
return None;
}
// Extract leading number
let (value, rest) = extract_number(input)?;
let rest = rest.trim();
if rest.is_empty() {
return None;
}
// Split on " to " or " in " (case-insensitive)
let (from_str, target_str) = split_on_connector(rest);
// Resolve from unit
let from_lower = from_str.trim().to_lowercase();
let from_symbol = units::find_unit(&from_lower)?;
let from_symbol_str = from_symbol.to_string();
// Resolve target unit if present
let target_unit = target_str.and_then(|t| {
let t_lower = t.trim().to_lowercase();
if t_lower.is_empty() {
None
} else {
units::find_unit(&t_lower).map(|_| t_lower)
}
});
Some(ParsedQuery {
value,
from_unit: from_lower,
from_symbol: from_symbol_str,
target_unit,
})
}
fn extract_number(input: &str) -> Option<(f64, &str)> {
let bytes = input.as_bytes();
let mut i = 0;
// Optional negative sign
if i < bytes.len() && bytes[i] == b'-' {
i += 1;
}
// Must have at least one digit or start with .
if i >= bytes.len() {
return None;
}
let start_digits = i;
// Integer part
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
// Decimal part
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
}
if i == start_digits && !(i > 0 && bytes[0] == b'-') {
// No digits found (and not just a negative sign before a dot)
// Handle ".5" case
if bytes[start_digits] == b'.' {
// already advanced past dot above
} else {
return None;
}
}
if i == 0 || (i == 1 && bytes[0] == b'-') {
return None;
}
let num_str = &input[..i];
let value: f64 = num_str.parse().ok()?;
let rest = &input[i..];
Some((value, rest))
}
fn split_on_connector(input: &str) -> (&str, Option<&str>) {
let lower = input.to_lowercase();
// Try " to " first
if let Some(pos) = lower.find(" to ") {
let from = &input[..pos];
let target = &input[pos + 4..];
return (from, Some(target));
}
// Try " in "
if let Some(pos) = lower.find(" in ") {
let from = &input[..pos];
let target = &input[pos + 4..];
return (from, Some(target));
}
(input, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_number_and_unit_with_space() {
let p = parse_conversion("100 km").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
assert!(p.target_unit.is_none());
}
#[test]
fn test_number_and_unit_no_space() {
let p = parse_conversion("100km").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
}
#[test]
fn test_with_target_to() {
let p = parse_conversion("100 km to mi").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_with_target_in() {
let p = parse_conversion("100 km in mi").unwrap();
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_temperature_no_space() {
let p = parse_conversion("102F to C").unwrap();
assert!((p.value - 102.0).abs() < 0.001);
assert_eq!(p.from_unit, "f");
assert_eq!(p.target_unit.as_deref(), Some("c"));
}
#[test]
fn test_temperature_with_space() {
let p = parse_conversion("102 F in K").unwrap();
assert!((p.value - 102.0).abs() < 0.001);
assert_eq!(p.from_unit, "f");
assert_eq!(p.target_unit.as_deref(), Some("k"));
}
#[test]
fn test_decimal_number() {
let p = parse_conversion("3.5 kg to lb").unwrap();
assert!((p.value - 3.5).abs() < 0.001);
}
#[test]
fn test_decimal_starting_with_dot() {
let p = parse_conversion(".5 kg").unwrap();
assert!((p.value - 0.5).abs() < 0.001);
}
#[test]
fn test_full_unit_names() {
let p = parse_conversion("100 kilometers to miles").unwrap();
assert_eq!(p.from_unit, "kilometers");
assert_eq!(p.target_unit.as_deref(), Some("miles"));
}
#[test]
fn test_case_insensitive() {
let p = parse_conversion("100 KM TO MI").unwrap();
assert_eq!(p.from_unit, "km");
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_currency() {
let p = parse_conversion("100 eur to usd").unwrap();
assert_eq!(p.from_unit, "eur");
assert_eq!(p.target_unit.as_deref(), Some("usd"));
}
#[test]
fn test_no_number_returns_none() {
assert!(parse_conversion("km to mi").is_none());
}
#[test]
fn test_unknown_unit_returns_none() {
assert!(parse_conversion("100 xyz to abc").is_none());
}
#[test]
fn test_empty_returns_none() {
assert!(parse_conversion("").is_none());
}
#[test]
fn test_number_only_returns_none() {
assert!(parse_conversion("100").is_none());
}
#[test]
fn test_compound_unit_alias() {
let p = parse_conversion("100 km/h to mph").unwrap();
assert_eq!(p.from_unit, "km/h");
assert_eq!(p.target_unit.as_deref(), Some("mph"));
}
#[test]
fn test_multi_word_unit() {
let p = parse_conversion("100 fl_oz to ml").unwrap();
assert_eq!(p.from_unit, "fl_oz");
}
}

View File

@@ -0,0 +1,944 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use super::currency;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Category {
Temperature,
Length,
Weight,
Volume,
Speed,
Area,
Data,
Time,
Pressure,
Energy,
Currency,
}
#[derive(Clone)]
enum Conversion {
Factor(f64),
Custom {
to_base: fn(f64) -> f64,
from_base: fn(f64) -> f64,
},
}
#[derive(Clone)]
pub(crate) struct UnitDef {
_id: &'static str,
symbol: &'static str,
aliases: &'static [&'static str],
category: Category,
conversion: Conversion,
}
impl UnitDef {
fn to_base(&self, value: f64) -> f64 {
match &self.conversion {
Conversion::Factor(f) => value * f,
Conversion::Custom { to_base, .. } => to_base(value),
}
}
fn convert_from_base(&self, value: f64) -> f64 {
match &self.conversion {
Conversion::Factor(f) => value / f,
Conversion::Custom { from_base, .. } => from_base(value),
}
}
}
pub struct ConversionResult {
pub value: f64,
pub raw_value: String,
pub display_value: String,
pub target_symbol: String,
}
static UNITS: LazyLock<Vec<UnitDef>> = LazyLock::new(build_unit_table);
static ALIAS_MAP: LazyLock<HashMap<String, usize>> = LazyLock::new(|| {
let mut map = HashMap::new();
for (i, unit) in UNITS.iter().enumerate() {
for alias in unit.aliases {
map.insert(alias.to_lowercase(), i);
}
}
map
});
// Common conversions per category (symbols to show when no target specified)
static COMMON_TARGETS: LazyLock<HashMap<Category, Vec<&'static str>>> = LazyLock::new(|| {
let mut m = HashMap::new();
m.insert(Category::Temperature, vec!["°C", "°F", "K"]);
m.insert(Category::Length, vec!["m", "km", "ft", "mi", "in"]);
m.insert(Category::Weight, vec!["kg", "lb", "oz", "g", "st"]);
m.insert(Category::Volume, vec!["l", "gal", "ml", "cup", "fl oz"]);
m.insert(Category::Speed, vec!["km/h", "mph", "m/s", "kn"]);
m.insert(Category::Area, vec!["", "ft²", "ac", "ha", "km²"]);
m.insert(Category::Data, vec!["MB", "GB", "MiB", "GiB", "TB"]);
m.insert(Category::Time, vec!["s", "min", "h", "d", "wk"]);
m.insert(Category::Pressure, vec!["bar", "psi", "atm", "hPa", "mmHg"]);
m.insert(Category::Energy, vec!["kJ", "kcal", "kWh", "BTU", "Wh"]);
m.insert(Category::Currency, vec!["USD", "EUR", "GBP", "JPY", "CNY"]);
m
});
pub fn find_unit(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
if let Some(&i) = ALIAS_MAP.get(&lower) {
return Some(UNITS[i].symbol);
}
currency::resolve_currency_code(&lower)
}
pub fn lookup_unit(alias: &str) -> Option<(usize, &UnitDef)> {
let lower = alias.to_lowercase();
ALIAS_MAP.get(&lower).map(|&i| (i, &UNITS[i]))
}
pub fn convert_to(value: &f64, from: &str, to: &str) -> Option<ConversionResult> {
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
if currency::is_currency_alias(from) || currency::is_currency_alias(to) {
return convert_currency(*value, from, to);
}
let (_, from_def) = lookup_unit(from)?;
let (_, to_def) = lookup_unit(to)?;
// Currency via UNITS table (shouldn't reach here, but just in case)
if from_def.category == Category::Currency || to_def.category == Category::Currency {
return convert_currency(*value, from, to);
}
// Must be same category
if from_def.category != to_def.category {
return None;
}
let base_value = from_def.to_base(*value);
let result = to_def.convert_from_base(base_value);
Some(format_result(result, to_def.symbol))
}
pub fn convert_common(value: &f64, from: &str) -> Vec<ConversionResult> {
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
if currency::is_currency_alias(from) {
return convert_currency_common(*value, from);
}
let (_, from_def) = match lookup_unit(from) {
Some(u) => u,
None => return vec![],
};
let category = from_def.category;
let from_symbol = from_def.symbol;
if category == Category::Currency {
return convert_currency_common(*value, from);
}
let targets = match COMMON_TARGETS.get(&category) {
Some(t) => t,
None => return vec![],
};
let base_value = from_def.to_base(*value);
targets
.iter()
.filter(|&&sym| sym != from_symbol)
.filter_map(|&sym| {
let (_, to_def) = lookup_unit(sym)?;
let result = to_def.convert_from_base(base_value);
Some(format_result(result, to_def.symbol))
})
.take(5)
.collect()
}
fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult> {
let rates = currency::get_rates()?;
let from_code = currency::resolve_currency_code(from)?;
let to_code = currency::resolve_currency_code(to)?;
let from_rate = if from_code == "EUR" { 1.0 } else { *rates.rates.get(from_code)? };
let to_rate = if to_code == "EUR" { 1.0 } else { *rates.rates.get(to_code)? };
let result = value / from_rate * to_rate;
Some(format_currency_result(result, to_code))
}
fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
let rates = match currency::get_rates() {
Some(r) => r,
None => return vec![],
};
let from_code = match currency::resolve_currency_code(from) {
Some(c) => c,
None => return vec![],
};
let targets = COMMON_TARGETS.get(&Category::Currency).unwrap();
let from_rate = if from_code == "EUR" {
1.0
} else {
match rates.rates.get(from_code) {
Some(&r) => r,
None => return vec![],
}
};
targets
.iter()
.filter(|&&sym| sym != from_code)
.filter_map(|&sym| {
let to_rate = if sym == "EUR" { 1.0 } else { *rates.rates.get(sym)? };
let result = value / from_rate * to_rate;
Some(format_currency_result(result, sym))
})
.take(5)
.collect()
}
fn format_result(value: f64, symbol: &str) -> ConversionResult {
let raw = if value.fract() == 0.0 && value.abs() < 1e15 {
format!("{}", value as i64)
} else {
format!("{:.4}", value)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
};
let display = if value.abs() >= 1000.0 && value.fract() == 0.0 && value.abs() < 1e15 {
super::format_with_separators(value as i64)
} else {
raw.clone()
};
ConversionResult {
value,
raw_value: raw,
display_value: format!("{} {}", display, symbol),
target_symbol: symbol.to_string(),
}
}
fn format_currency_result(value: f64, code: &str) -> ConversionResult {
let raw = format!("{:.2}", value);
let display = raw.clone();
ConversionResult {
value,
raw_value: raw,
display_value: format!("{} {}", display, code),
target_symbol: code.to_string(),
}
}
fn build_unit_table() -> Vec<UnitDef> {
vec![
// Temperature (base: Kelvin)
UnitDef {
_id: "celsius",
symbol: "°C",
aliases: &["c", "°c", "celsius", "degc", "centigrade"],
category: Category::Temperature,
conversion: Conversion::Custom {
to_base: |v| v + 273.15,
from_base: |v| v - 273.15,
},
},
UnitDef {
_id: "fahrenheit",
symbol: "°F",
aliases: &["f", "°f", "fahrenheit", "degf"],
category: Category::Temperature,
conversion: Conversion::Custom {
to_base: |v| (v - 32.0) * 5.0 / 9.0 + 273.15,
from_base: |v| (v - 273.15) * 9.0 / 5.0 + 32.0,
},
},
UnitDef {
_id: "kelvin",
symbol: "K",
aliases: &["k", "kelvin"],
category: Category::Temperature,
conversion: Conversion::Factor(1.0), // base
},
// Length (base: meter)
UnitDef {
_id: "millimeter",
symbol: "mm",
aliases: &["mm", "millimeter", "millimeters", "millimetre"],
category: Category::Length,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "centimeter",
symbol: "cm",
aliases: &["cm", "centimeter", "centimeters", "centimetre"],
category: Category::Length,
conversion: Conversion::Factor(0.01),
},
UnitDef {
_id: "meter",
symbol: "m",
aliases: &["m", "meter", "meters", "metre", "metres"],
category: Category::Length,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilometer",
symbol: "km",
aliases: &["km", "kms", "kilometer", "kilometers", "kilometre"],
category: Category::Length,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "inch",
symbol: "in",
aliases: &["in", "inch", "inches"],
category: Category::Length,
conversion: Conversion::Factor(0.0254),
},
UnitDef {
_id: "foot",
symbol: "ft",
aliases: &["ft", "foot", "feet"],
category: Category::Length,
conversion: Conversion::Factor(0.3048),
},
UnitDef {
_id: "yard",
symbol: "yd",
aliases: &["yd", "yard", "yards"],
category: Category::Length,
conversion: Conversion::Factor(0.9144),
},
UnitDef {
_id: "mile",
symbol: "mi",
aliases: &["mi", "mile", "miles"],
category: Category::Length,
conversion: Conversion::Factor(1609.344),
},
UnitDef {
_id: "nautical_mile",
symbol: "nmi",
aliases: &["nmi", "nautical_mile", "nautical_miles"],
category: Category::Length,
conversion: Conversion::Factor(1852.0),
},
// Weight (base: kg)
UnitDef {
_id: "milligram",
symbol: "mg",
aliases: &["mg", "milligram", "milligrams"],
category: Category::Weight,
conversion: Conversion::Factor(0.000001),
},
UnitDef {
_id: "gram",
symbol: "g",
aliases: &["g", "gram", "grams"],
category: Category::Weight,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "kilogram",
symbol: "kg",
aliases: &["kg", "kilogram", "kilograms", "kilo", "kilos"],
category: Category::Weight,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "tonne",
symbol: "t",
aliases: &["t", "ton", "tons", "tonne", "tonnes", "metric_ton"],
category: Category::Weight,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "short_ton",
symbol: "short_ton",
aliases: &["short_ton", "ton_us"],
category: Category::Weight,
conversion: Conversion::Factor(907.185),
},
UnitDef {
_id: "ounce",
symbol: "oz",
aliases: &["oz", "ounce", "ounces"],
category: Category::Weight,
conversion: Conversion::Factor(0.0283495),
},
UnitDef {
_id: "pound",
symbol: "lb",
aliases: &["lb", "lbs", "pound", "pounds"],
category: Category::Weight,
conversion: Conversion::Factor(0.453592),
},
UnitDef {
_id: "stone",
symbol: "st",
aliases: &["st", "stone", "stones"],
category: Category::Weight,
conversion: Conversion::Factor(6.35029),
},
// Volume (base: liter)
UnitDef {
_id: "milliliter",
symbol: "ml",
aliases: &["ml", "milliliter", "milliliters", "millilitre"],
category: Category::Volume,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "liter",
symbol: "l",
aliases: &["l", "liter", "liters", "litre", "litres"],
category: Category::Volume,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "us_gallon",
symbol: "gal",
aliases: &["gal", "gallon", "gallons"],
category: Category::Volume,
conversion: Conversion::Factor(3.78541),
},
UnitDef {
_id: "imp_gallon",
symbol: "imp gal",
aliases: &["imp_gal", "gal_uk", "imperial_gallon"],
category: Category::Volume,
conversion: Conversion::Factor(4.54609),
},
UnitDef {
_id: "quart",
symbol: "qt",
aliases: &["qt", "quart", "quarts"],
category: Category::Volume,
conversion: Conversion::Factor(0.946353),
},
UnitDef {
_id: "pint",
symbol: "pt",
aliases: &["pt", "pint", "pints"],
category: Category::Volume,
conversion: Conversion::Factor(0.473176),
},
UnitDef {
_id: "cup",
symbol: "cup",
aliases: &["cup", "cups"],
category: Category::Volume,
conversion: Conversion::Factor(0.236588),
},
UnitDef {
_id: "fluid_ounce",
symbol: "fl oz",
aliases: &["floz", "fl_oz", "fluid_ounce", "fluid_ounces"],
category: Category::Volume,
conversion: Conversion::Factor(0.0295735),
},
UnitDef {
_id: "tablespoon",
symbol: "tbsp",
aliases: &["tbsp", "tablespoon", "tablespoons"],
category: Category::Volume,
conversion: Conversion::Factor(0.0147868),
},
UnitDef {
_id: "teaspoon",
symbol: "tsp",
aliases: &["tsp", "teaspoon", "teaspoons"],
category: Category::Volume,
conversion: Conversion::Factor(0.00492892),
},
// Speed (base: m/s)
UnitDef {
_id: "mps",
symbol: "m/s",
aliases: &["m/s", "mps", "meters_per_second"],
category: Category::Speed,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kmh",
symbol: "km/h",
aliases: &["km/h", "kmh", "kph", "kilometers_per_hour"],
category: Category::Speed,
conversion: Conversion::Factor(0.277778),
},
UnitDef {
_id: "mph",
symbol: "mph",
aliases: &["mph", "miles_per_hour"],
category: Category::Speed,
conversion: Conversion::Factor(0.44704),
},
UnitDef {
_id: "knot",
symbol: "kn",
aliases: &["kn", "kt", "knot", "knots"],
category: Category::Speed,
conversion: Conversion::Factor(0.514444),
},
UnitDef {
_id: "fps",
symbol: "ft/s",
aliases: &["ft/s", "fps", "feet_per_second"],
category: Category::Speed,
conversion: Conversion::Factor(0.3048),
},
// Area (base: m²)
UnitDef {
_id: "sqmm",
symbol: "mm²",
aliases: &["mm2", "sqmm", "square_millimeter"],
category: Category::Area,
conversion: Conversion::Factor(0.000001),
},
UnitDef {
_id: "sqcm",
symbol: "cm²",
aliases: &["cm2", "sqcm", "square_centimeter"],
category: Category::Area,
conversion: Conversion::Factor(0.0001),
},
UnitDef {
_id: "sqm",
symbol: "",
aliases: &["m2", "sqm", "square_meter", "square_meters"],
category: Category::Area,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "sqkm",
symbol: "km²",
aliases: &["km2", "sqkm", "square_kilometer"],
category: Category::Area,
conversion: Conversion::Factor(1000000.0),
},
UnitDef {
_id: "sqft",
symbol: "ft²",
aliases: &["ft2", "sqft", "square_foot", "square_feet"],
category: Category::Area,
conversion: Conversion::Factor(0.092903),
},
UnitDef {
_id: "acre",
symbol: "ac",
aliases: &["ac", "acre", "acres"],
category: Category::Area,
conversion: Conversion::Factor(4046.86),
},
UnitDef {
_id: "hectare",
symbol: "ha",
aliases: &["ha", "hectare", "hectares"],
category: Category::Area,
conversion: Conversion::Factor(10000.0),
},
// Data (base: byte)
UnitDef {
_id: "byte",
symbol: "B",
aliases: &["b", "byte", "bytes"],
category: Category::Data,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilobyte",
symbol: "KB",
aliases: &["kb", "kilobyte", "kilobytes"],
category: Category::Data,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "megabyte",
symbol: "MB",
aliases: &["mb", "megabyte", "megabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000.0),
},
UnitDef {
_id: "gigabyte",
symbol: "GB",
aliases: &["gb", "gigabyte", "gigabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000_000.0),
},
UnitDef {
_id: "terabyte",
symbol: "TB",
aliases: &["tb", "terabyte", "terabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000_000_000.0),
},
UnitDef {
_id: "kibibyte",
symbol: "KiB",
aliases: &["kib", "kibibyte", "kibibytes"],
category: Category::Data,
conversion: Conversion::Factor(1024.0),
},
UnitDef {
_id: "mebibyte",
symbol: "MiB",
aliases: &["mib", "mebibyte", "mebibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_048_576.0),
},
UnitDef {
_id: "gibibyte",
symbol: "GiB",
aliases: &["gib", "gibibyte", "gibibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_073_741_824.0),
},
UnitDef {
_id: "tebibyte",
symbol: "TiB",
aliases: &["tib", "tebibyte", "tebibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_099_511_627_776.0),
},
// Time (base: second)
UnitDef {
_id: "second",
symbol: "s",
aliases: &["s", "sec", "second", "seconds"],
category: Category::Time,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "minute",
symbol: "min",
aliases: &["min", "minute", "minutes"],
category: Category::Time,
conversion: Conversion::Factor(60.0),
},
UnitDef {
_id: "hour",
symbol: "h",
aliases: &["h", "hr", "hour", "hours"],
category: Category::Time,
conversion: Conversion::Factor(3600.0),
},
UnitDef {
_id: "day",
symbol: "d",
aliases: &["d", "day", "days"],
category: Category::Time,
conversion: Conversion::Factor(86400.0),
},
UnitDef {
_id: "week",
symbol: "wk",
aliases: &["wk", "week", "weeks"],
category: Category::Time,
conversion: Conversion::Factor(604800.0),
},
UnitDef {
_id: "month",
symbol: "mo",
aliases: &["mo", "month", "months"],
category: Category::Time,
conversion: Conversion::Factor(2_592_000.0),
},
UnitDef {
_id: "year",
symbol: "yr",
aliases: &["yr", "year", "years"],
category: Category::Time,
conversion: Conversion::Factor(31_536_000.0),
},
// Pressure (base: Pa)
UnitDef {
_id: "pascal",
symbol: "Pa",
aliases: &["pa", "pascal", "pascals"],
category: Category::Pressure,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "hectopascal",
symbol: "hPa",
aliases: &["hpa", "hectopascal"],
category: Category::Pressure,
conversion: Conversion::Factor(100.0),
},
UnitDef {
_id: "kilopascal",
symbol: "kPa",
aliases: &["kpa", "kilopascal"],
category: Category::Pressure,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "bar",
symbol: "bar",
aliases: &["bar", "bars"],
category: Category::Pressure,
conversion: Conversion::Factor(100_000.0),
},
UnitDef {
_id: "millibar",
symbol: "mbar",
aliases: &["mbar", "millibar"],
category: Category::Pressure,
conversion: Conversion::Factor(100.0),
},
UnitDef {
_id: "psi",
symbol: "psi",
aliases: &["psi", "pounds_per_square_inch"],
category: Category::Pressure,
conversion: Conversion::Factor(6894.76),
},
UnitDef {
_id: "atmosphere",
symbol: "atm",
aliases: &["atm", "atmosphere", "atmospheres"],
category: Category::Pressure,
conversion: Conversion::Factor(101_325.0),
},
UnitDef {
_id: "mmhg",
symbol: "mmHg",
aliases: &["mmhg", "torr"],
category: Category::Pressure,
conversion: Conversion::Factor(133.322),
},
// Energy (base: Joule)
UnitDef {
_id: "joule",
symbol: "J",
aliases: &["j", "joule", "joules"],
category: Category::Energy,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilojoule",
symbol: "kJ",
aliases: &["kj", "kilojoule", "kilojoules"],
category: Category::Energy,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "calorie",
symbol: "cal",
aliases: &["cal", "calorie", "calories"],
category: Category::Energy,
conversion: Conversion::Factor(4.184),
},
UnitDef {
_id: "kilocalorie",
symbol: "kcal",
aliases: &["kcal", "kilocalorie", "kilocalories"],
category: Category::Energy,
conversion: Conversion::Factor(4184.0),
},
UnitDef {
_id: "watt_hour",
symbol: "Wh",
aliases: &["wh", "watt_hour"],
category: Category::Energy,
conversion: Conversion::Factor(3600.0),
},
UnitDef {
_id: "kilowatt_hour",
symbol: "kWh",
aliases: &["kwh", "kilowatt_hour"],
category: Category::Energy,
conversion: Conversion::Factor(3_600_000.0),
},
UnitDef {
_id: "btu",
symbol: "BTU",
aliases: &["btu", "british_thermal_unit"],
category: Category::Energy,
conversion: Conversion::Factor(1055.06),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_celsius_to_fahrenheit() {
let r = convert_to(&100.0, "c", "f").unwrap();
assert!((r.value - 212.0).abs() < 0.01);
}
#[test]
fn test_fahrenheit_to_celsius() {
let r = convert_to(&32.0, "f", "c").unwrap();
assert!((r.value - 0.0).abs() < 0.01);
}
#[test]
fn test_body_temp_f_to_c() {
let r = convert_to(&98.6, "f", "c").unwrap();
assert!((r.value - 37.0).abs() < 0.01);
}
#[test]
fn test_km_to_miles() {
let r = convert_to(&100.0, "km", "mi").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_miles_to_km() {
let r = convert_to(&1.0, "mi", "km").unwrap();
assert!((r.value - 1.60934).abs() < 0.01);
}
#[test]
fn test_kg_to_lb() {
let r = convert_to(&1.0, "kg", "lb").unwrap();
assert!((r.value - 2.20462).abs() < 0.01);
}
#[test]
fn test_lb_to_kg() {
let r = convert_to(&100.0, "lbs", "kg").unwrap();
assert!((r.value - 45.3592).abs() < 0.01);
}
#[test]
fn test_liters_to_gallons() {
let r = convert_to(&3.78541, "l", "gal").unwrap();
assert!((r.value - 1.0).abs() < 0.01);
}
#[test]
fn test_kmh_to_mph() {
let r = convert_to(&100.0, "kmh", "mph").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_gb_to_mb() {
let r = convert_to(&1.0, "gb", "mb").unwrap();
assert!((r.value - 1000.0).abs() < 0.01);
}
#[test]
fn test_gib_to_mib() {
let r = convert_to(&1.0, "gib", "mib").unwrap();
assert!((r.value - 1024.0).abs() < 0.01);
}
#[test]
fn test_hours_to_minutes() {
let r = convert_to(&2.5, "h", "min").unwrap();
assert!((r.value - 150.0).abs() < 0.01);
}
#[test]
fn test_bar_to_psi() {
let r = convert_to(&1.0, "bar", "psi").unwrap();
assert!((r.value - 14.5038).abs() < 0.01);
}
#[test]
fn test_kcal_to_kj() {
let r = convert_to(&1.0, "kcal", "kj").unwrap();
assert!((r.value - 4.184).abs() < 0.01);
}
#[test]
fn test_sqm_to_sqft() {
let r = convert_to(&1.0, "m2", "ft2").unwrap();
assert!((r.value - 10.7639).abs() < 0.01);
}
#[test]
fn test_unknown_unit_returns_none() {
assert!(convert_to(&100.0, "xyz", "abc").is_none());
}
#[test]
fn test_cross_category_returns_none() {
assert!(convert_to(&100.0, "km", "kg").is_none());
}
#[test]
fn test_convert_common_returns_results() {
let results = convert_common(&100.0, "km");
assert!(!results.is_empty());
assert!(results.len() <= 5);
}
#[test]
fn test_convert_common_excludes_source() {
let results = convert_common(&100.0, "km");
for r in &results {
assert_ne!(r.target_symbol, "km");
}
}
#[test]
fn test_alias_case_insensitive() {
let r1 = convert_to(&100.0, "KM", "MI").unwrap();
let r2 = convert_to(&100.0, "km", "mi").unwrap();
assert!((r1.value - r2.value).abs() < 0.001);
}
#[test]
fn test_full_name_alias() {
let r = convert_to(&100.0, "kilometers", "miles").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_format_currency_two_decimals() {
let r = convert_to(&1.0, "km", "mi").unwrap();
// display_value should have reasonable formatting
assert!(!r.display_value.is_empty());
}
#[test]
fn test_currency_alias_convert_to() {
// "dollar" and "euro" are aliases, not in the UNITS table
let r = convert_to(&20.0, "dollar", "euro");
// May return None if ECB rates unavailable (network), but should not panic
// In a network-available environment, this should return Some
if let Some(r) = r {
assert!(r.value > 0.0);
assert_eq!(r.target_symbol, "EUR");
}
}
#[test]
fn test_currency_alias_convert_common() {
let results = convert_common(&20.0, "dollar");
// May be empty if ECB rates unavailable, but should not panic
for r in &results {
assert!(r.value > 0.0);
}
}
#[test]
fn test_display_value_no_double_unit() {
let r = convert_to(&100.0, "km", "mi").unwrap();
// display_value should contain the symbol exactly once
let count = r.display_value.matches(&r.target_symbol).count();
assert_eq!(count, 1, "display_value '{}' should contain '{}' exactly once", r.display_value, r.target_symbol);
}
}

View File

@@ -89,10 +89,12 @@ impl Provider for LuaProvider {
} }
} }
// LuaProvider needs to be Send for the Provider trait // LuaProvider needs to be Send + Sync for the Provider trait.
// Since we're using Rc<RefCell<>>, we need to be careful about thread safety // Rc<RefCell<>> is !Send and !Sync, but the ProviderManager RwLock ensures
// For now, owlry is single-threaded, so this is safe // Rc<RefCell<>> is only accessed during refresh() (write lock = exclusive access).
// Read-only operations (items(), search) only touch self.items (Vec<LaunchItem>).
unsafe impl Send for LuaProvider {} unsafe impl Send for LuaProvider {}
unsafe impl Sync for LuaProvider {}
/// Create LuaProviders from all registered providers in a plugin /// Create LuaProviders from all registered providers in a plugin
pub fn create_providers_from_plugin(plugin: Rc<RefCell<LoadedPlugin>>) -> Vec<Box<dyn Provider>> { pub fn create_providers_from_plugin(plugin: Rc<RefCell<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {

View File

@@ -1,6 +1,10 @@
// Core providers (no plugin equivalents) // Core providers (no plugin equivalents)
mod application; mod application;
mod command; mod command;
pub(crate) mod calculator;
pub(crate) mod config_editor;
pub(crate) mod converter;
pub(crate) mod system;
// Native plugin bridge // Native plugin bridge
pub mod native_provider; pub mod native_provider;
@@ -16,6 +20,7 @@ pub use command::CommandProvider;
// Re-export native provider for plugin loading // Re-export native provider for plugin loading
pub use native_provider::NativeProvider; pub use native_provider::NativeProvider;
use chrono::Utc;
use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::skim::SkimMatcherV2;
use log::info; use log::info;
@@ -25,6 +30,7 @@ use log::debug;
use crate::config::Config; use crate::config::Config;
use crate::data::FrecencyStore; use crate::data::FrecencyStore;
use crate::plugins::runtime_loader::LoadedRuntime;
/// Metadata descriptor for an available provider (used by IPC/daemon API) /// Metadata descriptor for an available provider (used by IPC/daemon API)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -94,7 +100,7 @@ impl std::fmt::Display for ProviderType {
} }
/// Trait for all search providers /// Trait for all search providers
pub trait Provider: Send { pub trait Provider: Send + Sync {
#[allow(dead_code)] #[allow(dead_code)]
fn name(&self) -> &str; fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType; fn provider_type(&self) -> ProviderType;
@@ -102,10 +108,29 @@ pub trait Provider: Send {
fn items(&self) -> &[LaunchItem]; fn items(&self) -> &[LaunchItem];
} }
/// Trait for built-in providers that produce results per-keystroke.
/// Unlike static `Provider`s which cache items via `refresh()`/`items()`,
/// dynamic providers generate results on every query.
pub(crate) trait DynamicProvider: Send + Sync {
#[allow(dead_code)]
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32;
/// Handle a plugin action command. Returns true if handled.
fn execute_action(&self, _command: &str) -> bool {
false
}
}
/// Manages all providers and handles searching /// Manages all providers and handles searching
pub struct ProviderManager { pub struct ProviderManager {
/// Core static providers (apps, commands, dmenu) /// Core static providers (apps, commands, dmenu)
providers: Vec<Box<dyn Provider>>, providers: Vec<Box<dyn Provider>>,
/// Built-in dynamic providers (calculator, converter)
/// These are queried per-keystroke, like native dynamic plugins
builtin_dynamic: Vec<Box<dyn DynamicProvider>>,
/// Static native plugin providers (need query() for submenu support) /// Static native plugin providers (need query() for submenu support)
static_native_providers: Vec<NativeProvider>, static_native_providers: Vec<NativeProvider>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch) /// Dynamic providers from native plugins (calculator, websearch, filesearch)
@@ -116,6 +141,10 @@ pub struct ProviderManager {
widget_providers: Vec<NativeProvider>, widget_providers: Vec<NativeProvider>,
/// Fuzzy matcher for search /// Fuzzy matcher for search
matcher: SkimMatcherV2, matcher: SkimMatcherV2,
/// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles
runtimes: Vec<LoadedRuntime>,
/// Type IDs of providers from script runtimes (for hot-reload removal)
runtime_type_ids: std::collections::HashSet<String>,
} }
impl ProviderManager { impl ProviderManager {
@@ -130,10 +159,13 @@ impl ProviderManager {
) -> Self { ) -> Self {
let mut manager = Self { let mut manager = Self {
providers: core_providers, providers: core_providers,
builtin_dynamic: Vec::new(),
static_native_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(),
runtimes: Vec::new(),
runtime_type_ids: std::collections::HashSet::new(),
}; };
// Categorize native plugins based on their declared ProviderKind and ProviderPosition // Categorize native plugins based on their declared ProviderKind and ProviderPosition
@@ -180,7 +212,7 @@ impl ProviderManager {
use std::sync::Arc; use std::sync::Arc;
// Create core providers // Create core providers
let core_providers: Vec<Box<dyn Provider>> = vec![ let mut core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()), Box::new(ApplicationProvider::new()),
Box::new(CommandProvider::new()), Box::new(CommandProvider::new()),
]; ];
@@ -220,7 +252,183 @@ impl ProviderManager {
} }
}; };
Self::new(core_providers, native_providers) // Load script runtimes (Lua, Rune) for user plugins
let mut runtime_providers: Vec<Box<dyn Provider>> = Vec::new();
let mut runtimes: Vec<LoadedRuntime> = Vec::new();
let mut runtime_type_ids = std::collections::HashSet::new();
let owlry_version = env!("CARGO_PKG_VERSION");
let skip_runtimes = std::env::var("OWLRY_SKIP_RUNTIMES").is_ok();
if !skip_runtimes
&& let Some(plugins_dir) = crate::paths::plugins_dir()
{
// Try Lua runtime
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Loaded Lua runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
runtime_type_ids.insert(type_id);
runtime_providers.push(provider);
}
runtimes.push(rt);
}
Err(e) => {
info!("Lua runtime not available: {}", e);
}
}
// Try Rune runtime
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Loaded Rune runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
runtime_type_ids.insert(type_id);
runtime_providers.push(provider);
}
runtimes.push(rt);
}
Err(e) => {
info!("Rune runtime not available: {}", e);
}
}
} // skip_runtimes
// Merge runtime providers into core providers
for provider in runtime_providers {
info!("Registered runtime provider: {}", provider.name());
core_providers.push(provider);
}
// Built-in dynamic providers
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
if config.providers.calculator {
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
info!("Registered built-in calculator provider");
}
if config.providers.converter {
builtin_dynamic.push(Box::new(converter::ConverterProvider::new()));
info!("Registered built-in converter provider");
}
// Config editor — always enabled
let config_arc = std::sync::Arc::new(std::sync::RwLock::new(config.clone()));
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(config_arc)));
info!("Registered built-in config editor provider");
// Built-in static providers
if config.providers.system {
core_providers.push(Box::new(system::SystemProvider::new()));
info!("Registered built-in system provider");
}
// Compute built-in type IDs to detect conflicts with native plugins.
// A native plugin whose type_id matches a built-in provider would
// produce duplicate results, so we skip it.
let builtin_ids: std::collections::HashSet<String> = {
let mut ids = std::collections::HashSet::new();
// Dynamic built-ins (calculator, converter)
for p in &builtin_dynamic {
if let ProviderType::Plugin(id) = p.provider_type() {
ids.insert(id);
}
}
// Static built-ins added to core_providers (e.g. system)
for p in &core_providers {
if let ProviderType::Plugin(id) = p.provider_type() {
ids.insert(id);
}
}
ids
};
let native_providers: Vec<NativeProvider> = native_providers
.into_iter()
.filter(|provider| {
let type_id = provider.type_id();
if builtin_ids.contains(type_id) {
info!(
"Skipping native plugin '{}' — built-in provider exists",
type_id
);
false
} else {
true
}
})
.collect();
let mut manager = Self::new(core_providers, native_providers);
manager.builtin_dynamic = builtin_dynamic;
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
manager
}
/// Reload all script runtime providers (called by filesystem watcher)
pub fn reload_runtimes(&mut self) {
use crate::plugins::runtime_loader::LoadedRuntime;
// Remove old runtime providers from the core providers list
self.providers.retain(|p| {
let type_str = format!("{}", p.provider_type());
!self.runtime_type_ids.contains(&type_str)
});
// Drop old runtimes (catch panics from runtime cleanup)
let old_runtimes = std::mem::take(&mut self.runtimes);
drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
drop(old_runtimes);
})));
self.runtime_type_ids.clear();
let owlry_version = env!("CARGO_PKG_VERSION");
let plugins_dir = match crate::paths::plugins_dir() {
Some(d) => d,
None => return,
};
// Reload Lua runtime
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
self.runtime_type_ids.insert(type_id);
self.providers.push(provider);
}
self.runtimes.push(rt);
}
Err(e) => {
info!("Lua runtime not available on reload: {}", e);
}
}
// Reload Rune runtime
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Reloaded Rune runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
self.runtime_type_ids.insert(type_id);
self.providers.push(provider);
}
self.runtimes.push(rt);
}
Err(e) => {
info!("Rune runtime not available on reload: {}", e);
}
}
// Refresh the newly added providers
for provider in &mut self.providers {
provider.refresh();
}
info!("Runtime reload complete");
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -313,6 +521,14 @@ impl ProviderManager {
return true; return true;
} }
} }
// Check built-in dynamic providers
for provider in &self.builtin_dynamic {
if provider.execute_action(command) {
return true;
}
}
false false
} }
@@ -448,6 +664,7 @@ impl ProviderManager {
query, max_results, frecency_weight query, max_results, frecency_weight
); );
let now = Utc::now();
let mut results: Vec<(LaunchItem, i64)> = Vec::new(); let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Add widget items first (highest priority) - only when: // Add widget items first (highest priority) - only when:
@@ -478,8 +695,42 @@ impl ProviderManager {
let dynamic_results = provider.query(query); let dynamic_results = provider.query(query);
// Priority comes from plugin-declared priority field // Priority comes from plugin-declared priority field
let base_score = provider.priority() as i64; let base_score = provider.priority() as i64;
// Auto-detect plugins (calc, conv) get a grouping bonus so
// all their results stay together above generic search results
let grouping_bonus: i64 = match provider.provider_type() {
ProviderType::Plugin(ref id)
if matches!(id.as_str(), "calc" | "conv") =>
{
10_000
}
_ => 0,
};
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 + grouping_bonus - idx as i64));
}
}
// Built-in dynamic providers (calculator, converter)
for provider in &self.builtin_dynamic {
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query);
let base_score = provider.priority() as i64;
let grouping_bonus: i64 = match provider.provider_type() {
ProviderType::Plugin(ref id)
if matches!(id.as_str(), "calc" | "conv") =>
{
10_000
}
_ => 0,
};
for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score + grouping_bonus - idx as i64));
} }
} }
} }
@@ -511,7 +762,7 @@ impl ProviderManager {
} }
}) })
.map(|item| { .map(|item| {
let frecency_score = frecency.get_score(&item.id); let frecency_score = frecency.get_score_at(&item.id, now);
let boosted = (frecency_score * frecency_weight * 100.0) as i64; let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted) (item, boosted)
}) })
@@ -560,9 +811,20 @@ impl ProviderManager {
}; };
base_score.map(|s| { base_score.map(|s| {
let frecency_score = frecency.get_score(&item.id); let frecency_score = frecency.get_score_at(&item.id, now);
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)
// Exact name match bonus — apps get a higher boost
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
match &item.provider {
ProviderType::Application => 50_000,
_ => 30_000,
}
} else {
0
};
(item.clone(), s + frecency_boost + exact_match_boost)
}) })
}; };
@@ -954,4 +1216,5 @@ mod tests {
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].0.name, "Firefox"); assert_eq!(results[0].0.name, "Firefox");
} }
} }

View File

@@ -0,0 +1,148 @@
use super::{LaunchItem, Provider, ProviderType};
/// Built-in system provider. Returns a fixed set of power and session management actions.
///
/// This is a static provider — items are populated in `new()` and `refresh()` is a no-op.
pub(crate) struct SystemProvider {
items: Vec<LaunchItem>,
}
impl SystemProvider {
pub fn new() -> Self {
let commands: &[(&str, &str, &str, &str, &str)] = &[
(
"shutdown",
"Shutdown",
"Power off the system",
"system-shutdown",
"systemctl poweroff",
),
(
"reboot",
"Reboot",
"Restart the system",
"system-reboot",
"systemctl reboot",
),
(
"reboot-bios",
"Reboot to BIOS",
"Restart into UEFI/BIOS setup",
"system-reboot",
"systemctl reboot --firmware-setup",
),
(
"suspend",
"Suspend",
"Suspend to RAM",
"system-suspend",
"systemctl suspend",
),
(
"hibernate",
"Hibernate",
"Suspend to disk",
"system-suspend-hibernate",
"systemctl hibernate",
),
(
"lock",
"Lock Screen",
"Lock the session",
"system-lock-screen",
"loginctl lock-session",
),
(
"logout",
"Log Out",
"End the current session",
"system-log-out",
"loginctl terminate-session self",
),
];
let items = commands
.iter()
.map(|(action_id, name, description, icon, command)| LaunchItem {
id: format!("sys:{}", action_id),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin("sys".into()),
command: command.to_string(),
terminal: false,
tags: vec!["system".into()],
})
.collect();
Self { items }
}
}
impl Provider for SystemProvider {
fn name(&self) -> &str {
"System"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("sys".into())
}
fn refresh(&mut self) {
// Static provider — no-op
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_seven_actions() {
let provider = SystemProvider::new();
assert_eq!(provider.items().len(), 7);
}
#[test]
fn contains_expected_action_names() {
let provider = SystemProvider::new();
let names: Vec<&str> = provider.items().iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Shutdown"));
assert!(names.contains(&"Reboot"));
assert!(names.contains(&"Lock Screen"));
assert!(names.contains(&"Log Out"));
}
#[test]
fn provider_type_is_sys_plugin() {
let provider = SystemProvider::new();
assert_eq!(provider.provider_type(), ProviderType::Plugin("sys".into()));
}
#[test]
fn shutdown_command_is_correct() {
let provider = SystemProvider::new();
let shutdown = provider
.items()
.iter()
.find(|i| i.name == "Shutdown")
.expect("Shutdown item must exist");
assert_eq!(shutdown.command, "systemctl poweroff");
}
#[test]
fn all_items_have_system_tag() {
let provider = SystemProvider::new();
for item in provider.items() {
assert!(
item.tags.contains(&"system".to_string()),
"item '{}' is missing 'system' tag",
item.name
);
}
}
}

View File

@@ -1,9 +1,14 @@
use std::io::{self, BufRead, BufReader, Write}; use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, RwLock};
use std::time::Duration;
use std::thread; use std::thread;
/// Maximum allowed size for a single IPC request line (1 MiB).
const MAX_REQUEST_SIZE: usize = 1_048_576;
use log::{error, info, warn}; use log::{error, info, warn};
use crate::config::Config; use crate::config::Config;
@@ -17,8 +22,8 @@ use crate::providers::{LaunchItem, ProviderManager};
pub struct Server { pub struct Server {
listener: UnixListener, listener: UnixListener,
socket_path: PathBuf, socket_path: PathBuf,
provider_manager: Arc<Mutex<ProviderManager>>, provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>, frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>, config: Arc<Config>,
} }
@@ -34,6 +39,7 @@ impl Server {
} }
let listener = UnixListener::bind(socket_path)?; let listener = UnixListener::bind(socket_path)?;
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
info!("IPC server listening on {:?}", socket_path); info!("IPC server listening on {:?}", socket_path);
let config = Config::load_or_default(); let config = Config::load_or_default();
@@ -43,14 +49,17 @@ impl Server {
Ok(Self { Ok(Self {
listener, listener,
socket_path: socket_path.to_path_buf(), socket_path: socket_path.to_path_buf(),
provider_manager: Arc::new(Mutex::new(provider_manager)), provider_manager: Arc::new(RwLock::new(provider_manager)),
frecency: Arc::new(Mutex::new(frecency)), frecency: Arc::new(RwLock::new(frecency)),
config: Arc::new(config), config: Arc::new(config),
}) })
} }
/// Accept connections in a loop, spawning a thread per client. /// Accept connections in a loop, spawning a thread per client.
pub fn run(&self) -> io::Result<()> { pub fn run(&self) -> io::Result<()> {
// Start filesystem watcher for user plugin hot-reload
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
info!("Server entering accept loop"); info!("Server entering accept loop");
for stream in self.listener.incoming() { for stream in self.listener.incoming() {
match stream { match stream {
@@ -90,15 +99,33 @@ impl Server {
/// dispatch each, and write the JSON response back. /// dispatch each, and write the JSON response back.
fn handle_client( fn handle_client(
stream: UnixStream, stream: UnixStream,
pm: Arc<Mutex<ProviderManager>>, pm: Arc<RwLock<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>, frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>, config: Arc<Config>,
) -> io::Result<()> { ) -> io::Result<()> {
let reader = BufReader::new(stream.try_clone()?); stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream; let mut writer = stream;
for line in reader.lines() { loop {
let line = line?; let mut line = String::new();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break;
}
if line.len() > MAX_REQUEST_SIZE {
let resp = Response::Error {
message: format!(
"request too large ({} bytes, max {})",
line.len(),
MAX_REQUEST_SIZE
),
};
write_response(&mut writer, &resp)?;
break;
}
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
continue; continue;
@@ -107,6 +134,7 @@ impl Server {
let request: Request = match serde_json::from_str(trimmed) { let request: Request = match serde_json::from_str(trimmed) {
Ok(req) => req, Ok(req) => req,
Err(e) => { Err(e) => {
warn!("Malformed request from client: {}", e);
let resp = Response::Error { let resp = Response::Error {
message: format!("invalid request JSON: {}", e), message: format!("invalid request JSON: {}", e),
}; };
@@ -126,8 +154,8 @@ impl Server {
/// the response. /// the response.
fn handle_request( fn handle_request(
request: &Request, request: &Request,
pm: &Arc<Mutex<ProviderManager>>, pm: &Arc<RwLock<ProviderManager>>,
frecency: &Arc<Mutex<FrecencyStore>>, frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>, config: &Arc<Config>,
) -> Response { ) -> Response {
match request { match request {
@@ -139,8 +167,8 @@ impl Server {
let max = config.general.max_results; let max = config.general.max_results;
let weight = config.providers.frecency_weight; let weight = config.providers.frecency_weight;
let pm_guard = pm.lock().unwrap(); let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let frecency_guard = frecency.lock().unwrap(); let frecency_guard = frecency.read().unwrap_or_else(|e| e.into_inner());
let results = pm_guard.search_with_frecency( let results = pm_guard.search_with_frecency(
text, text,
max, max,
@@ -162,13 +190,13 @@ impl Server {
item_id, item_id,
provider: _, provider: _,
} => { } => {
let mut frecency_guard = frecency.lock().unwrap(); let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
frecency_guard.record_launch(item_id); frecency_guard.record_launch(item_id);
Response::Ack Response::Ack
} }
Request::Providers => { Request::Providers => {
let pm_guard = pm.lock().unwrap(); let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let descs = pm_guard.available_providers(); let descs = pm_guard.available_providers();
Response::Providers { Response::Providers {
list: descs.into_iter().map(descriptor_to_desc).collect(), list: descs.into_iter().map(descriptor_to_desc).collect(),
@@ -176,7 +204,7 @@ impl Server {
} }
Request::Refresh { provider } => { Request::Refresh { provider } => {
let mut pm_guard = pm.lock().unwrap(); let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.refresh_provider(provider); pm_guard.refresh_provider(provider);
Response::Ack Response::Ack
} }
@@ -187,7 +215,7 @@ impl Server {
} }
Request::Submenu { plugin_id, data } => { Request::Submenu { plugin_id, data } => {
let pm_guard = pm.lock().unwrap(); let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) { match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
Some((_name, actions)) => Response::SubmenuItems { Some((_name, actions)) => Response::SubmenuItems {
items: actions items: actions
@@ -202,7 +230,7 @@ impl Server {
} }
Request::PluginAction { command } => { Request::PluginAction { command } => {
let pm_guard = pm.lock().unwrap(); let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
if pm_guard.execute_plugin_action(command) { if pm_guard.execute_plugin_action(command) {
Response::Ack Response::Ack
} else { } else {

View File

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

View File

@@ -22,7 +22,7 @@ pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
} }
/// Implementation of owlry.provider.register() /// Implementation of owlry.provider.register()
fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> { fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> {
let name: String = config.get("name")?; let name: String = config.get("name")?;
let display_name: String = config let display_name: String = config
.get::<Option<String>>("display_name")? .get::<Option<String>>("display_name")?
@@ -47,6 +47,21 @@ fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> {
let is_dynamic = has_query; let is_dynamic = has_query;
// Store the config table in owlry.provider._registrations[name]
// so call_refresh/call_query can find the callback functions later
let globals = lua.globals();
let owlry: Table = globals.get("owlry")?;
let provider: Table = owlry.get("provider")?;
let registrations: Table = match provider.get::<Value>("_registrations")? {
Value::Table(t) => t,
_ => {
let t = lua.create_table()?;
provider.set("_registrations", t.clone())?;
t
}
};
registrations.set(name.as_str(), config)?;
REGISTRATIONS.with(|regs| { REGISTRATIONS.with(|regs| {
regs.borrow_mut().push(ProviderRegistration { regs.borrow_mut().push(ProviderRegistration {
name, name,

View File

@@ -27,28 +27,19 @@ mod manifest;
mod runtime; mod runtime;
use abi_stable::std_types::{ROption, RStr, RString, RVec}; use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{PluginItem, ProviderKind}; use owlry_plugin_api::PluginItem;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use loader::LoadedPlugin; use loader::LoadedPlugin;
// Runtime metadata
const RUNTIME_ID: &str = "lua";
const RUNTIME_NAME: &str = "Lua Runtime";
const RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION");
const RUNTIME_DESCRIPTION: &str = "Lua 5.4 runtime for user plugins";
/// API version for compatibility checking
pub const LUA_RUNTIME_API_VERSION: u32 = 1;
/// Runtime vtable - exported interface for the core to use /// Runtime vtable - exported interface for the core to use
#[repr(C)] #[repr(C)]
pub struct LuaRuntimeVTable { pub struct LuaRuntimeVTable {
/// Get runtime info /// Get runtime info
pub info: extern "C" fn() -> RuntimeInfo, pub info: extern "C" fn() -> RuntimeInfo,
/// Initialize the runtime with plugins directory /// Initialize the runtime with plugins directory
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle, pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
/// Get provider infos from all loaded plugins /// Get provider infos from all loaded plugins
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<LuaProviderInfo>, pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<LuaProviderInfo>,
/// Refresh a provider's items /// Refresh a provider's items
@@ -66,11 +57,8 @@ pub struct LuaRuntimeVTable {
/// Runtime info returned by the runtime /// Runtime info returned by the runtime
#[repr(C)] #[repr(C)]
pub struct RuntimeInfo { pub struct RuntimeInfo {
pub id: RString,
pub name: RString, pub name: RString,
pub version: RString, pub version: RString,
pub description: RString,
pub api_version: u32,
} }
/// Opaque handle to the runtime state /// Opaque handle to the runtime state
@@ -106,24 +94,22 @@ impl RuntimeHandle {
} }
/// Provider info from a Lua plugin /// Provider info from a Lua plugin
///
/// Must match ScriptProviderInfo layout in owlry-core/src/plugins/runtime_loader.rs
#[repr(C)] #[repr(C)]
pub struct LuaProviderInfo { pub struct LuaProviderInfo {
/// Full provider ID: "plugin_id:provider_name" /// Provider name (used as vtable refresh/query key: "plugin_id:provider_name")
pub id: RString, pub name: RString,
/// Plugin ID this provider belongs to
pub plugin_id: RString,
/// Provider name within the plugin
pub provider_name: RString,
/// Display name /// Display name
pub display_name: RString, pub display_name: RString,
/// Optional prefix trigger
pub prefix: ROption<RString>,
/// Icon name
pub icon: RString,
/// Provider type (static/dynamic)
pub provider_type: ProviderKind,
/// Type ID for filtering /// Type ID for filtering
pub type_id: RString, pub type_id: RString,
/// Icon name
pub default_icon: RString,
/// Whether this is a static provider (true) or dynamic (false)
pub is_static: bool,
/// Optional prefix trigger
pub prefix: ROption<RString>,
} }
/// Internal runtime state /// Internal runtime state
@@ -187,21 +173,14 @@ impl LuaRuntimeState {
if let Ok(registrations) = plugin.get_provider_registrations() { if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in registrations { for reg in registrations {
let full_id = format!("{}:{}", plugin_id, reg.name); let full_id = format!("{}:{}", plugin_id, reg.name);
let provider_type = if reg.is_dynamic {
ProviderKind::Dynamic
} else {
ProviderKind::Static
};
providers.push(LuaProviderInfo { providers.push(LuaProviderInfo {
id: RString::from(full_id), name: RString::from(full_id),
plugin_id: RString::from(plugin_id.as_str()),
provider_name: RString::from(reg.name.as_str()),
display_name: RString::from(reg.display_name.as_str()), display_name: RString::from(reg.display_name.as_str()),
prefix: reg.prefix.map(RString::from).into(),
icon: RString::from(reg.default_icon.as_str()),
provider_type,
type_id: RString::from(reg.type_id.as_str()), type_id: RString::from(reg.type_id.as_str()),
default_icon: RString::from(reg.default_icon.as_str()),
is_static: !reg.is_dynamic,
prefix: reg.prefix.map(RString::from).into(),
}); });
} }
} }
@@ -259,22 +238,15 @@ impl LuaRuntimeState {
extern "C" fn runtime_info() -> RuntimeInfo { extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo { RuntimeInfo {
id: RString::from(RUNTIME_ID), name: RString::from("Lua"),
name: RString::from(RUNTIME_NAME), version: RString::from(env!("CARGO_PKG_VERSION")),
version: RString::from(RUNTIME_VERSION),
description: RString::from(RUNTIME_DESCRIPTION),
api_version: LUA_RUNTIME_API_VERSION,
} }
} }
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle { extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let plugins_dir = PathBuf::from(plugins_dir.as_str()); let plugins_dir = PathBuf::from(plugins_dir.as_str());
let mut state = Box::new(LuaRuntimeState::new(plugins_dir)); let mut state = Box::new(LuaRuntimeState::new(plugins_dir));
state.discover_and_load(owlry_version.as_str());
// TODO: Get owlry version from core somehow
// For now, use a reasonable default
state.discover_and_load("0.3.0");
RuntimeHandle::from_box(state) RuntimeHandle::from_box(state)
} }
@@ -346,8 +318,8 @@ mod tests {
#[test] #[test]
fn test_runtime_info() { fn test_runtime_info() {
let info = runtime_info(); let info = runtime_info();
assert_eq!(info.id.as_str(), "lua"); assert_eq!(info.name.as_str(), "Lua");
assert_eq!(info.api_version, LUA_RUNTIME_API_VERSION); assert!(!info.version.as_str().is_empty());
} }
#[test] #[test]

View File

@@ -200,7 +200,7 @@ version = "1.0.0"
id, id id, id
); );
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap(); fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();
} }
#[test] #[test]

View File

@@ -41,7 +41,7 @@ pub struct PluginInfo {
#[serde(default = "default_owlry_version")] #[serde(default = "default_owlry_version")]
pub owlry_version: String, pub owlry_version: String,
/// Entry point file (relative to plugin directory) /// Entry point file (relative to plugin directory)
#[serde(default = "default_entry")] #[serde(default = "default_entry", alias = "entry_point")]
pub entry: String, pub entry: String,
} }
@@ -50,7 +50,7 @@ fn default_owlry_version() -> String {
} }
fn default_entry() -> String { fn default_entry() -> String {
"init.lua".to_string() "main.lua".to_string()
} }
/// What the plugin provides /// What the plugin provides
@@ -160,7 +160,7 @@ version = "1.0.0"
assert_eq!(manifest.plugin.id, "test-plugin"); assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin"); assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0"); assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "init.lua"); assert_eq!(manifest.plugin.entry, "main.lua");
} }
#[test] #[test]

View File

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

View File

@@ -297,26 +297,24 @@ pub struct HostAPI {
pub log_error: extern "C" fn(message: RStr<'_>), pub log_error: extern "C" fn(message: RStr<'_>),
} }
use std::sync::OnceLock;
// Global host API pointer - set by the host when loading plugins // Global host API pointer - set by the host when loading plugins
static mut HOST_API: Option<&'static HostAPI> = None; static HOST_API: OnceLock<&'static HostAPI> = OnceLock::new();
/// Initialize the host API (called by the host) /// Initialize the host API (called by the host)
/// ///
/// # Safety /// # Safety
/// Must only be called once by the host before any plugins use the API /// Must only be called once by the host before any plugins use the API
pub unsafe fn init_host_api(api: &'static HostAPI) { pub unsafe fn init_host_api(api: &'static HostAPI) {
// SAFETY: Caller guarantees this is called once before any plugins use the API let _ = HOST_API.set(api);
unsafe {
HOST_API = Some(api);
}
} }
/// Get the host API /// Get the host API
/// ///
/// Returns None if the host hasn't initialized the API yet /// Returns None if the host hasn't initialized the API yet
pub fn host_api() -> Option<&'static HostAPI> { pub fn host_api() -> Option<&'static HostAPI> {
// SAFETY: We only read the pointer, and it's set once at startup HOST_API.get().copied()
unsafe { HOST_API }
} }
// ============================================================================ // ============================================================================

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-rune" name = "owlry-rune"
version = "1.0.0" version = "1.1.1"
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

@@ -2,7 +2,7 @@
//! //!
//! This module provides the `owlry` module that Rune plugins can use. //! This module provides the `owlry` module that Rune plugins can use.
use rune::{ContextError, Module}; use rune::{Any, ContextError, Module};
use std::sync::Mutex; use std::sync::Mutex;
use owlry_plugin_api::{PluginItem, RString}; use owlry_plugin_api::{PluginItem, RString};
@@ -20,9 +20,9 @@ pub struct ProviderRegistration {
/// An item returned by a provider /// An item returned by a provider
/// ///
/// Used for converting Rune plugin items to FFI format. /// Exposed to Rune scripts as `owlry::Item`.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Any)]
#[allow(dead_code)] #[rune(item = ::owlry)]
pub struct Item { pub struct Item {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -34,8 +34,42 @@ pub struct Item {
} }
impl Item { impl Item {
/// Constructor exposed to Rune via #[rune::function]
#[rune::function(path = Self::new)]
pub fn rune_new(id: String, name: String, command: String) -> Self {
Self {
id,
name,
command,
description: None,
icon: None,
terminal: false,
keywords: Vec::new(),
}
}
/// Set description (builder pattern for Rune)
#[rune::function]
fn description(mut self, desc: String) -> Self {
self.description = Some(desc);
self
}
/// Set icon (builder pattern for Rune)
#[rune::function]
fn icon(mut self, icon: String) -> Self {
self.icon = Some(icon);
self
}
/// Set keywords (builder pattern for Rune)
#[rune::function]
fn keywords(mut self, keywords: Vec<String>) -> Self {
self.keywords = keywords;
self
}
/// Convert to PluginItem for FFI /// Convert to PluginItem for FFI
#[allow(dead_code)]
pub fn to_plugin_item(&self) -> PluginItem { pub fn to_plugin_item(&self) -> PluginItem {
let mut item = PluginItem::new( let mut item = PluginItem::new(
RString::from(self.id.as_str()), RString::from(self.id.as_str()),
@@ -62,7 +96,14 @@ pub static REGISTRATIONS: Mutex<Vec<ProviderRegistration>> = Mutex::new(Vec::new
pub fn module() -> Result<Module, ContextError> { pub fn module() -> Result<Module, ContextError> {
let mut module = Module::with_crate("owlry")?; let mut module = Module::with_crate("owlry")?;
// Register logging functions using builder pattern // Register Item type with constructor and builder methods
module.ty::<Item>()?;
module.function_meta(Item::rune_new)?;
module.function_meta(Item::description)?;
module.function_meta(Item::icon)?;
module.function_meta(Item::keywords)?;
// Register logging functions
module.function("log_info", log_info).build()?; module.function("log_info", log_info).build()?;
module.function("log_debug", log_debug).build()?; module.function("log_debug", log_debug).build()?;
module.function("log_warn", log_warn).build()?; module.function("log_warn", log_warn).build()?;

View File

@@ -72,7 +72,7 @@ struct RuntimeState {
#[repr(C)] #[repr(C)]
pub struct RuneRuntimeVTable { pub struct RuneRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo, pub info: extern "C" fn() -> RuntimeInfo,
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle, pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<RuneProviderInfo>, pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<RuneProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>, pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn( pub query: extern "C" fn(
@@ -94,8 +94,9 @@ extern "C" fn runtime_info() -> RuntimeInfo {
} }
} }
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle { extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let _version = owlry_version.as_str();
let plugins_dir = PathBuf::from(plugins_dir.as_str()); let plugins_dir = PathBuf::from(plugins_dir.as_str());
log::info!( log::info!(
@@ -250,7 +251,7 @@ mod tests {
let plugins_dir = temp.path().to_string_lossy(); let plugins_dir = temp.path().to_string_lossy();
// Initialize runtime // Initialize runtime
let handle = runtime_init(RStr::from_str(&plugins_dir)); let handle = runtime_init(RStr::from_str(&plugins_dir), RStr::from_str("1.0.0"));
assert!(!handle.0.is_null()); assert!(!handle.0.is_null());
// Get providers (should be empty with no plugins) // Get providers (should be empty with no plugins)

View File

@@ -59,8 +59,20 @@ impl LoadedPlugin {
} }
} }
// Collect registrations // Collect registrations — from runtime API or from manifest [[providers]]
let registrations = api::get_registrations(); let mut registrations = api::get_registrations();
if registrations.is_empty() && !manifest.providers.is_empty() {
for decl in &manifest.providers {
registrations.push(ProviderRegistration {
name: decl.id.clone(),
display_name: decl.name.clone(),
type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()),
default_icon: decl.icon.clone().unwrap_or_else(|| "application-x-addon".to_string()),
is_static: decl.provider_type != "dynamic",
prefix: decl.prefix.clone(),
});
}
}
log::info!( log::info!(
"Loaded Rune plugin '{}' with {} provider(s)", "Loaded Rune plugin '{}' with {} provider(s)",
@@ -92,16 +104,37 @@ impl LoadedPlugin {
self.registrations.iter().any(|r| r.name == name) self.registrations.iter().any(|r| r.name == name)
} }
/// Refresh a static provider (stub for now) /// Refresh a static provider by calling the Rune `refresh()` function
pub fn refresh_provider(&mut self, _name: &str) -> Result<Vec<PluginItem>, String> { pub fn refresh_provider(&mut self, _name: &str) -> Result<Vec<PluginItem>, String> {
// TODO: Implement provider refresh by calling Rune function let mut vm = create_vm(&self.context, self.unit.clone())
Ok(Vec::new()) .map_err(|e| format!("Failed to create VM: {}", e))?;
let output = vm
.call(rune::Hash::type_hash(["refresh"]), ())
.map_err(|e| format!("refresh() call failed: {}", e))?;
let items: Vec<crate::api::Item> = rune::from_value(output)
.map_err(|e| format!("Failed to parse refresh() result: {}", e))?;
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
} }
/// Query a dynamic provider (stub for now) /// Query a dynamic provider by calling the Rune `query(q)` function
pub fn query_provider(&mut self, _name: &str, _query: &str) -> Result<Vec<PluginItem>, String> { pub fn query_provider(&mut self, _name: &str, query: &str) -> Result<Vec<PluginItem>, String> {
// TODO: Implement provider query by calling Rune function let mut vm = create_vm(&self.context, self.unit.clone())
Ok(Vec::new()) .map_err(|e| format!("Failed to create VM: {}", e))?;
let output = vm
.call(
rune::Hash::type_hash(["query"]),
(query.to_string(),),
)
.map_err(|e| format!("query() call failed: {}", e))?;
let items: Vec<crate::api::Item> = rune::from_value(output)
.map_err(|e| format!("Failed to parse query() result: {}", e))?;
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
} }
} }

View File

@@ -11,6 +11,28 @@ pub struct PluginManifest {
pub provides: PluginProvides, pub provides: PluginProvides,
#[serde(default)] #[serde(default)]
pub permissions: PluginPermissions, pub permissions: PluginPermissions,
/// Provider declarations from [[providers]] sections
#[serde(default)]
pub providers: Vec<ProviderDecl>,
}
/// A provider declared in [[providers]] section of plugin.toml
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderDecl {
pub id: String,
pub name: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default = "default_provider_type", rename = "type")]
pub provider_type: String,
#[serde(default)]
pub type_id: Option<String>,
}
fn default_provider_type() -> String {
"static".to_string()
} }
/// Core plugin information /// Core plugin information
@@ -25,7 +47,7 @@ pub struct PluginInfo {
pub author: String, pub author: String,
#[serde(default = "default_owlry_version")] #[serde(default = "default_owlry_version")]
pub owlry_version: String, pub owlry_version: String,
#[serde(default = "default_entry")] #[serde(default = "default_entry", alias = "entry_point")]
pub entry: String, pub entry: String,
} }
@@ -34,7 +56,7 @@ fn default_owlry_version() -> String {
} }
fn default_entry() -> String { fn default_entry() -> String {
"init.rn".to_string() "main.rn".to_string()
} }
/// What the plugin provides /// What the plugin provides
@@ -128,7 +150,7 @@ version = "1.0.0"
"#; "#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin"); assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.entry, "init.rn"); assert_eq!(manifest.plugin.entry, "main.rn");
} }
#[test] #[test]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry" name = "owlry"
version = "1.0.0" version = "1.0.6"
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"
@@ -46,6 +46,9 @@ dirs = "5"
# Semantic versioning (needed by plugin commands) # Semantic versioning (needed by plugin commands)
semver = "1" semver = "1"
# Async oneshot channel (background thread -> main loop)
futures-channel = "0.3"
[build-dependencies] [build-dependencies]
# GResource compilation for bundled icons # GResource compilation for bundled icons
glib-build-tools = "0.20" glib-build-tools = "0.20"

View File

@@ -69,7 +69,7 @@ impl OwlryApp {
match CoreClient::connect_or_start() { match CoreClient::connect_or_start() {
Ok(client) => { Ok(client) => {
info!("Connected to owlry-core daemon"); info!("Connected to owlry-core daemon");
SearchBackend::Daemon(client) SearchBackend::Daemon(crate::backend::DaemonHandle::new(client))
} }
Err(e) => { Err(e) => {
warn!( warn!(
@@ -135,6 +135,9 @@ impl OwlryApp {
Self::load_css(&config.borrow()); Self::load_css(&config.borrow());
window.present(); window.present();
// Populate results AFTER present() so the window appears immediately
window.schedule_initial_results();
} }
/// Create a local backend as fallback when daemon is unavailable. /// Create a local backend as fallback when daemon is unavailable.
@@ -182,16 +185,25 @@ impl OwlryApp {
} }
fn setup_icon_theme() { fn setup_icon_theme() {
// Ensure we have icon fallbacks for weather/media icons
// These may not exist in all icon themes
if let Some(display) = gtk4::gdk::Display::default() { if let Some(display) = gtk4::gdk::Display::default() {
let icon_theme = gtk4::IconTheme::for_display(&display); let icon_theme = gtk4::IconTheme::for_display(&display);
// Add Adwaita as fallback search path (has weather and media icons) // If the system icon theme doesn't exist on disk (e.g., set in
icon_theme.add_search_path("/usr/share/icons/Adwaita"); // gsettings but not installed), GTK falls back to hicolor which
icon_theme.add_search_path("/usr/share/icons/breeze"); // has almost no icons. Detect this and use Adwaita instead.
let theme_name = icon_theme.theme_name();
let theme_exists = icon_theme
.search_path()
.iter()
.any(|p| p.join(theme_name.as_str()).is_dir());
debug!("Icon theme search paths configured with Adwaita/breeze fallbacks"); if !theme_exists && theme_name != "hicolor" && theme_name != "Adwaita" {
info!(
"Icon theme '{}' not found on disk, falling back to Adwaita",
theme_name
);
icon_theme.set_theme_name(Some("Adwaita"));
}
} }
} }

View File

@@ -10,12 +10,87 @@ use owlry_core::data::FrecencyStore;
use owlry_core::filter::ProviderFilter; use owlry_core::filter::ProviderFilter;
use owlry_core::ipc::ResultItem; use owlry_core::ipc::ResultItem;
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
use std::sync::{Arc, Mutex};
/// Parameters needed to run a search query on a background thread.
pub struct QueryParams {
pub query: String,
#[allow(dead_code)]
pub max_results: usize,
pub modes: Option<Vec<String>>,
pub tag_filter: Option<String>,
}
/// Result of an async search, sent back to the main thread.
pub struct QueryResult {
#[allow(dead_code)]
pub query: String,
pub items: Vec<LaunchItem>,
}
/// Thread-safe handle to the daemon IPC connection.
pub struct DaemonHandle {
pub(crate) client: Arc<Mutex<CoreClient>>,
}
impl DaemonHandle {
pub fn new(client: CoreClient) -> Self {
Self {
client: Arc::new(Mutex::new(client)),
}
}
/// Dispatch an IPC query on a background thread.
///
/// Returns a `futures_channel::oneshot::Receiver` that resolves with
/// the `QueryResult` once the background thread completes IPC. The
/// caller should `.await` it inside `glib::spawn_future_local` to
/// process results on the GTK main thread without `Send` constraints.
pub fn query_async(
&self,
params: QueryParams,
) -> futures_channel::oneshot::Receiver<QueryResult> {
let (tx, rx) = futures_channel::oneshot::channel();
let client = Arc::clone(&self.client);
let query_for_result = params.query.clone();
std::thread::spawn(move || {
let items = match client.lock() {
Ok(mut c) => {
let effective_query = if let Some(ref tag) = params.tag_filter {
format!(":tag:{} {}", tag, params.query)
} else {
params.query
};
match c.query(&effective_query, params.modes) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => {
warn!("IPC query failed: {}", e);
Vec::new()
}
}
}
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
};
let _ = tx.send(QueryResult {
query: query_for_result,
items,
});
});
rx
}
}
/// Backend for search operations. Wraps either an IPC client (daemon mode) /// Backend for search operations. Wraps either an IPC client (daemon mode)
/// or a local ProviderManager (dmenu mode). /// or a local ProviderManager (dmenu mode).
pub enum SearchBackend { pub enum SearchBackend {
/// IPC client connected to owlry-core daemon /// IPC client connected to owlry-core daemon
Daemon(CoreClient), Daemon(DaemonHandle),
/// Direct local provider manager (dmenu mode only) /// Direct local provider manager (dmenu mode only)
Local { Local {
providers: Box<ProviderManager>, providers: Box<ProviderManager>,
@@ -24,6 +99,22 @@ pub enum SearchBackend {
} }
impl SearchBackend { impl SearchBackend {
/// Build the modes parameter from a ProviderFilter.
/// When accept_all, returns None so the daemon doesn't restrict to a specific set
/// (otherwise dynamically loaded plugin types would be filtered out).
fn build_modes_param(filter: &ProviderFilter) -> Option<Vec<String>> {
if filter.is_accept_all() {
None
} else {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
if modes.is_empty() { None } else { Some(modes) }
}
}
/// Search for items matching the query. /// Search for items matching the query.
/// ///
/// In daemon mode, sends query over IPC. The modes list is derived from /// In daemon mode, sends query over IPC. The modes list is derived from
@@ -38,21 +129,20 @@ impl SearchBackend {
config: &Config, config: &Config,
) -> Vec<LaunchItem> { ) -> Vec<LaunchItem> {
match self { match self {
SearchBackend::Daemon(client) => { SearchBackend::Daemon(handle) => {
let modes: Vec<String> = filter let modes_param = Self::build_modes_param(filter);
.enabled_providers() match handle.client.lock() {
.iter() Ok(mut client) => match client.query(query, modes_param) {
.map(|p| p.to_string())
.collect();
let modes_param = if modes.is_empty() { None } else { Some(modes) };
match client.query(query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(), Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => { Err(e) => {
warn!("IPC query failed: {}", e); warn!("IPC query failed: {}", e);
Vec::new() Vec::new()
} }
},
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
} }
} }
SearchBackend::Local { SearchBackend::Local {
@@ -96,29 +186,26 @@ impl SearchBackend {
tag_filter: Option<&str>, tag_filter: Option<&str>,
) -> Vec<LaunchItem> { ) -> Vec<LaunchItem> {
match self { match self {
SearchBackend::Daemon(client) => { SearchBackend::Daemon(handle) => {
// Daemon doesn't support tag filtering in IPC yet — pass query as-is.
// If there's a tag filter, prepend it so the daemon can handle it.
let effective_query = if let Some(tag) = tag_filter { let effective_query = if let Some(tag) = tag_filter {
format!(":tag:{} {}", tag, query) format!(":tag:{} {}", tag, query)
} else { } else {
query.to_string() query.to_string()
}; };
let modes: Vec<String> = filter let modes_param = Self::build_modes_param(filter);
.enabled_providers() match handle.client.lock() {
.iter() Ok(mut client) => match client.query(&effective_query, modes_param) {
.map(|p| p.to_string())
.collect();
let modes_param = if modes.is_empty() { None } else { Some(modes) };
match client.query(&effective_query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(), Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => { Err(e) => {
warn!("IPC query failed: {}", e); warn!("IPC query failed: {}", e);
Vec::new() Vec::new()
} }
},
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
} }
} }
SearchBackend::Local { SearchBackend::Local {
@@ -152,16 +239,46 @@ impl SearchBackend {
} }
} }
/// Dispatch async search (daemon mode only).
/// Returns `Some(Receiver)` if dispatched, `None` for local mode.
pub fn query_async(
&self,
query: &str,
max_results: usize,
filter: &ProviderFilter,
_config: &Config,
tag_filter: Option<&str>,
) -> Option<futures_channel::oneshot::Receiver<QueryResult>> {
match self {
SearchBackend::Daemon(handle) => {
let params = QueryParams {
query: query.to_string(),
max_results,
modes: Self::build_modes_param(filter),
tag_filter: tag_filter.map(|s| s.to_string()),
};
Some(handle.query_async(params))
}
SearchBackend::Local { .. } => None,
}
}
/// Execute a plugin action command. Returns true if handled. /// Execute a plugin action command. Returns true if handled.
pub fn execute_plugin_action(&mut self, command: &str) -> bool { pub fn execute_plugin_action(&mut self, command: &str) -> bool {
match self { match self {
SearchBackend::Daemon(client) => match client.plugin_action(command) { SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(mut client) => match client.plugin_action(command) {
Ok(handled) => handled, Ok(handled) => handled,
Err(e) => { Err(e) => {
warn!("IPC plugin_action failed: {}", e); warn!("IPC plugin_action failed: {}", e);
false false
} }
}, },
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
false
}
},
SearchBackend::Local { providers, .. } => providers.execute_plugin_action(command), SearchBackend::Local { providers, .. } => providers.execute_plugin_action(command),
} }
} }
@@ -175,7 +292,8 @@ impl SearchBackend {
display_name: &str, display_name: &str,
) -> Option<(String, Vec<LaunchItem>)> { ) -> Option<(String, Vec<LaunchItem>)> {
match self { match self {
SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) { SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(mut client) => match client.submenu(plugin_id, data) {
Ok(items) if !items.is_empty() => { Ok(items) if !items.is_empty() => {
let actions: Vec<LaunchItem> = let actions: Vec<LaunchItem> =
items.into_iter().map(result_to_launch_item).collect(); items.into_iter().map(result_to_launch_item).collect();
@@ -187,6 +305,11 @@ impl SearchBackend {
None None
} }
}, },
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
None
}
},
SearchBackend::Local { providers, .. } => { SearchBackend::Local { providers, .. } => {
providers.query_submenu_actions(plugin_id, data, display_name) providers.query_submenu_actions(plugin_id, data, display_name)
} }
@@ -196,10 +319,14 @@ impl SearchBackend {
/// Record a launch event for frecency tracking. /// Record a launch event for frecency tracking.
pub fn record_launch(&mut self, item_id: &str, provider: &str) { pub fn record_launch(&mut self, item_id: &str, provider: &str) {
match self { match self {
SearchBackend::Daemon(client) => { SearchBackend::Daemon(handle) => {
if let Ok(mut client) = handle.client.lock() {
if let Err(e) = client.launch(item_id, provider) { if let Err(e) = client.launch(item_id, provider) {
warn!("IPC launch notification failed: {}", e); warn!("IPC launch notification failed: {}", e);
} }
} else {
warn!("Failed to lock daemon client for launch");
}
} }
SearchBackend::Local { frecency, .. } => { SearchBackend::Local { frecency, .. } => {
frecency.record_launch(item_id); frecency.record_launch(item_id);
@@ -226,13 +353,19 @@ impl SearchBackend {
#[allow(dead_code)] #[allow(dead_code)]
pub fn available_provider_ids(&mut self) -> Vec<String> { pub fn available_provider_ids(&mut self) -> Vec<String> {
match self { match self {
SearchBackend::Daemon(client) => match client.providers() { SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(mut client) => match client.providers() {
Ok(descs) => descs.into_iter().map(|d| d.id).collect(), Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
Err(e) => { Err(e) => {
warn!("IPC providers query failed: {}", e); warn!("IPC providers query failed: {}", e);
Vec::new() Vec::new()
} }
}, },
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
},
SearchBackend::Local { providers, .. } => providers SearchBackend::Local { providers, .. } => providers
.available_providers() .available_providers()
.into_iter() .into_iter()

View File

@@ -5,7 +5,7 @@ use std::time::Duration;
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
/// IPC client that connects to the owlry-core daemon Unix socket /// IPC client that connects to the owlryd daemon Unix socket
/// and provides typed methods for all IPC operations. /// and provides typed methods for all IPC operations.
pub struct CoreClient { pub struct CoreClient {
stream: UnixStream, stream: UnixStream,
@@ -38,15 +38,15 @@ impl CoreClient {
// Socket not available — try to start the daemon. // Socket not available — try to start the daemon.
let status = std::process::Command::new("systemctl") let status = std::process::Command::new("systemctl")
.args(["--user", "start", "owlry-core"]) .args(["--user", "start", "owlryd"])
.status() .status()
.map_err(|e| { .map_err(|e| {
io::Error::other(format!("failed to start owlry-core via systemd: {e}")) io::Error::other(format!("failed to start owlryd via systemd: {e}"))
})?; })?;
if !status.success() { if !status.success() {
return Err(io::Error::other(format!( return Err(io::Error::other(format!(
"systemctl --user start owlry-core exited with status {}", "systemctl --user start owlryd exited with status {}",
status status
))); )));
} }

View File

@@ -1114,10 +1114,14 @@ fn execute_plugin_command(
// Load the appropriate runtime // Load the appropriate runtime
let loaded_runtime = match runtime { let loaded_runtime = match runtime {
PluginRuntime::Lua => LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path)) PluginRuntime::Lua => {
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?, let owlry_version = env!("CARGO_PKG_VERSION");
LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path), owlry_version)
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?
}
PluginRuntime::Rune => { PluginRuntime::Rune => {
LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path)) let owlry_version = env!("CARGO_PKG_VERSION");
LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path), owlry_version)
.map_err(|e| format!("Failed to load Rune runtime: {}", e))? .map_err(|e| format!("Failed to load Rune runtime: {}", e))?
} }
}; };

View File

@@ -14,6 +14,7 @@
background-color: var(--owlry-bg, @theme_bg_color); background-color: var(--owlry-bg, @theme_bg_color);
border-radius: var(--owlry-border-radius, 12px); border-radius: var(--owlry-border-radius, 12px);
border: 1px solid var(--owlry-border, @borders); border: 1px solid var(--owlry-border, @borders);
box-shadow: var(--owlry-shadow, none);
padding: 12px; padding: 12px;
} }
@@ -56,6 +57,16 @@
color: var(--owlry-accent-bright, @theme_selected_fg_color); color: var(--owlry-accent-bright, @theme_selected_fg_color);
} }
/* Highlighted result row (exact match or auto-detected plugin result) */
.owlry-result-highlight {
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.08);
border-left: 3px solid var(--owlry-accent, @theme_selected_bg_color);
}
.owlry-result-highlight:selected {
border-left: 3px solid var(--owlry-accent-bright, @theme_selected_fg_color);
}
/* Result icon */ /* Result icon */
.owlry-result-icon { .owlry-result-icon {
color: var(--owlry-text, @theme_fg_color); color: var(--owlry-text, @theme_fg_color);

View File

@@ -31,8 +31,6 @@
.owlry-main { .owlry-main {
background-color: rgba(26, 27, 38, 0.95); background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6); border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(224, 175, 104, 0.1);
} }
/* Search entry */ /* Search entry */

View File

@@ -42,6 +42,8 @@ struct LazyLoadState {
all_results: Vec<LaunchItem>, all_results: Vec<LaunchItem>,
/// Number of items currently displayed /// Number of items currently displayed
displayed_count: usize, displayed_count: usize,
/// The query that produced these results (for highlighting in lazy-loaded batches)
query: String,
} }
/// Number of items to display initially and per batch /// Number of items to display initially and per batch
@@ -224,7 +226,6 @@ impl MainWindow {
main_window.setup_signals(); main_window.setup_signals();
main_window.setup_lazy_loading(); main_window.setup_lazy_loading();
main_window.update_results("");
// Ensure search entry has focus when window is shown // Ensure search entry has focus when window is shown
main_window.search_entry.grab_focus(); main_window.search_entry.grab_focus();
@@ -240,36 +241,18 @@ impl MainWindow {
search_entry_for_refresh.emit_by_name::<()>("changed", &[]); search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
}); });
// Set up periodic widget auto-refresh (every 5 seconds) — local backend only // Periodic widget refresh — local backend only.
// In daemon mode, the daemon handles widget refresh and results come via IPC // In daemon mode, the daemon handles widget refresh internally;
if main_window.is_dmenu_mode { // the UI gets updated data on the next user-initiated search.
// dmenu typically has no widgets, but this is harmless // We do NOT re-query in daemon mode because it resets the user's
} // scroll position and selection.
if !matches!(&*main_window.backend.borrow(), SearchBackend::Daemon(_)) {
let backend_for_auto = main_window.backend.clone(); let backend_for_auto = main_window.backend.clone();
let current_results_for_auto = main_window.current_results.clone();
let submenu_state_for_auto = main_window.submenu_state.clone();
let search_entry_for_auto = main_window.search_entry.clone();
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || { gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
let in_submenu = submenu_state_for_auto.borrow().active;
// For local backend: refresh widgets (daemon handles this itself)
backend_for_auto.borrow_mut().refresh_widgets(); backend_for_auto.borrow_mut().refresh_widgets();
// For daemon backend: re-query to get updated widget data
if !in_submenu {
if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() {
// Trigger a re-search to pick up updated widget items from daemon
search_entry_for_auto.emit_by_name::<()>("changed", &[]);
} else {
// Local backend: update widget items in-place (legacy behavior)
// This path is only hit in dmenu mode which doesn't have widgets,
// but keep it for completeness.
let _results = current_results_for_auto.borrow();
// No-op for local mode without widget access
}
}
gtk4::glib::ControlFlow::Continue gtk4::glib::ControlFlow::Continue
}); });
}
main_window main_window
} }
@@ -458,7 +441,12 @@ impl MainWindow {
} }
/// Scroll the given row into view within the scrolled window /// Scroll the given row into view within the scrolled window
fn scroll_to_row(scrolled: &ScrolledWindow, results_list: &ListBox, row: &ListBoxRow) { fn scroll_to_row(
scrolled: &ScrolledWindow,
results_list: &ListBox,
row: &ListBoxRow,
lazy_state: &Rc<RefCell<LazyLoadState>>,
) {
let vadj = scrolled.vadjustment(); let vadj = scrolled.vadjustment();
let row_index = row.index(); let row_index = row.index();
@@ -470,15 +458,7 @@ impl MainWindow {
let current_scroll = vadj.value(); let current_scroll = vadj.value();
let list_height = results_list.height() as f64; let list_height = results_list.height() as f64;
let row_count = { let row_count = lazy_state.borrow().displayed_count.max(1) as f64;
let mut count = 0;
let mut child = results_list.first_child();
while child.is_some() {
count += 1;
child = child.and_then(|c| c.next_sibling());
}
count.max(1) as f64
};
let row_height = list_height / row_count; let row_height = list_height / row_count;
let row_top = row_index as f64 * row_height; let row_top = row_index as f64 * row_height;
@@ -532,7 +512,7 @@ impl MainWindow {
} }
for item in &actions { for item in &actions {
let row = ResultRow::new(item); let row = ResultRow::new(item, "");
results_list.append(&row); results_list.append(&row);
} }
@@ -614,7 +594,7 @@ impl MainWindow {
} }
for item in &filtered { for item in &filtered {
let row = ResultRow::new(item); let row = ResultRow::new(item, "");
results_list.append(&row); results_list.append(&row);
} }
@@ -675,6 +655,11 @@ impl MainWindow {
let filter = filter.clone(); let filter = filter.clone();
let lazy_state = lazy_state.clone(); let lazy_state = lazy_state.clone();
let debounce_source_for_closure = debounce_source.clone(); let debounce_source_for_closure = debounce_source.clone();
let query_str = parsed.query.clone();
let tag = parsed.tag_filter.clone();
// Capture the raw entry text at dispatch time for staleness detection.
let raw_text_at_dispatch = entry.text().to_string();
let search_entry_for_stale = search_entry_for_change.clone();
// Schedule debounced search // Schedule debounced search
let source_id = gtk4::glib::timeout_add_local_once( let source_id = gtk4::glib::timeout_add_local_once(
@@ -687,30 +672,81 @@ impl MainWindow {
let max_results = cfg.general.max_results; let max_results = cfg.general.max_results;
drop(cfg); drop(cfg);
// Try async path (daemon mode)
let receiver = {
let be = backend.borrow();
let f = filter.borrow();
let c = config.borrow();
be.query_async(
&query_str,
max_results,
&f,
&c,
tag.as_deref(),
)
};
if let Some(rx) = receiver {
// Daemon mode: results arrive asynchronously on the main loop.
// spawn_future_local runs the async block on the GTK main
// thread, so non-Send types (Rc, GTK widgets) are fine.
let results_list_cb = results_list.clone();
let current_results_cb = current_results.clone();
let lazy_state_cb = lazy_state.clone();
let query_for_highlight = query_str.clone();
gtk4::glib::spawn_future_local(async move {
if let Ok(result) = rx.await {
// Discard stale results: the user has typed something new
// since this query was dispatched.
if search_entry_for_stale.text().as_str() != raw_text_at_dispatch {
return;
}
while let Some(child) = results_list_cb.first_child() {
results_list_cb.remove(&child);
}
let items = result.items;
let initial_count =
INITIAL_RESULTS.min(items.len());
for item in items.iter().take(initial_count) {
let row = ResultRow::new(item, &query_for_highlight);
results_list_cb.append(&row);
}
if let Some(first_row) =
results_list_cb.row_at_index(0)
{
results_list_cb.select_row(Some(&first_row));
}
*current_results_cb.borrow_mut() =
items[..initial_count].to_vec();
let mut lazy = lazy_state_cb.borrow_mut();
lazy.all_results = items;
lazy.displayed_count = initial_count;
lazy.query = query_for_highlight;
}
});
} else {
// Local mode (dmenu): synchronous search
let results = backend.borrow_mut().search_with_tag( let results = backend.borrow_mut().search_with_tag(
&parsed.query, &query_str,
max_results, max_results,
&filter.borrow(), &filter.borrow(),
&config.borrow(), &config.borrow(),
parsed.tag_filter.as_deref(), tag.as_deref(),
); );
// Clear existing results
while let Some(child) = results_list.first_child() { while let Some(child) = results_list.first_child() {
results_list.remove(&child); results_list.remove(&child);
} }
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len()); 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) { for item in results.iter().take(initial_count) {
let row = ResultRow::new(item); let row = ResultRow::new(item, &query_str);
results_list.append(&row); results_list.append(&row);
} }
@@ -718,9 +754,13 @@ impl MainWindow {
results_list.select_row(Some(&first_row)); results_list.select_row(Some(&first_row));
} }
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = *current_results.borrow_mut() =
results.into_iter().take(initial_count).collect(); results[..initial_count].to_vec();
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results;
lazy.query = query_str;
lazy.displayed_count = initial_count;
}
}, },
); );
@@ -856,6 +896,7 @@ impl MainWindow {
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; let is_dmenu_mode = self.is_dmenu_mode;
let lazy_state_for_keys = self.lazy_state.clone();
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);
@@ -919,7 +960,7 @@ impl MainWindow {
let next_index = current.index() + 1; let next_index = current.index() + 1;
if let Some(next_row) = results_list.row_at_index(next_index) { if let Some(next_row) = results_list.row_at_index(next_index) {
results_list.select_row(Some(&next_row)); results_list.select_row(Some(&next_row));
Self::scroll_to_row(&scrolled, &results_list, &next_row); Self::scroll_to_row(&scrolled, &results_list, &next_row, &lazy_state_for_keys);
} }
} }
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
@@ -931,7 +972,7 @@ impl MainWindow {
&& let Some(prev_row) = results_list.row_at_index(prev_index) && let Some(prev_row) = results_list.row_at_index(prev_index)
{ {
results_list.select_row(Some(&prev_row)); results_list.select_row(Some(&prev_row));
Self::scroll_to_row(&scrolled, &results_list, &prev_row); Self::scroll_to_row(&scrolled, &results_list, &prev_row, &lazy_state_for_keys);
} }
} }
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
@@ -1183,43 +1224,49 @@ impl MainWindow {
entry.emit_by_name::<()>("changed", &[]); entry.emit_by_name::<()>("changed", &[]);
} }
fn update_results(&self, query: &str) { /// Schedule initial results population via idle callback.
let cfg = self.config.borrow(); /// Call this AFTER `window.present()` so the window appears immediately.
pub fn schedule_initial_results(&self) {
let backend = self.backend.clone();
let results_list = self.results_list.clone();
let config = self.config.clone();
let filter = self.filter.clone();
let current_results = self.current_results.clone();
let lazy_state = self.lazy_state.clone();
gtk4::glib::idle_add_local_once(move || {
let cfg = config.borrow();
let max_results = cfg.general.max_results; let max_results = cfg.general.max_results;
drop(cfg); drop(cfg);
let results = self.backend.borrow_mut().search( let results = backend.borrow_mut().search(
query, "",
max_results, max_results,
&self.filter.borrow(), &filter.borrow(),
&self.config.borrow(), &config.borrow(),
); );
// Clear existing results // Clear existing results
while let Some(child) = self.results_list.first_child() { while let Some(child) = results_list.first_child() {
self.results_list.remove(&child); results_list.remove(&child);
} }
// Store all results for lazy loading
let initial_count = INITIAL_RESULTS.min(results.len()); 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) { for item in results.iter().take(initial_count) {
let row = ResultRow::new(item); let row = ResultRow::new(item, "");
self.results_list.append(&row); results_list.append(&row);
} }
if let Some(first_row) = self.results_list.row_at_index(0) { if let Some(first_row) = results_list.row_at_index(0) {
self.results_list.select_row(Some(&first_row)); results_list.select_row(Some(&first_row));
} }
// current_results holds what's currently displayed *current_results.borrow_mut() = results[..initial_count].to_vec();
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect(); let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results;
lazy.displayed_count = initial_count;
});
} }
/// Set up lazy loading scroll detection /// Set up lazy loading scroll detection
@@ -1276,8 +1323,9 @@ impl MainWindow {
if displayed < all_count { if displayed < all_count {
// Load next batch // Load next batch
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count); let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
let query = lazy.query.clone();
for item in lazy.all_results[displayed..new_end].iter() { for item in lazy.all_results[displayed..new_end].iter() {
let row = ResultRow::new(item); let row = ResultRow::new(item, &query);
results_list.append(&row); results_list.append(&row);
} }
lazy.displayed_count = new_end; lazy.displayed_count = new_end;

View File

@@ -1,6 +1,6 @@
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget}; use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
use owlry_core::providers::LaunchItem; use owlry_core::providers::{LaunchItem, ProviderType};
#[allow(dead_code)] #[allow(dead_code)]
pub struct ResultRow { pub struct ResultRow {
@@ -18,9 +18,31 @@ fn is_emoji_icon(s: &str) -> bool {
!first_char.is_ascii() && s.chars().count() <= 8 !first_char.is_ascii() && s.chars().count() <= 8
} }
/// Check if this item should be highlighted based on the query.
/// Highlighted when:
/// - Item is from an auto-detecting plugin (calculator, converter) that parsed
/// the query into a result — these produce direct answers, not search results
/// - Item name exactly matches the query (case-insensitive)
fn should_highlight(item: &LaunchItem, query: &str) -> bool {
if query.is_empty() {
return false;
}
// Exact name match (case-insensitive)
if item.name.eq_ignore_ascii_case(query) {
return true;
}
// Auto-detect plugins that produce direct answers (not search tools)
matches!(
&item.provider,
ProviderType::Plugin(id) if matches!(id.as_str(), "calc" | "conv")
)
}
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, query: &str) -> ListBoxRow {
let row = ListBoxRow::builder() let row = ListBoxRow::builder()
.selectable(true) .selectable(true)
.activatable(true) .activatable(true)
@@ -28,6 +50,10 @@ impl ResultRow {
row.add_css_class("owlry-result-row"); row.add_css_class("owlry-result-row");
if should_highlight(item, query) {
row.add_css_class("owlry-result-highlight");
}
let hbox = GtkBox::builder() let hbox = GtkBox::builder()
.orientation(Orientation::Horizontal) .orientation(Orientation::Horizontal)
.spacing(12) .spacing(12)

View File

@@ -77,8 +77,6 @@
.owlry-main { .owlry-main {
background-color: rgba(5, 5, 5, 0.98); background-color: rgba(5, 5, 5, 0.98);
border: 1px solid rgba(38, 38, 38, 0.8); 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 { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(30, 30, 46, 0.95); background-color: rgba(30, 30, 46, 0.95);
border: 1px solid rgba(69, 71, 90, 0.6); border: 1px solid rgba(69, 71, 90, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(203, 166, 247, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(40, 42, 54, 0.95); background-color: rgba(40, 42, 54, 0.95);
border: 1px solid rgba(98, 114, 164, 0.6); border: 1px solid rgba(98, 114, 164, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(189, 147, 249, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(40, 40, 40, 0.95); background-color: rgba(40, 40, 40, 0.95);
border: 1px solid rgba(80, 73, 69, 0.6); border: 1px solid rgba(80, 73, 69, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(254, 128, 25, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(46, 52, 64, 0.95); background-color: rgba(46, 52, 64, 0.95);
border: 1px solid rgba(76, 86, 106, 0.6); border: 1px solid rgba(76, 86, 106, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(136, 192, 208, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(40, 44, 52, 0.95); background-color: rgba(40, 44, 52, 0.95);
border: 1px solid rgba(24, 26, 31, 0.6); border: 1px solid rgba(24, 26, 31, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(97, 175, 239, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -33,8 +33,6 @@
.owlry-main { .owlry-main {
background-color: rgba(26, 27, 38, 0.95); background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6); border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(224, 175, 104, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(25, 23, 36, 0.95); background-color: rgba(25, 23, 36, 0.95);
border: 1px solid rgba(38, 35, 58, 0.6); border: 1px solid rgba(38, 35, 58, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(196, 167, 231, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(0, 43, 54, 0.95); background-color: rgba(0, 43, 54, 0.95);
border: 1px solid rgba(88, 110, 117, 0.6); border: 1px solid rgba(88, 110, 117, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(38, 139, 210, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(26, 27, 38, 0.95); background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6); border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(122, 162, 247, 0.1);
} }
.owlry-search { .owlry-search {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,876 @@
# Config Editor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Built-in `:config` provider that lets users browse and modify settings, toggle providers, select themes/engines, and manage profiles — all from within the launcher.
**Architecture:** The config editor is a `DynamicProvider` that interprets the query text as a navigation path. `:config providers` shows toggles, `:config theme` lists themes, `:config profile dev modes` shows a mode checklist. Actions (toggling, setting values) use the existing `PluginAction` IPC flow which keeps the window open and re-queries, giving instant visual feedback. Config changes are persisted to `config.toml` via `Config::save()`.
**Tech Stack:** Rust, owlry-core providers, toml serialization
---
## Key Design Decision: Query-as-Navigation
Instead of submenus, the `:config` prefix scopes the search bar as navigation:
```
:config → category list
:config providers → provider toggles
:config theme → theme selection
:config engine → search engine selection
:config frecency → frecency toggle + weight
:config profiles → profile list
:config profile dev → profile actions (edit modes, rename, delete)
:config profile dev modes → mode checklist for profile
:config profile create myname → create profile action
:config fontsize 16 → set font size action
:config width 900 → set width action
```
Actions use `CONFIG:*` commands dispatched via `execute_plugin_action`. Since this returns `false` for `should_close`, the window stays open and re-queries — the user sees updated state immediately.
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `crates/owlry-core/src/providers/config_editor.rs` | Create | ConfigProvider: query parsing, result generation, action execution |
| `crates/owlry-core/src/providers/mod.rs` | Modify | Register ConfigProvider, extend action dispatch |
| `crates/owlry-core/src/config/mod.rs` | Modify | Add helper methods for config mutation |
---
### Task 1: Create ConfigProvider skeleton and register it
**Files:**
- Create: `crates/owlry-core/src/providers/config_editor.rs`
- Modify: `crates/owlry-core/src/providers/mod.rs`
- [ ] **Step 1: Add module declaration**
In `crates/owlry-core/src/providers/mod.rs`, add with the other module declarations:
```rust
pub(crate) mod config_editor;
```
- [ ] **Step 2: Create config_editor.rs with top-level categories**
Create `crates/owlry-core/src/providers/config_editor.rs`:
```rust
//! Built-in config editor provider.
//!
//! Lets users browse and modify settings from within the launcher.
//! Uses `:config` prefix with query-as-navigation pattern.
use std::sync::{Arc, RwLock};
use crate::config::Config;
use super::{DynamicProvider, LaunchItem, ProviderType};
const PROVIDER_TYPE_ID: &str = "config";
const PROVIDER_ICON: &str = "preferences-system-symbolic";
pub struct ConfigProvider {
config: Arc<RwLock<Config>>,
}
impl ConfigProvider {
pub fn new(config: Arc<RwLock<Config>>) -> Self {
Self { config }
}
/// Execute a CONFIG:* action command. Returns true if handled.
pub fn execute_action(&self, command: &str) -> bool {
let Some(action) = command.strip_prefix("CONFIG:") else {
return false;
};
let mut config = match self.config.write() {
Ok(c) => c,
Err(_) => return false,
};
let handled = self.handle_action(action, &mut config);
if handled {
if let Err(e) = config.save() {
log::warn!("Failed to save config: {}", e);
}
}
handled
}
fn handle_action(&self, action: &str, config: &mut Config) -> bool {
if let Some(key) = action.strip_prefix("toggle:") {
return self.toggle_bool(key, config);
}
if let Some(rest) = action.strip_prefix("set:") {
return self.set_value(rest, config);
}
if let Some(rest) = action.strip_prefix("profile:") {
return self.handle_profile_action(rest, config);
}
false
}
fn toggle_bool(&self, key: &str, config: &mut Config) -> bool {
match key {
"providers.applications" => { config.providers.applications = !config.providers.applications; true }
"providers.commands" => { config.providers.commands = !config.providers.commands; true }
"providers.calculator" => { config.providers.calculator = !config.providers.calculator; true }
"providers.converter" => { config.providers.converter = !config.providers.converter; true }
"providers.system" => { config.providers.system = !config.providers.system; true }
"providers.websearch" => { config.providers.websearch = !config.providers.websearch; true }
"providers.ssh" => { config.providers.ssh = !config.providers.ssh; true }
"providers.clipboard" => { config.providers.clipboard = !config.providers.clipboard; true }
"providers.bookmarks" => { config.providers.bookmarks = !config.providers.bookmarks; true }
"providers.emoji" => { config.providers.emoji = !config.providers.emoji; true }
"providers.scripts" => { config.providers.scripts = !config.providers.scripts; true }
"providers.files" => { config.providers.files = !config.providers.files; true }
"providers.uuctl" => { config.providers.uuctl = !config.providers.uuctl; true }
"providers.media" => { config.providers.media = !config.providers.media; true }
"providers.weather" => { config.providers.weather = !config.providers.weather; true }
"providers.pomodoro" => { config.providers.pomodoro = !config.providers.pomodoro; true }
"providers.frecency" => { config.providers.frecency = !config.providers.frecency; true }
_ => false,
}
}
fn set_value(&self, rest: &str, config: &mut Config) -> bool {
let Some((key, value)) = rest.split_once(':') else { return false };
match key {
"appearance.theme" => { config.appearance.theme = Some(value.to_string()); true }
"appearance.font_size" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.font_size = v;
true
} else { false }
}
"appearance.width" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.width = v;
true
} else { false }
}
"appearance.height" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.height = v;
true
} else { false }
}
"appearance.border_radius" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.border_radius = v;
true
} else { false }
}
"providers.search_engine" => { config.providers.search_engine = value.to_string(); true }
"providers.frecency_weight" => {
if let Ok(v) = value.parse::<f64>() {
config.providers.frecency_weight = v.clamp(0.0, 1.0);
true
} else { false }
}
_ => false,
}
}
fn handle_profile_action(&self, rest: &str, config: &mut Config) -> bool {
if let Some(name) = rest.strip_prefix("create:") {
config.profiles.entry(name.to_string()).or_insert_with(|| {
crate::config::ProfileConfig { modes: vec![] }
});
return true;
}
if let Some(name) = rest.strip_prefix("delete:") {
config.profiles.remove(name);
return true;
}
if let Some(rest) = rest.strip_prefix("rename:") {
if let Some((old, new)) = rest.split_once(':') {
if let Some(profile) = config.profiles.remove(old) {
config.profiles.insert(new.to_string(), profile);
return true;
}
}
return false;
}
if let Some(rest) = rest.strip_prefix("mode:") {
// format: profile_name:toggle:mode_name
let parts: Vec<&str> = rest.splitn(3, ':').collect();
if parts.len() == 3 && parts[1] == "toggle" {
let profile_name = parts[0];
let mode = parts[2];
if let Some(profile) = config.profiles.get_mut(profile_name) {
if let Some(pos) = profile.modes.iter().position(|m| m == mode) {
profile.modes.remove(pos);
} else {
profile.modes.push(mode.to_string());
}
return true;
}
}
return false;
}
false
}
}
impl DynamicProvider for ConfigProvider {
fn name(&self) -> &str {
"Config"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let config = match self.config.read() {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let path = query.trim();
self.generate_items(path, &config)
}
fn priority(&self) -> u32 {
8_000
}
}
```
- [ ] **Step 3: Implement generate_items — the query router**
Add to `ConfigProvider`:
```rust
fn generate_items(&self, path: &str, config: &Config) -> Vec<LaunchItem> {
// Top-level categories
if path.is_empty() {
return self.top_level_items();
}
let (section, rest) = match path.split_once(' ') {
Some((s, r)) => (s, r.trim()),
None => (path, ""),
};
match section {
"providers" => self.provider_items(config),
"theme" => self.theme_items(config, rest),
"engine" => self.engine_items(config),
"frecency" => self.frecency_items(config, rest),
"fontsize" => self.numeric_item("Font Size", "appearance.font_size", config.appearance.font_size, rest),
"width" => self.numeric_item("Width", "appearance.width", config.appearance.width, rest),
"height" => self.numeric_item("Height", "appearance.height", config.appearance.height, rest),
"radius" => self.numeric_item("Border Radius", "appearance.border_radius", config.appearance.border_radius, rest),
"profiles" => self.profile_items(config, rest),
"profile" => self.profile_detail_items(config, rest),
_ => self.top_level_items(),
}
}
fn top_level_items(&self) -> Vec<LaunchItem> {
vec![
self.make_item("config:providers", "Providers", "Toggle providers on/off", ""),
self.make_item("config:theme", "Theme", "Select color theme", ""),
self.make_item("config:engine", "Search Engine", "Select web search engine", ""),
self.make_item("config:frecency", "Frecency", "Frecency ranking settings", ""),
self.make_item("config:fontsize", "Font Size", "Set UI font size", ""),
self.make_item("config:width", "Width", "Set window width", ""),
self.make_item("config:height", "Height", "Set window height", ""),
self.make_item("config:radius", "Border Radius", "Set border radius", ""),
self.make_item("config:profiles", "Profiles", "Manage named mode profiles", ""),
]
}
fn make_item(&self, id: &str, name: &str, description: &str, command: &str) -> LaunchItem {
LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: command.to_string(),
terminal: false,
tags: vec!["config".into(), "settings".into()],
}
}
fn toggle_item(&self, id: &str, name: &str, enabled: bool, key: &str) -> LaunchItem {
let prefix = if enabled { "" } else { "" };
LaunchItem {
id: id.to_string(),
name: format!("{} {}", prefix, name),
description: Some(format!("{} (click to toggle)", if enabled { "Enabled" } else { "Disabled" })),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:toggle:{}", key),
terminal: false,
tags: vec!["config".into()],
}
}
```
- [ ] **Step 4: Implement provider_items**
```rust
fn provider_items(&self, config: &Config) -> Vec<LaunchItem> {
vec![
self.toggle_item("config:prov:app", "Applications", config.providers.applications, "providers.applications"),
self.toggle_item("config:prov:cmd", "Commands", config.providers.commands, "providers.commands"),
self.toggle_item("config:prov:calc", "Calculator", config.providers.calculator, "providers.calculator"),
self.toggle_item("config:prov:conv", "Converter", config.providers.converter, "providers.converter"),
self.toggle_item("config:prov:sys", "System", config.providers.system, "providers.system"),
self.toggle_item("config:prov:web", "Web Search", config.providers.websearch, "providers.websearch"),
self.toggle_item("config:prov:ssh", "SSH", config.providers.ssh, "providers.ssh"),
self.toggle_item("config:prov:clip", "Clipboard", config.providers.clipboard, "providers.clipboard"),
self.toggle_item("config:prov:bm", "Bookmarks", config.providers.bookmarks, "providers.bookmarks"),
self.toggle_item("config:prov:emoji", "Emoji", config.providers.emoji, "providers.emoji"),
self.toggle_item("config:prov:scripts", "Scripts", config.providers.scripts, "providers.scripts"),
self.toggle_item("config:prov:files", "File Search", config.providers.files, "providers.files"),
self.toggle_item("config:prov:uuctl", "systemd Units", config.providers.uuctl, "providers.uuctl"),
self.toggle_item("config:prov:media", "Media", config.providers.media, "providers.media"),
self.toggle_item("config:prov:weather", "Weather", config.providers.weather, "providers.weather"),
self.toggle_item("config:prov:pomo", "Pomodoro", config.providers.pomodoro, "providers.pomodoro"),
]
}
```
- [ ] **Step 5: Implement theme_items and engine_items**
```rust
fn theme_items(&self, config: &Config, filter: &str) -> Vec<LaunchItem> {
let current = config.appearance.theme.as_deref().unwrap_or("(default)");
let themes = [
"owl", "catppuccin-mocha", "nord", "rose-pine", "dracula",
"gruvbox-dark", "tokyo-night", "solarized-dark", "one-dark", "apex-neon",
];
themes.iter()
.filter(|t| filter.is_empty() || t.contains(filter))
.map(|t| {
let mark = if *t == current { "" } else { " " };
LaunchItem {
id: format!("config:theme:{}", t),
name: format!("{}{}", mark, t),
description: Some(if *t == current { "Current theme".into() } else { "Select this theme".into() }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:appearance.theme:{}", t),
terminal: false,
tags: vec!["config".into()],
}
})
.collect()
}
fn engine_items(&self, config: &Config) -> Vec<LaunchItem> {
let current = &config.providers.search_engine;
let engines = [
"duckduckgo", "google", "bing", "startpage", "brave", "ecosia",
];
engines.iter()
.map(|e| {
let mark = if *e == current.as_str() { "" } else { " " };
LaunchItem {
id: format!("config:engine:{}", e),
name: format!("{}{}", mark, e),
description: Some(if *e == current.as_str() { "Current engine".into() } else { "Select this engine".into() }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:providers.search_engine:{}", e),
terminal: false,
tags: vec!["config".into()],
}
})
.collect()
}
```
- [ ] **Step 6: Implement frecency_items and numeric_item**
```rust
fn frecency_items(&self, config: &Config, rest: &str) -> Vec<LaunchItem> {
let mut items = vec![
self.toggle_item("config:frecency:toggle", "Frecency Ranking", config.providers.frecency, "providers.frecency"),
];
// If user typed a weight value, show a set action
if !rest.is_empty() {
if let Ok(v) = rest.parse::<f64>() {
let clamped = v.clamp(0.0, 1.0);
items.push(LaunchItem {
id: "config:frecency:set".into(),
name: format!("Set weight to {:.1}", clamped),
description: Some(format!("Current: {:.1}", config.providers.frecency_weight)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:providers.frecency_weight:{}", clamped),
terminal: false,
tags: vec!["config".into()],
});
}
} else {
items.push(LaunchItem {
id: "config:frecency:weight".into(),
name: format!("Weight: {:.1}", config.providers.frecency_weight),
description: Some("Type a value (0.01.0) after :config frecency".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
});
}
items
}
fn numeric_item(&self, label: &str, key: &str, current: i32, input: &str) -> Vec<LaunchItem> {
if !input.is_empty() {
if let Ok(v) = input.parse::<i32>() {
return vec![LaunchItem {
id: format!("config:set:{}", key),
name: format!("Set {} to {}", label, v),
description: Some(format!("Current: {} (restart to apply)", current)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:{}:{}", key, v),
terminal: false,
tags: vec!["config".into()],
}];
}
}
vec![LaunchItem {
id: format!("config:show:{}", key),
name: format!("{}: {}", label, current),
description: Some(format!("Type a number after :config {} to change (restart to apply)", key.rsplit('.').next().unwrap_or(key))),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
}]
}
```
- [ ] **Step 7: Implement profile_items and profile_detail_items**
```rust
fn profile_items(&self, config: &Config, filter: &str) -> Vec<LaunchItem> {
let mut items: Vec<LaunchItem> = config.profiles.iter()
.filter(|(name, _)| filter.is_empty() || name.contains(filter))
.map(|(name, profile)| {
let modes = profile.modes.join(", ");
LaunchItem {
id: format!("config:profile:{}", name),
name: name.clone(),
description: Some(if modes.is_empty() { "(no modes)".into() } else { modes }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(), // navigate deeper by typing :config profile <name>
terminal: false,
tags: vec!["config".into(), "profile".into()],
}
})
.collect();
// "Create" action — user types :config profile create <name>
items.push(LaunchItem {
id: "config:profile:create_hint".into(),
name: " Create New Profile".into(),
description: Some("Type: :config profile create <name>".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
});
items
}
fn profile_detail_items(&self, config: &Config, rest: &str) -> Vec<LaunchItem> {
let (profile_name, sub) = match rest.split_once(' ') {
Some((n, s)) => (n, s.trim()),
None => (rest, ""),
};
// Handle "profile create <name>"
if profile_name == "create" && !sub.is_empty() {
return vec![LaunchItem {
id: format!("config:profile:create:{}", sub),
name: format!("Create profile '{}'", sub),
description: Some("Press Enter to create".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:create:{}", sub),
terminal: false,
tags: vec!["config".into()],
}];
}
let profile = match config.profiles.get(profile_name) {
Some(p) => p,
None => return vec![],
};
if sub == "modes" || sub.starts_with("modes") {
// Mode checklist
let all_modes = [
"app", "cmd", "calc", "conv", "sys", "web", "ssh", "clip",
"bm", "emoji", "scripts", "file", "uuctl", "media", "weather", "pomo",
];
return all_modes.iter()
.map(|mode| {
let enabled = profile.modes.iter().any(|m| m == mode);
let prefix = if enabled { "" } else { "" };
LaunchItem {
id: format!("config:profile:{}:mode:{}", profile_name, mode),
name: format!("{} {}", prefix, mode),
description: Some(format!("{} in profile '{}'", if enabled { "Enabled" } else { "Disabled" }, profile_name)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:mode:{}:toggle:{}", profile_name, mode),
terminal: false,
tags: vec!["config".into()],
}
})
.collect();
}
// Profile actions
vec![
LaunchItem {
id: format!("config:profile:{}:modes", profile_name),
name: "Edit Modes".into(),
description: Some(format!("Current: {}", profile.modes.join(", "))),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(), // navigate with :config profile <name> modes
terminal: false,
tags: vec!["config".into()],
},
LaunchItem {
id: format!("config:profile:{}:delete", profile_name),
name: format!("Delete profile '{}'", profile_name),
description: Some("Remove this profile".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:delete:{}", profile_name),
terminal: false,
tags: vec!["config".into()],
},
]
}
```
- [ ] **Step 8: Write tests**
Add at the end of `config_editor.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
fn make_config() -> Arc<RwLock<Config>> {
Arc::new(RwLock::new(Config::default()))
}
#[test]
fn test_top_level_categories() {
let p = ConfigProvider::new(make_config());
let items = p.query("");
assert!(items.len() >= 8);
assert!(items.iter().any(|i| i.name == "Providers"));
assert!(items.iter().any(|i| i.name == "Theme"));
assert!(items.iter().any(|i| i.name == "Profiles"));
}
#[test]
fn test_provider_toggles() {
let p = ConfigProvider::new(make_config());
let items = p.query("providers");
assert!(items.len() >= 10);
assert!(items.iter().any(|i| i.name.contains("Calculator")));
}
#[test]
fn test_toggle_action() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(config.read().unwrap().providers.calculator);
assert!(p.execute_action("CONFIG:toggle:providers.calculator"));
assert!(!config.read().unwrap().providers.calculator);
assert!(p.execute_action("CONFIG:toggle:providers.calculator"));
assert!(config.read().unwrap().providers.calculator);
}
#[test]
fn test_set_theme() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:appearance.theme:nord"));
assert_eq!(config.read().unwrap().appearance.theme, Some("nord".into()));
}
#[test]
fn test_set_numeric() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:appearance.font_size:18"));
assert_eq!(config.read().unwrap().appearance.font_size, 18);
}
#[test]
fn test_frecency_weight_clamped() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:providers.frecency_weight:2.0"));
assert_eq!(config.read().unwrap().providers.frecency_weight, 1.0);
}
#[test]
fn test_invalid_action() {
let p = ConfigProvider::new(make_config());
assert!(!p.execute_action("INVALID:something"));
assert!(!p.execute_action("CONFIG:toggle:nonexistent.key"));
}
#[test]
fn test_theme_items_show_current() {
let config = make_config();
{
config.write().unwrap().appearance.theme = Some("nord".into());
}
let p = ConfigProvider::new(config);
let items = p.query("theme");
let nord = items.iter().find(|i| i.name.contains("nord")).unwrap();
assert!(nord.name.starts_with(""));
}
#[test]
fn test_numeric_input_generates_set_action() {
let p = ConfigProvider::new(make_config());
let items = p.query("fontsize 18");
assert_eq!(items.len(), 1);
assert!(items[0].name.contains("Set Font Size to 18"));
assert_eq!(items[0].command, "CONFIG:set:appearance.font_size:18");
}
#[test]
fn test_profile_create() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:profile:create:myprofile"));
assert!(config.read().unwrap().profiles.contains_key("myprofile"));
}
#[test]
fn test_profile_delete() {
let config = make_config();
{
config.write().unwrap().profiles.insert("test".into(), crate::config::ProfileConfig { modes: vec!["app".into()] });
}
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:profile:delete:test"));
assert!(!config.read().unwrap().profiles.contains_key("test"));
}
#[test]
fn test_profile_mode_toggle() {
let config = make_config();
{
config.write().unwrap().profiles.insert("dev".into(), crate::config::ProfileConfig { modes: vec!["app".into()] });
}
let p = ConfigProvider::new(Arc::clone(&config));
// Add ssh
assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:ssh"));
assert!(config.read().unwrap().profiles["dev"].modes.contains(&"ssh".into()));
// Remove app
assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:app"));
assert!(!config.read().unwrap().profiles["dev"].modes.contains(&"app".into()));
}
#[test]
fn test_provider_type() {
let p = ConfigProvider::new(make_config());
assert_eq!(p.provider_type(), ProviderType::Plugin("config".into()));
}
#[test]
fn test_profile_create_query() {
let p = ConfigProvider::new(make_config());
let items = p.query("profile create myname");
assert_eq!(items.len(), 1);
assert!(items[0].name.contains("myname"));
assert_eq!(items[0].command, "CONFIG:profile:create:myname");
}
}
```
- [ ] **Step 9: Verify compilation and tests**
Run: `cargo test -p owlry-core config_editor`
Note: tests that call `execute_action` will try `config.save()` which writes to disk. The save will fail gracefully (warns) in test environment since there's no XDG config dir — the toggle/set still returns true. If tests fail due to save, add `#[allow(dead_code)]` or mock the save path. Alternatively, since `Config::save()` returns a Result and the provider logs but ignores errors, this should be fine.
Expected: All tests pass.
- [ ] **Step 10: Commit**
```bash
git add crates/owlry-core/src/providers/config_editor.rs crates/owlry-core/src/providers/mod.rs
git commit -m "feat(core): add built-in config editor provider
Interactive :config prefix for browsing and modifying settings.
Supports provider toggles, theme/engine selection, numeric input,
and profile CRUD. Uses CONFIG:* action commands that persist to
config.toml via Config::save()."
```
---
### Task 2: Wire ConfigProvider into ProviderManager
**Files:**
- Modify: `crates/owlry-core/src/providers/mod.rs`
- Modify: `crates/owlry-core/src/config/mod.rs` (if ProfileConfig is not public)
The ConfigProvider needs to be:
1. Registered as a built-in dynamic provider
2. Its `execute_action` called from `execute_plugin_action`
- [ ] **Step 1: Make Config wrap in Arc<RwLock> for shared ownership**
The ConfigProvider needs mutable access to config. Currently `new_with_config` takes `&Config`. Change the daemon startup to wrap Config in `Arc<RwLock<Config>>` and pass it to both the ConfigProvider and the server.
In `crates/owlry-core/src/providers/mod.rs`, in `new_with_config()`, after creating the config provider:
```rust
// Config editor — needs shared mutable access to config
let config_arc = std::sync::Arc::new(std::sync::RwLock::new(config.clone()));
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(config_arc)));
info!("Registered built-in config editor provider");
```
- [ ] **Step 2: Extend execute_plugin_action for built-in providers**
In `execute_plugin_action`, after the existing native provider check, add:
```rust
// Check built-in config editor
if command.starts_with("CONFIG:") {
for provider in &self.builtin_dynamic {
if let ProviderType::Plugin(ref id) = provider.provider_type() {
if id == "config" {
// Downcast to ConfigProvider to call execute_action
// Since we can't downcast trait objects easily, add an
// execute_action method to DynamicProvider with default impl
return provider.execute_action(command);
}
}
}
}
```
For this to work, add `execute_action` to the `DynamicProvider` trait with a default no-op:
```rust
pub(crate) trait DynamicProvider: Send + Sync {
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32;
/// Handle a plugin action command. Returns true if handled.
fn execute_action(&self, _command: &str) -> bool {
false
}
}
```
The ConfigProvider already has `execute_action` as an inherent method — just also implement it via the trait.
- [ ] **Step 3: Ensure ProfileConfig is accessible**
Check if `crate::config::ProfileConfig` is public. If not, add `pub` to its definition in `config/mod.rs`. The ConfigProvider needs to construct it for profile creation.
- [ ] **Step 4: Run tests**
Run: `cargo test -p owlry-core --lib`
Expected: All tests pass (128+ existing + new config editor tests).
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/providers/mod.rs crates/owlry-core/src/config/mod.rs
git commit -m "feat(core): wire config editor into ProviderManager
Register ConfigProvider as built-in dynamic provider. Extend
execute_plugin_action to dispatch CONFIG:* commands. Add
execute_action method to DynamicProvider trait."
```
---
### Task 3: Update CLAUDE.md and README with config editor docs
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Add config editor section to README**
In the README, in the Usage section (after Keyboard Shortcuts), add:
```markdown
### Settings Editor
Type `:config` to browse and modify settings without editing files:
| Command | What it does |
|---------|-------------|
| `:config` | Show all setting categories |
| `:config providers` | Toggle providers on/off |
| `:config theme` | Select color theme |
| `:config engine` | Select web search engine |
| `:config frecency` | Toggle frecency, set weight |
| `:config fontsize 16` | Set font size (restart to apply) |
| `:config profiles` | List profiles |
| `:config profile create dev` | Create a new profile |
| `:config profile dev modes` | Edit which modes a profile includes |
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: add config editor usage to README"
```
---
## Execution Notes
### Task dependency order
Task 1 is the bulk of the implementation. Task 2 wires it in. Task 3 is docs.
**Order:** 1 → 2 → 3
### What's NOT in this plan
- **Hot-apply for theme** — would need the UI to re-trigger CSS loading after a CONFIG action. Can be added later by emitting a signal from the daemon or having the UI check a flag after `execute_plugin_action` returns.
- **Profile rename via text input** — the current design supports `:config profile create <name>` but rename would need a two-step flow. Can be added later.
- **Config file watching** — if the user edits `config.toml` externally, the ConfigProvider's cached `Arc<RwLock<Config>>` becomes stale. A file watcher could reload it. Deferred.

View File

@@ -0,0 +1,116 @@
# Built-in Providers Migration — Design Spec
## Goal
Move calculator, converter, and system from external `.so` plugins (owlry-plugins repo) to native providers compiled into `owlry-core`. Remove 3 plugin AUR packages (transitional), 4 meta AUR packages (already deleted). Update READMEs for both repos.
## Architecture
The 3 plugins currently use the FFI plugin API (`PluginVTable`, `PluginItem`, etc.) and are loaded as `.so` files by `NativePluginLoader`. As built-in providers, they become native Rust modules inside `owlry-core/src/providers/` implementing the existing `Provider` trait — same as `ApplicationProvider` and `CommandProvider`.
No changes to the plugin system itself. External plugins continue to work via `.so` loading.
## Components
### New modules in owlry-core
- `providers/calculator.rs` — port of owlry-plugin-calculator (231 lines, depends on `meval`)
- `providers/converter/mod.rs` — port of owlry-plugin-converter entry point
- `providers/converter/parser.rs` — query parsing (235 lines, no new deps)
- `providers/converter/units.rs` — unit definitions + conversion (944 lines, no new deps)
- `providers/converter/currency.rs` — ECB rate fetching (313 lines, depends on `reqwest` blocking + `dirs` + `serde`)
- `providers/system.rs` — port of owlry-plugin-system (257 lines, no new deps)
### New owlry-core dependencies
- `meval` — math expression evaluation (currently optional behind `lua` feature, make required)
- `reqwest` with `blocking` feature — ECB currency rate fetching (currently optional behind `lua`, make required)
- `dirs` — already a dependency
- `serde`/`serde_json` — already dependencies
### Modified files
- `owlry-core/src/providers/mod.rs` — register the 3 new providers in `ProviderManager`, honor config toggles, classify calculator+converter as dynamic providers
- `owlry-core/Cargo.toml` — move `meval` and `reqwest` from optional to required
- `owlry-core/src/config/mod.rs` — add `converter` config toggle (calculator and system already exist)
### Provider classification
- Calculator → dynamic (queried per-keystroke via `query()`)
- Converter → dynamic (queried per-keystroke via `query()`)
- System → static (populated at `refresh()`, returns fixed list of actions)
## Provider Type IDs
Built-in providers use `ProviderType::Plugin(String)` with fixed IDs to maintain backward compatibility with the UI highlighting and filter system:
- Calculator: `ProviderType::Plugin("calc".into())`
- Converter: `ProviderType::Plugin("conv".into())`
- System: `ProviderType::Plugin("sys".into())`
This ensures the UI's highlighting logic (`matches!(id.as_str(), "calc" | "conv")`) and CSS badge classes (`.owlry-badge-calc`, `.owlry-badge-sys`) continue to work without changes.
## Config
Existing toggles in `[providers]`:
```toml
[providers]
calculator = true # already exists
system = true # already exists
converter = true # new — add with default true
```
When a toggle is false, the provider is not registered in `ProviderManager` at startup.
## Currency Conversion
The converter's currency feature uses `reqwest` (blocking) to fetch ECB exchange rates with a 24-hour file cache at `~/.cache/owlry/ecb_rates.json`. If the HTTP fetch fails (no network, timeout), currency conversion silently returns no results — unit conversion still works. This matches current plugin behavior.
## AUR Changes
### Main repo (owlry)
- `aur/owlry-core/PKGBUILD` — bump version
- Remove `aur/owlry-meta-*` directories (4 dirs, already deleted from AUR)
### Plugins repo (owlry-plugins)
- Remove crates: `owlry-plugin-calculator`, `owlry-plugin-converter`, `owlry-plugin-system`
- Remove AUR dirs: `aur/owlry-plugin-calculator`, `aur/owlry-plugin-converter`, `aur/owlry-plugin-system` from tracked files
- Push transitional PKGBUILDs to the 3 AUR repos:
```bash
pkgname=owlry-plugin-calculator # (and converter, system)
pkgver=<last_version>
pkgrel=99
pkgdesc="Transitional package — calculator is now built into owlry-core"
arch=('any')
depends=('owlry-core>=<new_version>')
replaces=('owlry-plugin-calculator')
# No source, no build, no package body
```
### Conflict prevention
When owlry-core gains built-in calculator/converter/system, users who have the old `.so` plugins installed will have both the built-in provider AND the `.so` plugin active — duplicate results. The daemon should detect this: if a built-in provider ID matches a loaded native plugin ID, skip the native plugin. Add this check in `ProviderManager` when registering native plugins.
## README Updates
### Main repo README
- Package table: remove separate plugin entries for calculator, converter, system — note them as built-in to owlry-core
- Remove meta package section entirely
- Update install examples (no need to install calculator/converter/system separately)
### Plugins repo README
- Remove calculator, converter, system from plugin listing
- Add note that these 3 are built into owlry-core
## Testing
- Port existing plugin tests directly — they test provider logic, not FFI wrappers
- `cargo test -p owlry-core --lib` covers all 3 new providers
- Add conflict detection test (built-in provider ID vs native plugin ID)
- Manual verification: `= 5+3` (calc), `20F` (conv), `20 euro to dollar` (currency), system actions

View File

@@ -0,0 +1,187 @@
# Config Editor — Design Spec
## Goal
A built-in provider in owlry-core that lets users browse and modify their configuration directly from the launcher UI, without opening a text editor.
## Scope
### Editable settings (curated)
**Provider toggles** (boolean):
- applications, commands, calculator, converter, system
- websearch, ssh, clipboard, bookmarks, emoji, scripts, files
- media, weather, pomodoro
- uuctl (systemd user units)
**Appearance** (text input + selection):
- theme (selection from available themes)
- font_size (numeric input)
- width, height (numeric input)
- border_radius (numeric input)
**Search** (text input + selection):
- search_engine (selection: google, duckduckgo, bing, startpage, brave, ecosia)
- frecency (boolean toggle)
- frecency_weight (numeric input, 0.01.0)
**Profiles** (CRUD):
- List existing profiles
- Create new profile (name input + mode checklist)
- Edit profile (rename, edit modes, delete)
### Not in scope
- Weather API key / location (sensitive, better in config file)
- Pomodoro durations (niche, config file)
- Plugin disabled list (covered by provider toggles)
- use_uwsm / terminal_command (advanced, config file)
## UX Flow
### Entry point
Type `:config` or select the "Settings" item that appears for queries like "settings", "config", "preferences".
### Top-level categories
```
:config →
┌─ Providers Toggle providers on/off
├─ Appearance Theme, font size, dimensions
├─ Search Search engine, frecency
└─ Profiles Manage named mode sets
```
Each category is a submenu item. Selecting one opens its submenu.
### Provider toggles
```
Providers →
┌─ ✓ Applications [toggle]
├─ ✓ Commands [toggle]
├─ ✓ Calculator [toggle]
├─ ✓ Converter [toggle]
├─ ✓ System [toggle]
├─ ✗ Weather [toggle]
├─ ...
```
Selecting a row toggles it. The ✓/✗ prefix updates immediately. Change is written to `config.toml` and hot-applied where possible.
### Appearance settings
```
Appearance →
┌─ Theme: owl [select]
├─ Font Size: 14 [edit]
├─ Width: 850 [edit]
├─ Height: 650 [edit]
└─ Border Radius: 12 [edit]
```
**Selection fields** (theme): Selecting opens a submenu with available options. Current value is marked with ✓.
**Text/numeric fields** (font size, width, etc.): Selecting a row enters edit mode — the search bar clears and shows a placeholder like "Font Size (current: 14)". User types a new value and presses Enter. The value is validated (numeric, within reasonable range), written to config, and the submenu re-displays with the updated value.
### Search settings
```
Search →
┌─ Search Engine: duckduckgo [select]
├─ Frecency: enabled [toggle]
└─ Frecency Weight: 0.3 [edit]
```
Same patterns — selection for engine, toggle for frecency, text input for weight.
### Profile management
```
Profiles →
┌─ dev (app, cmd, ssh) [submenu]
├─ media (media, emoji) [submenu]
└─ Create New Profile [action]
```
**Select existing profile** → submenu:
```
Profile: dev →
┌─ Edit Modes [submenu → checklist]
├─ Rename [text input]
└─ Delete [confirm action]
```
**Edit Modes** → checklist (same as provider toggles but for the profile's mode list):
```
Edit Modes: dev →
┌─ ✓ app
├─ ✓ cmd
├─ ✗ calc
├─ ✗ conv
├─ ✓ ssh
├─ ...
```
Toggle to include/exclude. Changes saved on submenu exit (Escape).
**Create New Profile**:
1. Search bar becomes name input (placeholder: "Profile name...")
2. User types name, presses Enter
3. Opens mode checklist (all unchecked)
4. Toggle desired modes, press Escape to save
**Delete**: Selecting "Delete" removes the profile from config and returns to the profiles list.
## Architecture
### Provider type
Built-in static provider in owlry-core. Uses `ProviderType::Plugin("config")` with prefix `:config`.
### Provider classification
**Static** — the top-level items (Providers, Appearance, Search, Profiles) are populated at refresh time. But it also needs **submenu support** — each category opens a submenu with actions.
This means the config provider needs to handle `?SUBMENU:` queries to generate submenu items dynamically, and `!ACTION:` commands to execute changes.
### Command protocol
Actions use the existing plugin action system (`PluginAction` IPC request):
- `CONFIG:toggle:providers.calculator` — toggle a boolean
- `CONFIG:set:appearance.font_size:16` — set a value
- `CONFIG:set:providers.search_engine:google` — set a string
- `CONFIG:profile:create:dev` — create a profile
- `CONFIG:profile:delete:dev` — delete a profile
- `CONFIG:profile:rename:dev:development` — rename
- `CONFIG:profile:mode:dev:toggle:ssh` — toggle a mode in a profile
### Config persistence
All changes write to `~/.config/owlry/config.toml` via the existing `Config::save()` method.
### Hot-apply behavior
| Setting | Hot-apply | Notes |
|---------|-----------|-------|
| Provider toggles | Yes | Daemon re-reads config, enables/disables providers |
| Theme | Yes | UI reloads CSS |
| Frecency toggle/weight | Yes | Next search uses new value |
| Search engine | Yes | Next web search uses new engine |
| Font size | Restart | CSS variable, needs reload |
| Width/Height | Restart | GTK window geometry set at construction |
| Border radius | Restart | CSS variable, needs reload |
| Profiles | Yes | Config file update, available on next `--profile` launch |
Settings that require restart show a "(restart to apply)" hint in the description.
### Submenu integration
The config provider uses the existing submenu system:
- Top-level items have `SUBMENU:config:{category}` commands
- Categories return action items via `?SUBMENU:{category}`
- Actions execute via `CONFIG:*` commands through `execute_plugin_action`
This keeps the implementation within the existing provider/submenu architecture without new IPC message types.

568
justfile
View File

@@ -1,65 +1,57 @@
# Owlry build and release automation # Owlry build and release automation
# Default recipe
default: default:
@just --list @just --list
# Build debug (all workspace members) # === Build ===
build: build:
cargo build --workspace cargo build --workspace
# Build UI binary only
build-ui: build-ui:
cargo build -p owlry cargo build -p owlry
# Build core daemon only
build-daemon: build-daemon:
cargo build -p owlry-core cargo build -p owlry-core
# Build core daemon release
release-daemon:
cargo build -p owlry-core --release
# Run core daemon
run-daemon *ARGS:
cargo run -p owlry-core -- {{ARGS}}
# Build release
release: release:
cargo build --workspace --release cargo build --workspace --release
# Run in debug mode release-daemon:
cargo build -p owlry-core --release
# === Run ===
run *ARGS: run *ARGS:
cargo run -p owlry -- {{ARGS}} cargo run -p owlry -- {{ARGS}}
# Run tests run-daemon *ARGS:
cargo run -p owlry-core -- {{ARGS}}
# === Quality ===
test: test:
cargo test --workspace cargo test --workspace
# Check code
check: check:
cargo check --workspace cargo check --workspace
cargo clippy --workspace cargo clippy --workspace
# Format code
fmt: fmt:
cargo fmt --all cargo fmt --all
# Clean build artifacts
clean: clean:
cargo clean cargo clean
# Install locally (core + runtimes) # === Install ===
install-local: install-local:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
echo "Building release..." echo "Building release..."
# Build UI without embedded Lua (smaller binary)
cargo build -p owlry --release --no-default-features cargo build -p owlry --release --no-default-features
# Build core daemon
cargo build -p owlry-core --release cargo build -p owlry-core --release
# Build runtimes
cargo build -p owlry-lua -p owlry-rune --release cargo build -p owlry-lua -p owlry-rune --release
echo "Creating directories..." echo "Creating directories..."
@@ -68,58 +60,24 @@ install-local:
echo "Installing binaries..." echo "Installing binaries..."
sudo install -Dm755 target/release/owlry /usr/bin/owlry sudo install -Dm755 target/release/owlry /usr/bin/owlry
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core sudo install -Dm755 target/release/owlryd /usr/bin/owlryd
echo "Installing runtimes..." echo "Installing runtimes..."
if [ -f "target/release/libowlry_lua.so" ]; then [ -f target/release/libowlry_lua.so ] && sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so [ -f target/release/libowlry_rune.so ] && sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
echo " → liblua.so"
fi
if [ -f "target/release/libowlry_rune.so" ]; then
sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
echo " → librune.so"
fi
echo "Installing systemd service files..." echo "Installing systemd service files..."
if [ -f "systemd/owlry-core.service" ]; then [ -f systemd/owlryd.service ] && sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service
sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service [ -f systemd/owlryd.socket ] && sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket
echo " → owlry-core.service"
fi
if [ -f "systemd/owlry-core.socket" ]; then
sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
echo " → owlry-core.socket"
fi
echo "" echo "Done. Start daemon: systemctl --user enable --now owlryd.service"
echo "Installation complete!"
echo " - /usr/bin/owlry (UI)"
echo " - /usr/bin/owlry-core (daemon)"
echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes"
echo " - systemd: owlry-core.service, owlry-core.socket"
echo ""
echo "To start the daemon:"
echo " systemctl --user enable --now owlry-core.service"
echo " OR add 'exec-once = owlry-core' to your compositor config"
echo ""
echo "Note: Install plugins separately from the owlry-plugins repo."
# === Release Management === # === Version Management ===
# AUR package directories (relative to project root)
aur_core_dir := "aur/owlry"
# Get current version from core crate
version := `grep '^version' crates/owlry/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'`
# Show current version
show-version:
@echo "Current version: {{version}}"
# Show all crate versions
show-versions: show-versions:
#!/usr/bin/env bash #!/usr/bin/env bash
echo "=== Crate Versions ===" echo "=== Crate Versions ==="
for toml in Cargo.toml crates/*/Cargo.toml; do for toml in crates/*/Cargo.toml; do
name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
printf " %-30s %s\n" "$name" "$ver" printf " %-30s %s\n" "$name" "$ver"
@@ -129,20 +87,16 @@ show-versions:
crate-version crate: crate-version crate:
@grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/' @grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'
# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0) # Bump a single crate version, update Cargo.lock, commit
bump-crate crate new_version: bump-crate crate new_version:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
toml="crates/{{crate}}/Cargo.toml" toml="crates/{{crate}}/Cargo.toml"
if [ ! -f "$toml" ]; then [ -f "$toml" ] || { echo "Error: $toml not found"; exit 1; }
echo "Error: $toml not found"
exit 1
fi
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" = "{{new_version}}" ]; then [ "$old" = "{{new_version}}" ] && { echo "{{crate}} already at {{new_version}}"; exit 0; }
echo "{{crate}} is already at {{new_version}}, skipping"
exit 0
fi
echo "Bumping {{crate}} from $old to {{new_version}}" echo "Bumping {{crate}} from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
cargo check -p {{crate}} cargo check -p {{crate}}
@@ -150,7 +104,214 @@ bump-crate crate new_version:
git commit -m "chore({{crate}}): bump version to {{new_version}}" git commit -m "chore({{crate}}): bump version to {{new_version}}"
echo "{{crate}} bumped to {{new_version}}" echo "{{crate}} bumped to {{new_version}}"
# Bump meta-packages (no crate, just AUR version) # Bump all crates to same version
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
for toml in crates/*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
[ "$old" = "{{new_version}}" ] && continue
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
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 UI only
bump new_version:
just bump-crate owlry {{new_version}}
# === Tagging ===
# Tag a specific crate (format: {crate}-v{version})
tag-crate crate:
#!/usr/bin/env bash
set -euo pipefail
ver=$(grep '^version' "crates/{{crate}}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
tag="{{crate}}-v$ver"
if git rev-parse "$tag" >/dev/null 2>&1; then
echo "Tag $tag already exists"
exit 0
fi
git tag -a "$tag" -m "{{crate}} v$ver"
echo "Created tag $tag"
# Push all local tags
push-tags:
git push --tags
# === AUR Package Management ===
# Stage AUR files into the main repo git index.
# AUR subdirs have their own .git (for aur.archlinux.org), which makes
# git treat them as embedded repos. Temporarily hide .git to stage files.
aur-stage pkg:
#!/usr/bin/env bash
set -euo pipefail
dir="aur/{{pkg}}"
[ -d "$dir" ] || { echo "Error: $dir not found"; exit 1; }
# Build list of files to stage
files=("$dir/PKGBUILD" "$dir/.SRCINFO")
for f in "$dir"/*.install; do
[ -f "$f" ] && files+=("$f")
done
if [ -d "$dir/.git" ]; then
mv "$dir/.git" "$dir/.git.bak"
git add "${files[@]}"
mv "$dir/.git.bak" "$dir/.git"
else
git add "${files[@]}"
fi
# Update a specific AUR package PKGBUILD with correct version + checksum
aur-update-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
[ -d "$aur_dir" ] || { echo "Error: $aur_dir not found"; exit 1; }
# Determine version
case "{{pkg}}" in
owlry-meta-*)
ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
echo "Meta-package {{pkg}} at $ver (bump pkgrel manually if needed)"
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
exit 0
;;
*)
crate_dir="crates/{{pkg}}"
[ -d "$crate_dir" ] || { echo "Error: $crate_dir not found"; exit 1; }
ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
;;
esac
tag="{{pkg}}-v$ver"
url="https://somegit.dev/Owlibou/owlry/archive/$tag.tar.gz"
echo "Updating {{pkg}} to $ver (tag: $tag)"
sed -i "s/^pkgver=.*/pkgver=$ver/" "$aur_dir/PKGBUILD"
sed -i 's/^pkgrel=.*/pkgrel=1/' "$aur_dir/PKGBUILD"
# Update checksum from the tagged tarball
if grep -q "^source=" "$aur_dir/PKGBUILD"; then
echo "Downloading tarball and computing checksum..."
hash=$(curl -sL "$url" | b2sum | cut -d' ' -f1)
if [ -z "$hash" ] || [ ${#hash} -lt 64 ]; then
echo "Error: failed to download or hash $url"
exit 1
fi
sed -i "s|^b2sums=.*|b2sums=('$hash')|" "$aur_dir/PKGBUILD"
fi
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
echo "{{pkg}} PKGBUILD updated to $ver"
# Shortcut: update core UI AUR package
aur-update:
just aur-update-pkg owlry
# Publish a specific AUR package to aur.archlinux.org
aur-publish-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
[ -d "$aur_dir/.git" ] || { echo "Error: $aur_dir has no AUR git repo"; exit 1; }
cd "$aur_dir"
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
git add -A
git commit -m "Update to v$ver" || { echo "Nothing to commit"; exit 0; }
git push origin master
echo "{{pkg}} v$ver published to AUR!"
# Shortcut: publish core UI to AUR
aur-publish:
just aur-publish-pkg owlry
# Update and publish ALL AUR packages
aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
echo "=== $pkg ==="
just aur-update-pkg "$pkg"
echo ""
done
echo "All updated. Run 'just aur-publish-all' to publish."
aur-publish-all:
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -d "$dir/.git" ] || continue
echo "=== $pkg ==="
just aur-publish-pkg "$pkg"
echo ""
done
echo "All published!"
# Show AUR package status
aur-status:
#!/usr/bin/env bash
echo "=== AUR Package Status ==="
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
if [ -d "$dir/.git" ]; then
printf " ✓ %-30s %s\n" "$pkg" "$ver"
else
printf " ✗ %-30s %s (no AUR repo)\n" "$pkg" "$ver"
fi
done
# Commit AUR file changes to the main repo (handles embedded .git dirs)
aur-commit msg="chore(aur): update PKGBUILDs":
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
just aur-stage "$pkg"
done
git diff --cached --quiet && { echo "No AUR changes to commit"; exit 0; }
git commit -m "{{msg}}"
# === Release Workflows ===
# Release a single crate: bump → push → tag → update AUR → publish AUR
release-crate crate new_version:
#!/usr/bin/env bash
set -euo pipefail
just bump-crate {{crate}} {{new_version}}
git push
just tag-crate {{crate}}
just push-tags
echo "Waiting for tag to propagate..."
sleep 3
just aur-update-pkg {{crate}}
just aur-commit "chore(aur): update {{crate}} to {{new_version}}"
git push
just aur-publish-pkg {{crate}}
echo ""
echo "{{crate}} v{{new_version}} released and published to AUR!"
# === Meta Package Management ===
# Bump meta-package versions
bump-meta new_version: bump-meta new_version:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -165,271 +326,14 @@ bump-meta new_version:
done done
echo "Meta-packages bumped to {{new_version}}" echo "Meta-packages bumped to {{new_version}}"
# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version # === Testing ===
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
for toml in crates/*/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
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 new_version:
#!/usr/bin/env bash
set -euo pipefail
if [ "{{version}}" = "{{new_version}}" ]; then
echo "Version is already {{new_version}}, skipping bump"
exit 0
fi
echo "Bumping core version from {{version}} to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' crates/owlry/Cargo.toml
cargo check -p owlry
git add crates/owlry/Cargo.toml Cargo.lock
git commit -m "chore: bump version to {{new_version}}"
echo "Version bumped to {{new_version}}"
# Create and push a release tag
tag:
#!/usr/bin/env bash
set -euo pipefail
if git rev-parse "v{{version}}" >/dev/null 2>&1; then
echo "Tag v{{version}} already exists, skipping"
exit 0
fi
echo "Creating tag v{{version}}"
git tag -a "v{{version}}" -m "Release v{{version}}"
git push origin "v{{version}}"
echo "Tag v{{version}} pushed"
# Update AUR package (core UI)
aur-update:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
url="https://somegit.dev/Owlibou/owlry"
echo "Updating PKGBUILD to version {{version}}"
sed -i 's/^pkgver=.*/pkgver={{version}}/' PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums (b2sums)
echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v{{version}}.tar.gz" | b2sum | cut -d' ' -f1)
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
# Generate .SRCINFO
echo "Generating .SRCINFO..."
makepkg --printsrcinfo > .SRCINFO
# Show diff
git diff
echo ""
echo "AUR package updated. Review changes above."
echo "Run 'just aur-publish' to commit and push."
# Publish AUR package (core UI)
aur-publish:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
git add PKGBUILD .SRCINFO
git commit -m "Update to v{{version}}"
git push
echo "AUR package v{{version}} published!"
# Test AUR package build locally (core UI)
aur-test:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
echo "Testing PKGBUILD..."
makepkg -sf
echo ""
echo "Package built successfully!"
ls -lh *.pkg.tar.zst
# === AUR Package Management (individual packages) ===
# Update a specific AUR package (usage: just aur-update-pkg owlry-core)
aur-update-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
if [ ! -d "$aur_dir" ]; then
echo "Error: $aur_dir not found"
exit 1
fi
url="https://somegit.dev/Owlibou/owlry"
# Determine crate version
case "{{pkg}}" in
owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full)
# Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes
crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
;;
*)
# Get version from crate
crate_dir="crates/{{pkg}}"
if [ ! -d "$crate_dir" ]; then
echo "Error: $crate_dir not found"
exit 1
fi
crate_ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
;;
esac
cd "$aur_dir"
echo "Updating {{pkg}} PKGBUILD:"
echo " pkgver=$crate_ver"
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums
if grep -q "^source=" PKGBUILD; then
echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
fi
# Generate .SRCINFO
echo "Generating .SRCINFO..."
makepkg --printsrcinfo > .SRCINFO
git diff --stat
echo ""
echo "{{pkg}} updated. Run 'just aur-publish-pkg {{pkg}}' to publish."
# Publish a specific AUR package
aur-publish-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
if [ ! -d "$aur_dir" ]; then
echo "Error: $aur_dir not found"
exit 1
fi
cd "$aur_dir"
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
git add PKGBUILD .SRCINFO
git commit -m "Update to v$ver"
git push origin master
echo "{{pkg}} v$ver published!"
# Test a specific AUR package build locally # Test a specific AUR package build locally
aur-test-pkg pkg: aur-test-pkg pkg:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "aur/{{pkg}}" cd "aur/{{pkg}}"
echo "Testing {{pkg}} PKGBUILD..." echo "Testing {{pkg}} PKGBUILD..."
makepkg -sf makepkg -sf
echo ""
echo "Package built successfully!" echo "Package built successfully!"
ls -lh *.pkg.tar.zst ls -lh *.pkg.tar.zst
# List all AUR packages with their versions
aur-status:
#!/usr/bin/env bash
echo "=== AUR Package Status ==="
for dir in aur/*/; do
pkg=$(basename "$dir")
if [ -f "$dir/PKGBUILD" ]; then
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
if [ -d "$dir/.git" ]; then
status="✓"
else
status="✗ (not initialized)"
fi
printf " %s %-30s %s\n" "$status" "$pkg" "$ver"
fi
done
# Update ALL AUR packages (core + daemon + runtimes + meta)
aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Updating core UI ==="
just aur-update
echo ""
echo "=== Updating core daemon ==="
just aur-update-pkg owlry-core
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 UI ==="
just aur-publish
echo ""
echo "=== Publishing core daemon ==="
just aur-publish-pkg owlry-core
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
set -euo pipefail
# Push version bump
git push
# Create and push tag
just tag
# Wait for tag to be available
echo "Waiting for tag to propagate..."
sleep 2
# Update AUR
just aur-update
echo ""
echo "Core release v{{new_version}} prepared!"
echo "Review AUR changes, then run 'just aur-publish'"

View File

@@ -5,7 +5,7 @@ After=graphical-session.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/bin/owlry-core ExecStart=/usr/bin/owlryd
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
Environment=RUST_LOG=warn Environment=RUST_LOG=warn