Compare commits

...

16 Commits

Author SHA1 Message Date
a920588df9 chore(aur): update owlry-lua 1.1.1, owlry-rune 1.1.1 2026-03-28 13:43:30 +01:00
c32b6c5456 chore(owlry-rune): bump version to 1.1.1 2026-03-28 13:43:06 +01:00
2a5f184230 chore(owlry-lua): bump version to 1.1.1 2026-03-28 13:43:04 +01:00
b2f068269a chore: remove unused builtin_type_ids method and test 2026-03-28 13:37:54 +01:00
e210a604f7 chore(aur): update owlry-core to 1.3.1 2026-03-28 13:30:28 +01:00
1adec7bf47 chore(owlry-core): bump version to 1.3.1 2026-03-28 13:30:23 +01:00
7f07a93dec fix(core): add :config and :conv to filter prefix tables
:config and :conv were not in the prefix lists, so typing them
showed 'Plugin' mode but didn't route to the config/converter
providers. Also added :settings, :converter aliases.
2026-03-28 13:30:10 +01:00
7351ba868e docs: revise README for current state
- Architecture diagram reflects owlryd binary name and built-in providers
- Add config editor, converter trigger (>) to prefix tables
- Add apex-neon to theme list (10 themes)
- Add --owlry-shadow CSS variable
- Fix build instructions (no deleted plugins)
- Add built-in provider toggles to example config
- Cross-reference :config throughout (Quick Start, Disabling Plugins, Theming)
2026-03-28 13:28:32 +01:00
44e1430ea5 chore(aur): update owlry-core to 1.3.0 2026-03-28 13:17:29 +01:00
80312a28f7 chore(owlry-core): bump version to 1.3.0 2026-03-28 13:17:11 +01:00
37abe98c9b docs: add config editor usage to README 2026-03-28 13:16:36 +01:00
d95b81bbcb feat(core): wire config editor into ProviderManager
Register ConfigProvider as built-in dynamic provider. Extend
execute_plugin_action to dispatch CONFIG:* commands via the
DynamicProvider::execute_action trait method.
2026-03-28 13:15:28 +01:00
562b38deba feat(core): add built-in config editor provider 2026-03-28 13:10:54 +01:00
2888677e38 docs: add config editor implementation plan 2026-03-28 13:05:57 +01:00
940ad58ee2 docs: add config editor design spec 2026-03-28 12:54:11 +01:00
18775d71fc chore(aur): update owlry 1.0.6, owlry-core 1.2.1 2026-03-28 12:40:33 +01:00
18 changed files with 2372 additions and 91 deletions

6
Cargo.lock generated
View File

