Compare commits
35 Commits
owlry-v1.0
...
owlry-core
| Author | SHA1 | Date | |
|---|---|---|---|
| 1adec7bf47 | |||
| 7f07a93dec | |||
| 7351ba868e | |||
| 44e1430ea5 | |||
| 80312a28f7 | |||
| 37abe98c9b | |||
| d95b81bbcb | |||
| 562b38deba | |||
| 2888677e38 | |||
| 940ad58ee2 | |||
| 18775d71fc | |||
| f189f4b1ce | |||
| 422ea6d816 | |||
| 8b444eec3b | |||
| 6d0bf1c401 | |||
| c8d8298274 | |||
| 62f6e1d4b0 | |||
| bf1d759cb2 | |||
| 3f9f4bb112 | |||
| c5f1f35167 | |||
| 81626c33dd | |||
| 99d38a66b8 | |||
| 8b4c704501 | |||
| 27e296e333 | |||
| 173d72ad43 | |||
| 3eea902c7f | |||
| a12e850c94 | |||
| eccfb217d4 | |||
| c3c35611fd | |||
| 5ecd0a6412 | |||
| 6fe7213b6f | |||
| b768bfd181 | |||
| c9a1ff28f4 | |||
| 623572ec14 | |||
| 5196255594 |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -2536,7 +2536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owlry"
|
||||
version = "1.0.4"
|
||||
version = "1.0.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -2557,7 +2557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owlry-core"
|
||||
version = "1.1.2"
|
||||
version = "1.3.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ctrlc",
|
||||
|
||||
118
README.md
118
README.md
@@ -11,16 +11,18 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
||||
## Features
|
||||
|
||||
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
|
||||
- **Modular plugin architecture** — Install only what you need
|
||||
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
|
||||
- **14 native plugins** — Calculator, clipboard, emoji, weather, media, and more
|
||||
- **Built-in providers** — Calculator, unit/currency converter, and system actions out of the box
|
||||
- **Built-in settings editor** — Configure everything from within the launcher (`:config`)
|
||||
- **11 optional plugins** — Clipboard, emoji, weather, media, bookmarks, and more
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **dmenu compatible** — Pipe-based selection mode, no daemon required
|
||||
- **Extensible** — Create custom plugins in Lua or Rune
|
||||
|
||||
## Installation
|
||||
@@ -28,19 +30,13 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Minimal core (applications + commands only)
|
||||
# Core (includes calculator, converter, system actions, settings editor)
|
||||
yay -S owlry
|
||||
|
||||
# Add individual plugins
|
||||
yay -S owlry-plugin-calculator owlry-plugin-weather
|
||||
# Add individual plugins as needed
|
||||
yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
|
||||
|
||||
# Or install bundles:
|
||||
yay -S owlry-meta-essentials # calculator, converter, 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
|
||||
# For custom Lua/Rune user plugins
|
||||
yay -S owlry-lua # Lua 5.4 runtime
|
||||
yay -S owlry-rune # Rune runtime
|
||||
```
|
||||
@@ -52,7 +48,7 @@ yay -S owlry-rune # Rune runtime
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry` | GTK4 UI client |
|
||||
| `owlry-core` | Headless daemon (plugin host, IPC server) |
|
||||
| `owlry-core` | Daemon (`owlryd`) with built-in calculator, converter, system, and settings providers |
|
||||
| `owlry-lua` | Lua 5.4 script runtime for user plugins |
|
||||
| `owlry-rune` | Rune script runtime for user plugins |
|
||||
|
||||
@@ -61,28 +57,18 @@ yay -S owlry-rune # Rune runtime
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
|
||||
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) |
|
||||
| `owlry-plugin-clipboard` | History via cliphist |
|
||||
| `owlry-plugin-converter` | Unit and currency conversion |
|
||||
| `owlry-plugin-emoji` | 400+ searchable emoji |
|
||||
| `owlry-plugin-filesearch` | File search (`/ filename`) |
|
||||
| `owlry-plugin-media` | MPRIS media controls |
|
||||
| `owlry-plugin-pomodoro` | Pomodoro timer widget |
|
||||
| `owlry-plugin-scripts` | User scripts |
|
||||
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
|
||||
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock |
|
||||
| `owlry-plugin-systemd` | User services with actions |
|
||||
| `owlry-plugin-weather` | Weather widget |
|
||||
| `owlry-plugin-websearch` | Web search (`? query`) |
|
||||
|
||||
**Meta bundles:**
|
||||
|
||||
| Package | Includes |
|
||||
|---------|----------|
|
||||
| `owlry-meta-essentials` | bookmarks, calculator, converter, scripts, ssh, system |
|
||||
| `owlry-meta-tools` | clipboard, emoji, filesearch, systemd, websearch |
|
||||
| `owlry-meta-widgets` | media, pomodoro, weather |
|
||||
| `owlry-meta-full` | All plugins + runtimes |
|
||||
> **Note:** Calculator, converter, and system actions are built into `owlry-core` and do not require separate packages.
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -117,7 +103,7 @@ cargo build --release --workspace
|
||||
```bash
|
||||
git clone https://somegit.dev/Owlibou/owlry-plugins.git
|
||||
cd owlry-plugins
|
||||
cargo build --release -p owlry-plugin-calculator # or any plugin
|
||||
cargo build --release -p owlry-plugin-bookmarks # or any plugin
|
||||
```
|
||||
|
||||
**Install locally:**
|
||||
@@ -125,11 +111,11 @@ cargo build --release -p owlry-plugin-calculator # or any plugin
|
||||
just install-local
|
||||
```
|
||||
|
||||
This installs the UI, daemon, runtimes, and systemd service files.
|
||||
This installs the UI (`owlry`), daemon (`owlryd`), runtimes, and systemd service files.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -141,25 +127,25 @@ Add to your compositor config:
|
||||
|
||||
```bash
|
||||
# Hyprland (~/.config/hypr/hyprland.conf)
|
||||
exec-once = owlry-core
|
||||
exec-once = owlryd
|
||||
|
||||
# Sway (~/.config/sway/config)
|
||||
exec owlry-core
|
||||
exec owlryd
|
||||
```
|
||||
|
||||
**2. Systemd user service**
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now owlry-core.service
|
||||
systemctl --user enable --now owlryd.service
|
||||
```
|
||||
|
||||
**3. Socket activation (auto-start on first use)**
|
||||
|
||||
```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
|
||||
|
||||
@@ -173,7 +159,7 @@ bind = SUPER, 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.
|
||||
|
||||
@@ -183,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 -m app # Applications 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 --help # Show all options with examples
|
||||
```
|
||||
@@ -218,14 +204,16 @@ bind = SUPER, D, exec, owlry --profile dev
|
||||
bind = SUPER, M, exec, owlry --profile media
|
||||
```
|
||||
|
||||
Profiles can also be managed from the launcher itself — see [Settings Editor](#settings-editor).
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
# Screenshot menu (execute selected command)
|
||||
# Screenshot menu
|
||||
printf '%s\n' \
|
||||
"grimblast --notify copy screen" \
|
||||
"grimblast --notify copy area" \
|
||||
@@ -244,9 +232,6 @@ find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
|
||||
|
||||
# Package manager search
|
||||
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
|
||||
|
||||
# Open selected file
|
||||
ls ~/Documents | owlry -m dmenu | xargs xdg-open
|
||||
```
|
||||
|
||||
The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
@@ -262,6 +247,24 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
| `Shift+Tab` | Cycle filter tabs (reverse) |
|
||||
| `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
|
||||
|
||||
| Prefix | Provider | Example |
|
||||
@@ -278,6 +281,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
| `:calc` | Calculator | `:calc sqrt(16)` |
|
||||
| `:web` | Web search | `:web rust docs` |
|
||||
| `:uuctl` | systemd | `:uuctl docker` |
|
||||
| `:config` | Settings | `:config theme` |
|
||||
| `:tag:X` | Filter by tag | `:tag:development` |
|
||||
|
||||
### Trigger Prefixes
|
||||
@@ -286,6 +290,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
|---------|----------|---------|
|
||||
| `=` | Calculator | `= 5+3` |
|
||||
| `calc ` | Calculator | `calc sqrt(16)` |
|
||||
| `>` | Converter | `> 20 km to mi` |
|
||||
| `?` | Web search | `? rust programming` |
|
||||
| `web ` | Web search | `web linux tips` |
|
||||
| `/` | File search | `/ .bashrc` |
|
||||
@@ -305,6 +310,7 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
|
||||
| `~/.local/share/owlry/frecency.json` | Usage history |
|
||||
|
||||
System locations:
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
|
||||
@@ -319,6 +325,8 @@ mkdir -p ~/.config/owlry
|
||||
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
|
||||
|
||||
```toml
|
||||
@@ -342,6 +350,9 @@ disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
|
||||
[providers]
|
||||
applications = true # .desktop files
|
||||
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_weight = 0.3 # 0.0-1.0
|
||||
|
||||
@@ -360,7 +371,7 @@ See `/usr/share/doc/owlry/config.example.toml` for all options with documentatio
|
||||
|
||||
## 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)
|
||||
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
|
||||
@@ -374,6 +385,8 @@ Add plugin IDs to the disabled list in your config:
|
||||
disabled = ["emoji", "pomodoro"]
|
||||
```
|
||||
|
||||
Or toggle providers interactively: type `:config providers` in the launcher.
|
||||
|
||||
### Plugin Management CLI
|
||||
|
||||
```bash
|
||||
@@ -429,12 +442,15 @@ See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
|
||||
| `tokyo-night` | Tokyo city lights |
|
||||
| `solarized-dark` | Precision colors |
|
||||
| `one-dark` | Atom's One Dark |
|
||||
| `apex-neon` | Neon cyberpunk |
|
||||
|
||||
```toml
|
||||
[appearance]
|
||||
theme = "catppuccin-mocha"
|
||||
```
|
||||
|
||||
Or select interactively: type `:config theme` in the launcher.
|
||||
|
||||
### Custom Theme
|
||||
|
||||
Create `~/.config/owlry/themes/mytheme.css`:
|
||||
@@ -462,18 +478,24 @@ Create `~/.config/owlry/themes/mytheme.css`:
|
||||
| `--owlry-text-secondary` | Muted text |
|
||||
| `--owlry-accent` | Accent color |
|
||||
| `--owlry-accent-bright` | Bright accent |
|
||||
| `--owlry-shadow` | Window shadow (default: none) |
|
||||
|
||||
## Architecture
|
||||
|
||||
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
|
||||
├── Applications provider ├── Renders results in GTK4 window
|
||||
├── Commands provider ├── Handles keyboard input
|
||||
├── Plugin loader ├── Toggle: second launch closes window
|
||||
│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon)
|
||||
├── Built-in providers ├── Renders results in GTK4 window
|
||||
│ ├── Applications (.desktop) ├── Handles keyboard input
|
||||
│ ├── Commands (PATH) ├── Toggle: second launch closes window
|
||||
│ ├── 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/
|
||||
│ └── ~/.config/owlry/plugins/
|
||||
├── Frecency tracking
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
pkgbase = owlry-core
|
||||
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
|
||||
pkgver = 1.1.1
|
||||
pkgver = 1.3.0
|
||||
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.1.1.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.1.1.tar.gz
|
||||
b2sums = 2924468a55fa62979b324c0c48cff2fa13e348f1d21a6ca5e19596bfbeb88fc932b285586275b219bcd75cacc72c1d1d9fecfe13c90dcbc4b258a193bcda1047
|
||||
source = owlry-core-1.3.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.3.0.tar.gz
|
||||
b2sums = 99b3ce396b3903bf4427209df20356bbbfb16edd267be39db3ff91ccf0e3931a2ec03c47b278ba48b9af3bba8c36d50774899c365f05ec966ce7799512e18424
|
||||
|
||||
pkgname = owlry-core
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-core
|
||||
pkgver=1.1.1
|
||||
pkgver=1.3.0
|
||||
pkgrel=1
|
||||
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
|
||||
arch=('x86_64')
|
||||
@@ -9,7 +9,7 @@ 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=('2924468a55fa62979b324c0c48cff2fa13e348f1d21a6ca5e19596bfbeb88fc932b285586275b219bcd75cacc72c1d1d9fecfe13c90dcbc4b258a193bcda1047')
|
||||
b2sums=('99b3ce396b3903bf4427209df20356bbbfb16edd267be39db3ff91ccf0e3931a2ec03c47b278ba48b9af3bba8c36d50774899c365f05ec966ce7799512e18424')
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
@@ -33,9 +33,9 @@ check() {
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
install -Dm755 "target/release/owlry-core" "$pkgdir/usr/bin/owlry-core"
|
||||
install -Dm644 "systemd/owlry-core.service" "$pkgdir/usr/lib/systemd/user/owlry-core.service"
|
||||
install -Dm644 "systemd/owlry-core.socket" "$pkgdir/usr/lib/systemd/user/owlry-core.socket"
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
pkgbase = owlry-meta-essentials
|
||||
pkgdesc = Essential plugin bundle for Owlry (calculator, converter, system, ssh, scripts, bookmarks)
|
||||
pkgver = 1.0.0
|
||||
pkgrel = 2
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = any
|
||||
license = GPL-3.0-or-later
|
||||
depends = owlry
|
||||
depends = owlry-core
|
||||
depends = owlry-plugin-bookmarks
|
||||
depends = owlry-plugin-calculator
|
||||
depends = owlry-plugin-converter
|
||||
depends = owlry-plugin-scripts
|
||||
depends = owlry-plugin-ssh
|
||||
depends = owlry-plugin-system
|
||||
conflicts = owlry-essentials
|
||||
replaces = owlry-essentials
|
||||
|
||||
pkgname = owlry-meta-essentials
|
||||
@@ -1,20 +0,0 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-meta-essentials
|
||||
pkgver=1.0.0
|
||||
pkgrel=2
|
||||
pkgdesc="Essential plugin bundle for Owlry (calculator, converter, system, ssh, scripts, bookmarks)"
|
||||
arch=('any')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=(
|
||||
'owlry'
|
||||
'owlry-core'
|
||||
'owlry-plugin-bookmarks'
|
||||
'owlry-plugin-calculator'
|
||||
'owlry-plugin-converter'
|
||||
'owlry-plugin-scripts'
|
||||
'owlry-plugin-ssh'
|
||||
'owlry-plugin-system'
|
||||
)
|
||||
replaces=('owlry-essentials')
|
||||
conflicts=('owlry-essentials')
|
||||
@@ -1,29 +0,0 @@
|
||||
pkgbase = owlry-meta-full
|
||||
pkgdesc = Complete Owlry installation with all official plugins and runtimes
|
||||
pkgver = 1.0.0
|
||||
pkgrel = 2
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = any
|
||||
license = GPL-3.0-or-later
|
||||
depends = owlry
|
||||
depends = owlry-core
|
||||
depends = owlry-plugin-bookmarks
|
||||
depends = owlry-plugin-calculator
|
||||
depends = owlry-plugin-converter
|
||||
depends = owlry-plugin-scripts
|
||||
depends = owlry-plugin-ssh
|
||||
depends = owlry-plugin-system
|
||||
depends = owlry-plugin-clipboard
|
||||
depends = owlry-plugin-emoji
|
||||
depends = owlry-plugin-filesearch
|
||||
depends = owlry-plugin-systemd
|
||||
depends = owlry-plugin-websearch
|
||||
depends = owlry-plugin-media
|
||||
depends = owlry-plugin-pomodoro
|
||||
depends = owlry-plugin-weather
|
||||
optdepends = owlry-lua: Lua runtime for custom user plugins
|
||||
optdepends = owlry-rune: Rune runtime for custom user plugins
|
||||
conflicts = owlry-full
|
||||
replaces = owlry-full
|
||||
|
||||
pkgname = owlry-meta-full
|
||||
@@ -1,35 +0,0 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-meta-full
|
||||
pkgver=1.0.0
|
||||
pkgrel=2
|
||||
pkgdesc="Complete Owlry installation with all official plugins and runtimes"
|
||||
arch=('any')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=(
|
||||
'owlry'
|
||||
'owlry-core'
|
||||
# Essential plugins
|
||||
'owlry-plugin-bookmarks'
|
||||
'owlry-plugin-calculator'
|
||||
'owlry-plugin-converter'
|
||||
'owlry-plugin-scripts'
|
||||
'owlry-plugin-ssh'
|
||||
'owlry-plugin-system'
|
||||
# Tool plugins
|
||||
'owlry-plugin-clipboard'
|
||||
'owlry-plugin-emoji'
|
||||
'owlry-plugin-filesearch'
|
||||
'owlry-plugin-systemd'
|
||||
'owlry-plugin-websearch'
|
||||
# Widget plugins
|
||||
'owlry-plugin-media'
|
||||
'owlry-plugin-pomodoro'
|
||||
'owlry-plugin-weather'
|
||||
)
|
||||
optdepends=(
|
||||
'owlry-lua: Lua runtime for custom user plugins'
|
||||
'owlry-rune: Rune runtime for custom user plugins'
|
||||
)
|
||||
replaces=('owlry-full')
|
||||
conflicts=('owlry-full')
|
||||
@@ -1,18 +0,0 @@
|
||||
pkgbase = owlry-meta-tools
|
||||
pkgdesc = Tool plugin bundle for Owlry (clipboard, emoji, web search, file search, systemd)
|
||||
pkgver = 1.0.0
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = any
|
||||
license = GPL-3.0-or-later
|
||||
depends = owlry
|
||||
depends = owlry-core
|
||||
depends = owlry-plugin-clipboard
|
||||
depends = owlry-plugin-emoji
|
||||
depends = owlry-plugin-filesearch
|
||||
depends = owlry-plugin-systemd
|
||||
depends = owlry-plugin-websearch
|
||||
conflicts = owlry-tools
|
||||
replaces = owlry-tools
|
||||
|
||||
pkgname = owlry-meta-tools
|
||||
@@ -1,19 +0,0 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-meta-tools
|
||||
pkgver=1.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Tool plugin bundle for Owlry (clipboard, emoji, web search, file search, systemd)"
|
||||
arch=('any')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=(
|
||||
'owlry'
|
||||
'owlry-core'
|
||||
'owlry-plugin-clipboard'
|
||||
'owlry-plugin-emoji'
|
||||
'owlry-plugin-filesearch'
|
||||
'owlry-plugin-systemd'
|
||||
'owlry-plugin-websearch'
|
||||
)
|
||||
replaces=('owlry-tools')
|
||||
conflicts=('owlry-tools')
|
||||
@@ -1,16 +0,0 @@
|
||||
pkgbase = owlry-meta-widgets
|
||||
pkgdesc = Widget plugin bundle for Owlry (weather, media controls, pomodoro timer)
|
||||
pkgver = 1.0.0
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = any
|
||||
license = GPL-3.0-or-later
|
||||
depends = owlry
|
||||
depends = owlry-core
|
||||
depends = owlry-plugin-media
|
||||
depends = owlry-plugin-pomodoro
|
||||
depends = owlry-plugin-weather
|
||||
conflicts = owlry-widgets
|
||||
replaces = owlry-widgets
|
||||
|
||||
pkgname = owlry-meta-widgets
|
||||
@@ -1,17 +0,0 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-meta-widgets
|
||||
pkgver=1.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Widget plugin bundle for Owlry (weather, media controls, pomodoro timer)"
|
||||
arch=('any')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=(
|
||||
'owlry'
|
||||
'owlry-core'
|
||||
'owlry-plugin-media'
|
||||
'owlry-plugin-pomodoro'
|
||||
'owlry-plugin-weather'
|
||||
)
|
||||
replaces=('owlry-widgets')
|
||||
conflicts=('owlry-widgets')
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = owlry
|
||||
pkgdesc = Lightweight Wayland application launcher with plugin support
|
||||
pkgver = 1.0.3
|
||||
pkgver = 1.0.6
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
@@ -28,7 +28,7 @@ pkgbase = owlry
|
||||
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.3.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.3.tar.gz
|
||||
b2sums = e20a227d0d5fd957155f7edbe5eadb24acf22b1f89df0620a619770f20f568621350a09973fe4d06aa0e4302e2929d4d770ad06e3c20c619af04eba17ab796de
|
||||
source = owlry-1.0.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.6.tar.gz
|
||||
b2sums = 8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05
|
||||
|
||||
pkgname = owlry
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry
|
||||
pkgver=1.0.3
|
||||
pkgver=1.0.6
|
||||
pkgrel=1
|
||||
pkgdesc="Lightweight Wayland application launcher with plugin support"
|
||||
arch=('x86_64')
|
||||
@@ -29,7 +29,7 @@ optdepends=(
|
||||
'owlry-rune: Rune runtime for user plugins'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
|
||||
b2sums=('e20a227d0d5fd957155f7edbe5eadb24acf22b1f89df0620a619770f20f568621350a09973fe4d06aa0e4302e2929d4d770ad06e3c20c619af04eba17ab796de')
|
||||
b2sums=('8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05')
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-core"
|
||||
version = "1.1.2"
|
||||
version = "1.3.1"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,7 +12,7 @@ name = "owlry_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "owlry-core"
|
||||
name = "owlryd"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
@@ -48,15 +48,17 @@ log = "0.4"
|
||||
env_logger = "0.11"
|
||||
notify-rust = "4"
|
||||
|
||||
# Built-in providers
|
||||
meval = "0.2"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
|
||||
|
||||
# Optional: embedded Lua runtime
|
||||
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]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
|
||||
lua = ["dep:mlua"]
|
||||
dev-logging = []
|
||||
|
||||
@@ -162,6 +162,9 @@ pub struct ProvidersConfig {
|
||||
/// Enable calculator provider (= expression or calc expression)
|
||||
#[serde(default = "default_true")]
|
||||
pub calculator: bool,
|
||||
/// Enable converter provider (> expression or auto-detect)
|
||||
#[serde(default = "default_true")]
|
||||
pub converter: bool,
|
||||
/// Enable frecency-based result ranking
|
||||
#[serde(default = "default_true")]
|
||||
pub frecency: bool,
|
||||
@@ -239,6 +242,7 @@ impl Default for ProvidersConfig {
|
||||
commands: true,
|
||||
uuctl: true,
|
||||
calculator: true,
|
||||
converter: true,
|
||||
frecency: true,
|
||||
frecency_weight: 0.3,
|
||||
websearch: true,
|
||||
|
||||
@@ -261,6 +261,10 @@ impl ProviderFilter {
|
||||
(":systemd ", "uuctl"),
|
||||
(":web ", "websearch"),
|
||||
(":search ", "websearch"),
|
||||
(":config ", "config"),
|
||||
(":settings ", "config"),
|
||||
(":conv ", "conv"),
|
||||
(":converter ", "conv"),
|
||||
];
|
||||
|
||||
// Check core prefixes
|
||||
@@ -327,6 +331,10 @@ impl ProviderFilter {
|
||||
(":systemd", "uuctl"),
|
||||
(":web", "websearch"),
|
||||
(":search", "websearch"),
|
||||
(":config", "config"),
|
||||
(":settings", "config"),
|
||||
(":conv", "conv"),
|
||||
(":converter", "conv"),
|
||||
];
|
||||
|
||||
for (prefix_str, provider) in partial_core {
|
||||
|
||||
@@ -7,7 +7,7 @@ fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
|
||||
let sock = paths::socket_path();
|
||||
info!("Starting owlry-core daemon...");
|
||||
info!("Starting owlryd daemon...");
|
||||
|
||||
// Ensure the socket parent directory exists
|
||||
if let Err(e) = paths::ensure_parent_dir(&sock) {
|
||||
@@ -18,7 +18,7 @@ fn main() {
|
||||
let server = match Server::bind(&sock) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start owlry-core: {e}");
|
||||
eprintln!("Failed to start owlryd: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
237
crates/owlry-core/src/providers/calculator.rs
Normal file
237
crates/owlry-core/src/providers/calculator.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
1192
crates/owlry-core/src/providers/config_editor.rs
Normal file
1192
crates/owlry-core/src/providers/config_editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
313
crates/owlry-core/src/providers/converter/currency.rs
Normal file
313
crates/owlry-core/src/providers/converter/currency.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
183
crates/owlry-core/src/providers/converter/mod.rs
Normal file
183
crates/owlry-core/src/providers/converter/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
235
crates/owlry-core/src/providers/converter/parser.rs
Normal file
235
crates/owlry-core/src/providers/converter/parser.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
944
crates/owlry-core/src/providers/converter/units.rs
Normal file
944
crates/owlry-core/src/providers/converter/units.rs
Normal 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!["m²", "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: "m²",
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// Core providers (no plugin equivalents)
|
||||
mod application;
|
||||
mod command;
|
||||
pub(crate) mod calculator;
|
||||
pub(crate) mod config_editor;
|
||||
pub(crate) mod converter;
|
||||
pub(crate) mod system;
|
||||
|
||||
// Native plugin bridge
|
||||
pub mod native_provider;
|
||||
@@ -104,10 +108,29 @@ pub trait Provider: Send + Sync {
|
||||
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
|
||||
pub struct ProviderManager {
|
||||
/// Core static providers (apps, commands, dmenu)
|
||||
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_providers: Vec<NativeProvider>,
|
||||
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
|
||||
@@ -136,6 +159,7 @@ impl ProviderManager {
|
||||
) -> Self {
|
||||
let mut manager = Self {
|
||||
providers: core_providers,
|
||||
builtin_dynamic: Vec::new(),
|
||||
static_native_providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
@@ -178,6 +202,25 @@ impl ProviderManager {
|
||||
manager
|
||||
}
|
||||
|
||||
/// Get type IDs of built-in providers (for conflict detection with native plugins)
|
||||
fn builtin_type_ids(&self) -> std::collections::HashSet<String> {
|
||||
let mut ids: std::collections::HashSet<String> = self
|
||||
.builtin_dynamic
|
||||
.iter()
|
||||
.filter_map(|p| match p.provider_type() {
|
||||
ProviderType::Plugin(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Also include built-in static providers that use Plugin type
|
||||
for p in &self.providers {
|
||||
if let ProviderType::Plugin(id) = p.provider_type() {
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Create a self-contained ProviderManager from config.
|
||||
///
|
||||
/// Loads native plugins, creates core providers (Application + Command),
|
||||
@@ -277,7 +320,68 @@ impl ProviderManager {
|
||||
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
|
||||
@@ -436,6 +540,14 @@ impl ProviderManager {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check built-in dynamic providers
|
||||
for provider in &self.builtin_dynamic {
|
||||
if provider.execute_action(command) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -602,8 +714,42 @@ impl ProviderManager {
|
||||
let dynamic_results = provider.query(query);
|
||||
// Priority comes from plugin-declared priority field
|
||||
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() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1089,4 +1235,24 @@ mod tests {
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0.name, "Firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_type_ids_includes_dynamic_and_static() {
|
||||
use super::calculator::CalculatorProvider;
|
||||
use super::converter::ConverterProvider;
|
||||
use super::system::SystemProvider;
|
||||
|
||||
let mut pm = ProviderManager::new(
|
||||
vec![Box::new(SystemProvider::new())],
|
||||
vec![],
|
||||
);
|
||||
pm.builtin_dynamic = vec![
|
||||
Box::new(CalculatorProvider),
|
||||
Box::new(ConverterProvider::new()),
|
||||
];
|
||||
let ids = pm.builtin_type_ids();
|
||||
assert!(ids.contains("calc"));
|
||||
assert!(ids.contains("conv"));
|
||||
assert!(ids.contains("sys"));
|
||||
}
|
||||
}
|
||||
|
||||
148
crates/owlry-core/src/providers/system.rs
Normal file
148
crates/owlry-core/src/providers/system.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "1.0.4"
|
||||
version = "1.0.6"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
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.
|
||||
pub struct CoreClient {
|
||||
stream: UnixStream,
|
||||
@@ -38,15 +38,15 @@ impl CoreClient {
|
||||
|
||||
// Socket not available — try to start the daemon.
|
||||
let status = std::process::Command::new("systemctl")
|
||||
.args(["--user", "start", "owlry-core"])
|
||||
.args(["--user", "start", "owlryd"])
|
||||
.status()
|
||||
.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() {
|
||||
return Err(io::Error::other(format!(
|
||||
"systemctl --user start owlry-core exited with status {}",
|
||||
"systemctl --user start owlryd exited with status {}",
|
||||
status
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -241,36 +241,18 @@ impl MainWindow {
|
||||
search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
|
||||
});
|
||||
|
||||
// Set up periodic widget auto-refresh (every 5 seconds) — local backend only
|
||||
// In daemon mode, the daemon handles widget refresh and results come via IPC
|
||||
if main_window.is_dmenu_mode {
|
||||
// dmenu typically has no widgets, but this is harmless
|
||||
// Periodic widget refresh — local backend only.
|
||||
// In daemon mode, the daemon handles widget refresh internally;
|
||||
// the UI gets updated data on the next user-initiated search.
|
||||
// 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();
|
||||
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
|
||||
backend_for_auto.borrow_mut().refresh_widgets();
|
||||
gtk4::glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
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 || {
|
||||
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();
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
main_window
|
||||
}
|
||||
@@ -1287,43 +1269,6 @@ impl MainWindow {
|
||||
});
|
||||
}
|
||||
|
||||
fn update_results(&self, query: &str) {
|
||||
let cfg = self.config.borrow();
|
||||
let max_results = cfg.general.max_results;
|
||||
drop(cfg);
|
||||
|
||||
let results = self.backend.borrow_mut().search(
|
||||
query,
|
||||
max_results,
|
||||
&self.filter.borrow(),
|
||||
&self.config.borrow(),
|
||||
);
|
||||
|
||||
// Clear existing results
|
||||
while let Some(child) = self.results_list.first_child() {
|
||||
self.results_list.remove(&child);
|
||||
}
|
||||
|
||||
// Display initial batch only
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item, query);
|
||||
self.results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = self.results_list.row_at_index(0) {
|
||||
self.results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
// current_results holds what's currently displayed; store full vec for lazy loading
|
||||
*self.current_results.borrow_mut() = results[..initial_count].to_vec();
|
||||
let mut lazy = self.lazy_state.borrow_mut();
|
||||
lazy.all_results = results;
|
||||
lazy.displayed_count = initial_count;
|
||||
lazy.query = query.to_string();
|
||||
}
|
||||
|
||||
/// Set up lazy loading scroll detection
|
||||
fn setup_lazy_loading(&self) {
|
||||
let vadj = self.scrolled.vadjustment();
|
||||
|
||||
@@ -20,8 +20,8 @@ fn is_emoji_icon(s: &str) -> bool {
|
||||
|
||||
/// Check if this item should be highlighted based on the query.
|
||||
/// Highlighted when:
|
||||
/// - Item is from a dynamic plugin (calculator, converter, websearch, filesearch)
|
||||
/// and the query is non-empty (auto-detect triggered)
|
||||
/// - 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() {
|
||||
@@ -33,10 +33,10 @@ fn should_highlight(item: &LaunchItem, query: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dynamic plugin auto-detect results
|
||||
// Auto-detect plugins that produce direct answers (not search tools)
|
||||
matches!(
|
||||
&item.provider,
|
||||
ProviderType::Plugin(id) if matches!(id.as_str(), "calc" | "conv" | "websearch" | "filesearch")
|
||||
ProviderType::Plugin(id) if matches!(id.as_str(), "calc" | "conv")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
1225
docs/superpowers/plans/2026-03-28-builtin-providers.md
Normal file
1225
docs/superpowers/plans/2026-03-28-builtin-providers.md
Normal file
File diff suppressed because it is too large
Load Diff
876
docs/superpowers/plans/2026-03-28-config-editor.md
Normal file
876
docs/superpowers/plans/2026-03-28-config-editor.md
Normal 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.0–1.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.
|
||||
116
docs/superpowers/specs/2026-03-28-builtin-providers-design.md
Normal file
116
docs/superpowers/specs/2026-03-28-builtin-providers-design.md
Normal 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
|
||||
187
docs/superpowers/specs/2026-03-28-config-editor-design.md
Normal file
187
docs/superpowers/specs/2026-03-28-config-editor-design.md
Normal 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.0–1.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.
|
||||
10
justfile
10
justfile
@@ -60,17 +60,17 @@ install-local:
|
||||
|
||||
echo "Installing binaries..."
|
||||
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..."
|
||||
[ -f target/release/libowlry_lua.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 "Installing systemd service files..."
|
||||
[ -f systemd/owlry-core.service ] && sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service
|
||||
[ -f systemd/owlry-core.socket ] && sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
|
||||
[ -f systemd/owlryd.service ] && sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service
|
||||
[ -f systemd/owlryd.socket ] && sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket
|
||||
|
||||
echo "Done. Start daemon: systemctl --user enable --now owlry-core.service"
|
||||
echo "Done. Start daemon: systemctl --user enable --now owlryd.service"
|
||||
|
||||
# === Version Management ===
|
||||
|
||||
@@ -224,7 +224,7 @@ aur-publish-pkg pkg:
|
||||
|
||||
cd "$aur_dir"
|
||||
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
|
||||
git add PKGBUILD .SRCINFO *.install 2>/dev/null || true
|
||||
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!"
|
||||
|
||||
@@ -5,7 +5,7 @@ After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/owlry-core
|
||||
ExecStart=/usr/bin/owlryd
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
Environment=RUST_LOG=warn
|
||||
Reference in New Issue
Block a user