@@ -2557,7 +2557,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-core" name = "owlry-core"
version = "1.2.1" version = "1.3.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"ctrlc", "ctrlc",
@@ -2584,7 +2584,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-lua" name = "owlry-lua"
version = "1.1.0" version = "1.1.1"
dependencies = [ dependencies = [
"abi_stable", "abi_stable",
"chrono", "chrono",
@@ -2610,7 +2610,7 @@ dependencies = [
[[package]] [[package]]
name = "owlry-rune" name = "owlry-rune"
version = "1.1.0" version = "1.1.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs", "dirs",

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev> # Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-core pkgname=owlry-core
pkgver=1.2.0 pkgver=1.3.1
pkgrel=1 pkgrel=1
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search' pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
arch=('x86_64') arch=('x86_64')
@@ -9,7 +9,7 @@ license=('GPL-3.0-or-later')
depends=('gcc-libs') depends=('gcc-libs')
makedepends=('cargo') makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
b2sums=('5e23b41ad12e3e0577213059e2509a9b42e3081b17944e300831e4cfa216628d5190e64d9fd72edc3aa34aebb387d3821ae1d9edd157acf1abf2e5b81f778fd7') b2sums=('e37383fd650a3bf9a2c554eb37676037e3ae72bbc2e1aad7c316809094254173b6fcd5ac87907c2f38ce5506e9f26201ec62f82446bc789153c280373e31fc9e')
prepare() { prepare() {
cd "owlry" cd "owlry"

View File

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

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev> # Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-lua pkgname=owlry-lua
pkgver=1.1.0 pkgver=1.1.1
pkgrel=1 pkgrel=1
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins" pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
arch=('x86_64') arch=('x86_64')
@@ -9,7 +9,7 @@ license=('GPL-3.0-or-later')
depends=('owlry-core') depends=('owlry-core')
makedepends=('cargo') makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76') b2sums=('a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8')
_cratename=owlry-lua _cratename=owlry-lua

View File

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

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev> # Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-rune pkgname=owlry-rune
pkgver=1.1.0 pkgver=1.1.1
pkgrel=1 pkgrel=1
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins" pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
arch=('x86_64') arch=('x86_64')
@@ -9,7 +9,7 @@ license=('GPL-3.0-or-later')
depends=('owlry-core') depends=('owlry-core')
makedepends=('cargo') makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76') b2sums=('a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8')
_cratename=owlry-rune _cratename=owlry-rune

View File

@@ -1,6 +1,6 @@
pkgbase = owlry pkgbase = owlry
pkgdesc = Lightweight Wayland application launcher with plugin support pkgdesc = Lightweight Wayland application launcher with plugin support
pkgver = 1.0.5 pkgver = 1.0.6
pkgrel = 1 pkgrel = 1
url = https://somegit.dev/Owlibou/owlry url = https://somegit.dev/Owlibou/owlry
arch = x86_64 arch = x86_64
@@ -28,7 +28,7 @@ pkgbase = owlry
optdepends = owlry-plugin-pomodoro: pomodoro timer widget optdepends = owlry-plugin-pomodoro: pomodoro timer widget
optdepends = owlry-lua: Lua runtime for user plugins optdepends = owlry-lua: Lua runtime for user plugins
optdepends = owlry-rune: Rune runtime for user plugins optdepends = owlry-rune: Rune runtime for user plugins
source = owlry-1.0.5.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.5.tar.gz source = owlry-1.0.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.6.tar.gz
b2sums = 3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894 b2sums = 8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05
pkgname = owlry pkgname = owlry

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev> # Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry pkgname=owlry
pkgver=1.0.5 pkgver=1.0.6
pkgrel=1 pkgrel=1
pkgdesc="Lightweight Wayland application launcher with plugin support" pkgdesc="Lightweight Wayland application launcher with plugin support"
arch=('x86_64') arch=('x86_64')
@@ -29,7 +29,7 @@ optdepends=(
'owlry-rune: Rune runtime for user plugins' 'owlry-rune: Rune runtime for user plugins'
) )
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
b2sums=('3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894') b2sums=('8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05')
prepare() { prepare() {
cd "owlry" cd "owlry"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-core" name = "owlry-core"
version = "1.2.1" version = "1.3.1"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true

View File

@@ -261,6 +261,10 @@ impl ProviderFilter {
(":systemd ", "uuctl"), (":systemd ", "uuctl"),
(":web ", "websearch"), (":web ", "websearch"),
(":search ", "websearch"), (":search ", "websearch"),
(":config ", "config"),
(":settings ", "config"),
(":conv ", "conv"),
(":converter ", "conv"),
]; ];
// Check core prefixes // Check core prefixes
@@ -327,6 +331,10 @@ impl ProviderFilter {
(":systemd", "uuctl"), (":systemd", "uuctl"),
(":web", "websearch"), (":web", "websearch"),
(":search", "websearch"), (":search", "websearch"),
(":config", "config"),
(":settings", "config"),
(":conv", "conv"),
(":converter", "conv"),
]; ];
for (prefix_str, provider) in partial_core { for (prefix_str, provider) in partial_core {

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
mod application; mod application;
mod command; mod command;
pub(crate) mod calculator; pub(crate) mod calculator;
pub(crate) mod config_editor;
pub(crate) mod converter; pub(crate) mod converter;
pub(crate) mod system; pub(crate) mod system;
@@ -116,6 +117,11 @@ pub(crate) trait DynamicProvider: Send + Sync {
fn provider_type(&self) -> ProviderType; fn provider_type(&self) -> ProviderType;
fn query(&self, query: &str) -> Vec<LaunchItem>; fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32; fn priority(&self) -> u32;
/// Handle a plugin action command. Returns true if handled.
fn execute_action(&self, _command: &str) -> bool {
false
}
} }
/// Manages all providers and handles searching /// Manages all providers and handles searching
@@ -196,25 +202,6 @@ impl ProviderManager {
manager 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. /// Create a self-contained ProviderManager from config.
/// ///
/// Loads native plugins, creates core providers (Application + Command), /// Loads native plugins, creates core providers (Application + Command),
@@ -327,6 +314,11 @@ impl ProviderManager {
info!("Registered built-in converter provider"); 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 // Built-in static providers
if config.providers.system { if config.providers.system {
core_providers.push(Box::new(system::SystemProvider::new())); core_providers.push(Box::new(system::SystemProvider::new()));
@@ -529,6 +521,14 @@ impl ProviderManager {
return true; return true;
} }
} }
// Check built-in dynamic providers
for provider in &self.builtin_dynamic {
if provider.execute_action(command) {
return true;
}
}
false false
} }
@@ -1217,23 +1217,4 @@ mod tests {
assert_eq!(results[0].0.name, "Firefox"); 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"));
}
} }

View File

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

View File

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

View File

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

View File

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