doc: Add project design, dev plan, coding standards, and use cases.

This commit is contained in:
2026-03-13 21:47:37 -04:00
parent 24f6635927
commit b4129eaeaf
5 changed files with 1364 additions and 0 deletions

121
docs/CODING_STANDARDS.md Normal file
View File

@@ -0,0 +1,121 @@
# Coding Standards
Rules of the road for pikl-menu. If something's not covered
here, use good judgement and keep it consistent with the rest
of the codebase.
## Error Handling
- **No `anyhow`.** Every error type is a proper enum. No
stringly-typed errors, no `Box<dyn Error>` as a crutch.
- Use `thiserror` where it helps (derive `Error` +
`Display`), but it's not mandatory. Hand-written impls
are fine too.
- Use `Result` and `?` everywhere. Convert between error
types with `.map_err(YourVariant)`. Keep it simple, no
closures when a variant constructor will do.
- It's fine to stringify an error when crossing thread/async
boundaries (e.g. `JoinError` to `Io`), but always wrap
it in a typed variant with context.
## No Panics
- **No `unwrap()`, `expect()`, or `panic!()` in library or
binary code.** CI lints enforce this.
- We know IO and FFI can't be made perfectly safe. The goal
is to not *add* unsafe situations. Handle failures, don't
crash through them.
## Clippy & Lints
- Deny `clippy::unwrap_used`, `clippy::expect_used`,
`clippy::panic` workspace-wide. These are the guardrails
for the no-panic rule.
- Deny `clippy::dbg_macro`, `clippy::print_stdout`,
`clippy::print_stderr`. Use `tracing` instead.
- Enable `clippy::must_use_candidate`. If a function returns
a `Result`, `Option`, or any value the caller shouldn't
silently ignore, it gets `#[must_use]`.
- Run `cargo clippy -- -D warnings` in CI. Warnings are
errors.
## Unsafe Code
- `unsafe` blocks need a `// SAFETY:` comment explaining
why it's sound.
- Keep unsafe surface area minimal. If there's a safe
alternative that's not meaningfully worse, use it.
## Logging & Diagnostics
- **All logging goes through `tracing` and
`tracing-subscriber`.** No `println!`, `eprintln!`,
`dbg!()`, or manual log files.
- No temp files for logs. No `/tmp/pikl-debug.log`. None
of that.
- We'll eventually build a debug subscriber tool (think
tokio-console style). Design logging with structured
events in mind.
## Async Conventions
- Tokio is the async runtime. No mixing in other runtimes.
- Prefer structured concurrency with `JoinSet` over
fire-and-forget `tokio::spawn`. If you spawn a task, you
should be able to cancel it and know when it's done.
- Be explicit about cancellation safety. If a future holds
state that would be lost on drop, document it. This
matters for hooks and IPC especially.
- Use `tokio::select!` carefully. Every branch should be
cancellation-safe or documented as not.
## Public API Surface
- `pikl-core` is an embeddable library. Treat its public
API like a contract.
- Don't make things `pub` unless they need to be. Default
to `pub(crate)` and open up intentionally.
- Re-export the public interface from `lib.rs`. Consumers
shouldn't need to reach into submodules.
- Breaking changes to the public API should be deliberate,
not accidental side effects of refactoring.
## Performance
- **Avoid cloning** where possible. Cheap copies (small
`Copy` types, `Arc::clone`) are fine. Cloning strings
and vecs as a convenience? Try harder.
- **Use zero-copy** patterns: borrowed slices, `Cow`, views
into existing data.
- **Prefer iterators** over collecting into intermediate
`Vec`s. Chain, filter, map. Only collect when you
actually need the collected result.
## Testing
- Every module in `pikl-core` gets unit tests. Every filter,
hook lifecycle event, I/O format.
- Integration tests exercise the real binary with real
pipes. No mocked IO at that level.
- Cross-platform: tests must pass on Linux and macOS. Gate
platform-specific tests with `#[cfg]`.
- When the GUI frontend lands, add snapshot/visual
regression tests.
- Build toward an e2e framework that can drive the full
tool.
## Dependencies
- `pikl-core` stays free of rendering, terminal, and GUI
deps. It's an embeddable library.
- Prefer pure-Rust deps. C deps behind feature flags only
(e.g. `pcre2`).
- Be intentional about what you pull in. If the standard
library does it, use the standard library.
## Import Ordering
- Group imports in this order: `std`, external crates,
workspace crates (`pikl_*`), then `self`/`super`/`crate`.
- Blank line between each group.
- `rustfmt` handles the rest. Don't fight it.

692
docs/DESIGN.md Normal file
View File

@@ -0,0 +1,692 @@
# pikl-menu: Design Document
A keyboard-driven, hookable, streaming menu for Wayland and
X11. Built as a Rust library with TUI and GUI frontends.
Think rofi/wofi but with structured I/O, lifecycle hooks,
vim keybindings, streaming input/output, and multi-select.
The kind of tool you build a wallpaper picker on top of, or
a clipboard manager, or a process killer, or anything else
that's fundamentally "pick from a filterable list and do
something with it."
## Core Concept
pikl-menu is a **filterable, hookable, streaming item list
with structured I/O**. Everything else is a feature on top
of that core.
Items go in (stdin, file, directory watch, IPC). The user
filters, navigates, selects. Events fire (hover, select,
cancel, filter change). Selected items come out (stdout,
hooks, IPC). That's it.
## Structured I/O
### Input
Items are JSON lines on stdin. Each item has fields:
```jsonl
{"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB", "res": "3840x2160"}, "icon": "/path/to/thumb.png"}
{"label": "city_night.png", "sublabel": "/home/maple/walls/urban/", "meta": {"size": "1.8MB", "res": "2560x1440"}}
```
Minimum viable item is just `{"label": "something"}`.
Everything else is optional.
Plain text mode also works: one line per item, each line
becomes a label. Keeps it pipeable for simple use cases.
### Output
Selected items are emitted as JSON lines on stdout.
Multi-select emits multiple lines. The output includes any
metadata that came in, plus selection context:
```jsonl
{"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3}
```
### Streaming
Input can arrive over time. The list populates progressively
as items stream in. Useful for slow sources (network
queries, recursive directory walks, long-running commands).
Output can also stream. `on-hover` events can be configured
to emit to stdout as they happen, not just on final
selection. Useful for live preview pipelines.
### CSV / TSV
`--input-format csv` or `--input-format tsv` parses columnar
input. First row is headers, which become field names. Maps
naturally to table/column display mode.
## Event Hooks
Hooks are shell commands that run in response to lifecycle
events. They receive the relevant item(s) as JSON on stdin.
### Available Hooks
| Hook | Fires When | Use Case |
|---|---|---|
| `on-open` | Menu opens | Save current state for revert |
| `on-close` | Menu closes (any reason) | Cleanup |
| `on-hover` | Selection cursor moves to a new item | Live wallpaper preview |
| `on-select` | User confirms selection (Enter) | Apply the choice |
| `on-cancel` | User cancels (Escape) | Revert preview |
| `on-filter` | Filter text changes | Dynamic item reloading |
| `on-mark` | User marks/unmarks an item | Visual feedback, register management |
### Configuration
Hooks are set via CLI flags:
```sh
pikl --on-hover 'hyprctl hyprpaper wallpaper "DP-4, {path}"' \
--on-cancel 'hyprctl hyprpaper wallpaper "DP-4, {original}"' \
--on-hover-debounce 100
```
Or via a manifest file for reusable configurations:
```toml
# ~/.config/pikl/wallpaper.toml
[hooks]
on-hover = 'hyprctl hyprpaper wallpaper "DP-4, {label}"'
on-cancel = 'restore-wallpaper.sh'
on-hover-debounce = 100
[display]
columns = ["label", "meta.res"]
```
### Bidirectional Hooks
Hooks can return JSON on stdout to modify menu state:
- Update an item's display
- Add/remove items from the list
- Set the filter text
- Close the menu
### Debouncing
All hooks support a debounce option
(`--on-hover-debounce 100`). When the user is scrolling
fast, only the last event in the debounce window fires.
Built-in, no external tooling needed.
## Filtering
### Strategies
The filter strategy is determined by the prefix/syntax of
the query text. No separate mode selector. The filter engine
parses the query on every keystroke and picks the right
strategy.
| Prefix | Strategy | Keeps/Removes | Case | Example |
|---|---|---|---|---|
| (none) | Fuzzy (nucleo) | keeps matches | smart case | `bch snst` matches "beach_sunset" |
| `'` | Exact substring | keeps matches | case-insensitive | `'sunset` matches "Sunset_beach" |
| `!` | Fuzzy inverse | removes matches | smart case | `!urban` excludes items matching "urban" |
| `!'` | Exact inverse | removes matches | case-insensitive | `!'temp` excludes items containing "temp" |
| `/pattern/` | Regex | keeps matches | case-sensitive | `/\d{4}x\d{4}/` matches resolution strings |
| `!/pattern/` | Regex inverse | removes matches | case-sensitive | `!/\.bak$/` excludes backup files |
**Case sensitivity rules:**
- **Fuzzy (nucleo):** Smart case. Case-insensitive until
you type an uppercase character.
- **Exact substring:** Case-insensitive always.
- **Regex:** Case-sensitive by default. Use `(?i)` flag
inside the pattern for case-insensitive matching. This
follows standard regex convention.
The `!` prefix is a modifier that inverts any strategy. It
stacks with `'` and `/pattern/`.
### Chaining (Pipeline)
Filters chain with `|` (pipe). Each stage takes the previous
stage's output as its input and narrows further:
```
'log | !temp | /[0-9]+/
```
Means: exact match "log", then exclude items containing
"temp", then keep only items with at least one digit. Each
`|` is a pipeline step, not an OR.
OR semantics live inside regex: `/foo|bar/` matches either.
Outside regex, `|` always means "then filter again."
**Escaping:** `\|` produces a literal pipe character in
the query.
**Whitespace:** Spaces around `|` are trimmed. `log|!temp`
and `log | !temp` are identical.
**Empty segments:** Skipped silently. `log | | foo` is the
same as `log | foo`.
### Incremental Evaluation
The filter pipeline uses an incremental caching strategy to
avoid redundant work:
- Each pipeline stage caches its result as a set of item
indices (not copies of the items).
- When the user edits a segment, only that stage and
everything downstream is recomputed. Upstream stages keep
their cached results.
- Backspacing over a `|` pops the last stage. Instant, no
recomputation.
- Typing a new `|` pushes a new empty stage.
- Within a fuzzy stage, nucleo's own incremental narrowing
applies when appending characters.
- Regex and exact stages recompute fully on each keystroke
within their segment, but they run against the previous
stage's (already filtered) output, so the input set is
small.
The cursor position determines which segment is active.
Arrow keys move through the query text normally. Editing an
earlier segment invalidates from that point forward and
cascades recomputation.
### Regex Engine
Built on `fancy-regex` (pure Rust, no C deps). Full
PCRE2-style features:
- Unlimited capture groups (no artificial cap)
- Lookahead / lookbehind
- Non-greedy quantifiers
- Unicode support
- Named groups
Optional `pcre2` feature flag for JIT-compiled patterns
when performance matters on huge datasets.
### Filter Scoping
By default, filters match against the label.
`--filter-fields label,sublabel,meta.tags` expands the
search scope.
Field filters (`meta.res:3840`) are a Phase 3 feature that
builds on top of this pipeline infrastructure.
### Future: Syntax Highlighting
The filter input is a single text field. A future
enhancement is to syntax-highlight the query: color-code
prefixes (`'`, `!`, `/`), pipe delimiters, and regex
patterns so the user gets visual feedback on how their query
is being parsed. Low-cost, high-value, but deferred until
we get into proper theming work.
## Navigation & Interaction
### Modes
pikl-menu has two input modes, inspired by vim:
**Insert mode** (default on open):
- Typing goes to the filter input
- Arrow keys navigate the list
- Ctrl+N switches to normal mode
- Escape always cancels and exits (no exceptions, in any
mode)
**Normal mode**:
- Full vim keybindings for navigation
- Ctrl+E switches to insert (edit) mode
- `/` enters insert mode with filter focused (like vim
search)
- Escape cancels and exits
`--start-mode normal` to open in normal mode instead.
**Escape is sacred.** It always means cancel and exit,
regardless of mode. It never means "go back to previous
mode." This is a deliberate departure from vim to keep the
exit path predictable.
**Command mode** is planned for the future but not yet
designed.
### Vim Keybindings (Normal Mode)
| Key | Action |
|---|---|
| `j` / `k` | Down / up |
| `g g` | Jump to top |
| `G` | Jump to bottom |
| `Ctrl+D` / `Ctrl+U` | Half-page down / up |
| `Ctrl+F` / `Ctrl+B` | Full page down / up |
| `/` | Enter filter (insert) mode |
| `n` / `N` | Next / previous filter match |
| `m{a-z}` | Set mark at current position |
| `'{a-z}` | Jump to mark |
| `"a` | Select into register `a` |
| `v` | Toggle multi-select on current item |
| `V` | Visual line select mode |
| `Enter` | Confirm selection |
| `Escape` | Cancel and exit |
| `q` | Cancel and exit |
| `Space` | Toggle select current item and move down |
`H`/`M`/`L` (viewport-relative jumps) are deferred. They're
nice-to-have but not essential for the first pass of vim
navigation.
### Multi-Select
- `Space` or `v` toggles selection on current item
- `V` enters visual line mode, moving up/down selects ranges
- All selected items are emitted on confirm
- `"a` through `"z`: select into named registers (like vim
yanking)
- Registers can be recalled or included in output
### Drill-Down
An `on-select` hook can return
`{"action": "replace", "items": [...]}` to replace the
current list in-place. No new process spawn. Combined with
a back keybind (`Backspace` or `h` in normal mode), this
gives you hierarchical navigation.
## Display
### List Mode (default)
Vertical list of items. Each entry shows available fields
based on a format template:
```sh
pikl --format '{icon} {label} <dim>{sublabel}</dim>'
```
### Table / Column Mode
```sh
pikl --columns label,meta.res,meta.size --column-separator ' │ '
```
Auto-aligns columns. Sortable per-column with keybinds.
CSV/TSV input maps naturally here.
### Preview Pane
Optional side panel that runs a command on the hovered item
and displays its stdout:
```sh
pikl --preview 'cat {meta.path}' --preview-position right --preview-width 50%
```
For GUI mode, this can render images (thumbnails, etc). For
TUI mode, text or sixel/kitty graphics protocol.
### Sections / Groups
Items can include a `group` field. pikl-menu renders group
headers and allows Tab to cycle between groups. Groups are
collapsible in normal mode with `za` (vim fold toggle).
### Theming
Minimal but configurable. TOML-based:
```toml
# ~/.config/pikl/theme.toml
[colors]
background = "#1a1a2e"
foreground = "#e0e0e0"
selection = "#e94560"
filter = "#0f3460"
dim = "#666666"
border = "#333333"
[layout]
width = "40%"
position = "right" # left, right, center
padding = 1
```
A few built-in themes ship with pikl. `--theme monokai` or
`--theme nord` etc.
## Sessions
`--session name` persists the menu's state (filter text,
scroll position, selected items, marks, registers) across
invocations. State lives in
`~/.local/state/pikl/sessions/`.
No `--session` = ephemeral, nothing saved.
Session names are just strings, use shell expansion for
dynamic names:
```sh
pikl --session "walls-$(hostname)"
pikl --session "logs-$USER"
```
Session history is a log file alongside the state. Other
tools can tail it for observability.
## Watched Sources
```sh
pikl --watch ~/Pictures/walls/ --watch-extensions jpg,png,webp
```
The list updates live as files are added, removed, or
renamed. Combines naturally with hooks: a new file appears,
the list updates, the user can immediately select it.
Also supports watching a file (one item per line) or a
named pipe.
## IPC
While running, pikl-menu listens on a Unix socket
(`/run/user/$UID/pikl-$PID.sock`). External tools can:
- Push new items
- Remove items
- Update item fields
- Set the filter text
- Read current selection
- Close the menu
Protocol is newline-delimited JSON. Simple enough to use
with `socat` or any language's Unix socket support.
## Exit Codes
| Code | Meaning |
|---|---|
| 0 | User selected item(s) |
| 1 | User cancelled (Escape / q) |
| 2 | Error (bad input, hook failure, etc) |
| 10-19 | Custom actions (configurable keybinds, future) |
## Platform Support
### Primary Targets
- **Wayland** (layer-shell overlay via `iced` +
`iced_layershell`)
- **X11** (standard window with override-redirect or EWMH
hints)
- **TUI** (ratatui, runs in any terminal)
### Rendering Selection
pikl auto-detects the environment:
1. If `$WAYLAND_DISPLAY` is set: GUI (Wayland)
2. If `$DISPLAY` is set: GUI (X11)
3. Otherwise: TUI
Override with `--mode gui` or `--mode tui`.
## Architecture
```
pikl-core (library crate)
├── Item store - structured items, streaming input, indexing
├── Filter engine - fuzzy, regex, fancy-regex, field filters, chaining
├── Selection - single, multi, registers, marks
├── Hook system - lifecycle events, debouncing, bidirectional
├── Session - persistence, history log
├── IPC server - Unix socket, JSON protocol
└── Event bus - internal pub/sub connecting all the above
pikl-tui (binary crate)
├── ratatui renderer
├── Terminal input handling
└── Sixel / kitty image protocol (optional)
pikl-gui (binary crate)
├── iced + layer-shell (Wayland)
├── iced + X11 windowing
└── Native image rendering
pikl (binary crate, unified CLI)
├── CLI argument parsing (clap)
├── Auto-detection of GUI vs TUI
├── Stdin/stdout JSON streaming
└── Manifest file loading
```
`pikl-core` has zero rendering dependencies. It's the
engine. The frontends are thin layers that subscribe to the
event bus and render state.
Third-party Rust tools can depend on `pikl-core` directly
and skip the CLI entirely. Your wallpaper picker becomes a
Rust binary that calls `pikl_core::Menu::new()` with items
and hook closures.
### Frontends
Every way of driving pikl is a frontend. Frontends send
`Action` variants into the core's `mpsc` channel and
optionally subscribe to `MenuEvent` via the `broadcast`
channel. The core is frontend-agnostic: it processes actions
sequentially regardless of where they came from.
| Frontend | Input Source | Interactive | Phase |
|---|---|---|---|
| TUI | Terminal keypresses (crossterm) | Yes | 1 |
| GUI | GUI events (iced) | Yes | 8 |
| Action-fd | Pre-validated script from a file descriptor | No (unless `show-ui`) | 1.5 |
| IPC | Unix socket, JSON protocol | Yes (bidirectional) | 6 |
| Lua | LuaJIT script via mlua | Yes (stateful, conditional) | Post-6 |
This framing means new interaction modes don't require core
changes: they're just new frontends that push actions.
## Action-fd (Headless Mode)
`--action-fd <N>` reads a pre-built action script from file
descriptor N. Items come from stdin, selected output goes to
stdout. No TUI or GUI is launched unless the script requests
it.
### Protocol
One action per line, plain text:
```
filter foo
move-down
move-down
confirm
```
Available actions:
| Action | Argument | Maps to |
|---|---|---|
| `filter <text>` | Filter string | `Action::UpdateFilter` |
| `move-up` | (none) | `Action::MoveUp` |
| `move-down` | (none) | `Action::MoveDown` |
| `move-to-top` | (none) | `Action::MoveToTop` |
| `move-to-bottom` | (none) | `Action::MoveToBottom` |
| `page-up` | (none) | `Action::PageUp` |
| `page-down` | (none) | `Action::PageDown` |
| `resize <n>` | Height (items) | `Action::Resize` |
| `confirm` | (none) | `Action::Confirm` |
| `cancel` | (none) | `Action::Cancel` |
| `show-ui` | (none) | Hand off to interactive frontend |
| `show-tui` | (none) | Hand off to TUI specifically |
| `show-gui` | (none) | Hand off to GUI specifically |
`show-ui` auto-detects the appropriate interactive frontend
(Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and
`show-gui` are explicit overrides. All three must be the
last action in the script: anything after them is a
validation error.
### Execution Model
1. **Read + validate action-fd:** the entire script is
loaded and validated before anything runs. Unknown
actions, malformed arguments, and actions after
`show-ui`/`show-tui`/`show-gui` are rejected with
diagnostic errors.
2. **Read all stdin until EOF:** items are fully loaded. If
stdin doesn't close within the timeout (default 30s),
pikl exits with an error.
3. **Create Menu** with items, default viewport height
of 50.
4. **Execute actions** sequentially.
5. **Exit with result.** If the script ended with
`show-ui`/`show-tui`/`show-gui`, launch the interactive
frontend and let the user finish.
### Stdin Timeout
`--stdin-timeout <seconds>` controls how long pikl waits
for stdin to close.
- **Action-fd mode:** defaults to 30 seconds. Catches
mistakes like piping from a streaming source (`tail -f`).
- **Interactive mode:** defaults to 0 (no timeout). The
user can Ctrl+C.
- `--stdin-timeout 0` disables the timeout in any mode.
### Constraints
- **Action-fd is intentionally stateless.** The caller sends
actions but cannot read state back or branch on it. For
conditional/stateful automation, use Lua (when available)
or IPC.
- **Action-fd and streaming stdin are mutually exclusive.**
Action-fd requires stdin to be finite and fully consumed
before actions execute. Streaming input (phase 7) is for
interactive frontends.
- **Unknown actions are validation errors**, not silently
ignored. The script is pre-validated: there's no
forward-compatibility concern since the caller controls
both the script and the pikl version.
### Examples
```sh
# Simple: filter and confirm
exec 3<<'EOF'
filter projects
confirm
EOF
ls ~/dev | pikl --action-fd 3
# Pre-navigate, then hand off to user
exec 3<<'EOF'
filter .rs
move-down
move-down
show-ui
EOF
find . -type f | pikl --action-fd 3
# Integration test (Rust)
# Spawn pikl with items on stdin, actions on fd 3, assert stdout
```
## Key Crates
| Crate | Purpose |
|---|---|
| `fancy-regex` | Regex engine with lookaround support |
| `serde` + `serde_json` | Structured I/O |
| `clap` | CLI argument parsing |
| `ratatui` + `crossterm` | TUI rendering |
| `iced` + `iced_layershell` | GUI rendering (Wayland) |
| `nucleo` | Fuzzy matching with smart case and incremental narrowing |
| `tokio` | Async runtime (streaming, IPC, hooks) |
| `notify` | File/directory watching |
| `csv` | CSV/TSV input parsing |
| `toml` | Config and manifest parsing |
| `dirs` | XDG directory paths |
## Example Use Cases
```sh
# Simple picker: pipe in lines, get selection back
ls ~/walls/*.jpg | pikl
# Structured items with live preview hooks
find-walls --json | pikl \
--on-hover 'set-wallpaper {label}' \
--on-cancel 'restore-wallpaper' \
--on-hover-debounce 100
# Process killer with table view
ps aux --no-headers | pikl --input-format tsv --columns 1,10,2 --multi
# Drill-down file browser
pikl --manifest ~/.config/pikl/filebrowser.toml --session filebrowser
# Clipboard history with preview
clipman list --json | pikl --preview 'echo {meta.content}' --on-select 'clipman paste {id}'
```
## Scripting Ladder
pikl has a deliberate progression for automation
complexity:
1. **Action-fd:** fire-and-forget scripts. No state, no
branching, no custom DSL. For shell one-liners,
integration tests, and simple automation. If you need
conditionals, you've outgrown this.
2. **IPC** (phase 6): bidirectional JSON over Unix socket.
External tools can read state and send actions while pikl
runs interactively. Good for tool integration.
3. **Lua** (post phase 6): embedded LuaJIT via mlua. Full
stateful scripting: subscribe to events, branch on state,
loops, the works. The Lua runtime is just another
frontend pushing Actions and subscribing to MenuEvents.
For anything complex enough to need a real language.
No custom DSL. Action-fd stays simple forever. The jump from
"I need conditionals" to "use Lua" is intentional: there's
no value in a half-language.
## Use Cases
Concrete workflows pikl-menu is designed to support.
These are first-class targets that inform feature
priorities, not hypotheticals. Each has its own doc
with the full writeup.
- [Wallpaper Picker](use-cases/wallpaper-picker.md):
keyboard-driven wallpaper browser with live preview
via hyprpaper hooks.
- [App Launcher](use-cases/app-launcher.md): global
hotkey launcher that fuzzy-filters PATH binaries
or .desktop entries.
## Open Questions
- Should marks/registers persist across sessions or only
within a session?
- Accessibility: screen reader support for TUI mode?
- Should `--watch` support inotify on Linux and FSEvents on
macOS, or use `notify` crate to abstract?
- Maximum practical item count before we need virtual
scrolling? (Probably around 100k)
- Should hooks run in a pool or strictly sequential?
- Plugin system via WASM for custom filter strategies?
(Probably way later if ever)

269
docs/DEVPLAN.md Normal file
View File

@@ -0,0 +1,269 @@
# pikl-menu: Development Plan
Implementation order, phase definitions, and the running
list of ideas. The DESIGN.md is the source of truth for
*what* pikl-menu is. This doc is the plan for *how and
when* we build it.
## Principles
- Get a working vertical slice before going wide on features
- pikl-core stays rendering-agnostic. No GUI or TUI deps
in the library.
- Every feature gets tests in pikl-core before frontend work
- Ship something usable early, iterate from real usage
- Don't optimize until there's a reason to
## Phase 1: Core Loop (TUI)
The minimum thing that works end-to-end.
**Deliverables:**
- pikl-core: Item store (accepts JSON lines on stdin,
plain text fallback)
- pikl-core: Basic fuzzy filter on label
- pikl-core: Single selection, Enter to confirm, Escape
to cancel
- pikl-core: Event bus with on-select and on-cancel hooks
(shell commands)
- pikl-tui: ratatui list renderer
- pikl (CLI): Reads stdin, opens TUI, emits selected item
as JSON on stdout
- Exit codes: 0 = selected, 1 = cancelled, 2 = error
- CI: Strict clippy, fmt, tests on Linux + macOS
**Done when:** `ls | pikl` works and prints the selected
item.
## Phase 1.5: Action-fd (Headless Mode)
Scriptable, non-interactive mode for integration tests and
automation. Small enough to slot in before phase 2. It's
about 100 lines of new code plus the binary orchestration
for `show-ui`.
**Deliverables:**
- `--action-fd <N>`: read action script from file
descriptor N
- Action protocol: plain text, one action per line
(filter, move-up/down, confirm, cancel, etc.)
- `show-ui` / `show-tui` / `show-gui` actions for
headless-to-interactive handoff
- Upfront validation pass: reject unknown actions,
malformed args, actions after show-ui
- `--stdin-timeout <seconds>`: default 30s in action-fd
mode, 0 in interactive mode
- Default viewport height of 50 in headless mode
- Binary integration tests: spawn pikl with piped stdin +
action-fd, assert stdout
**Done when:** `echo -e "hello\nworld" | pikl --action-fd 3`
with `confirm` on fd 3 prints `"hello"` to stdout.
## Phase 2: Navigation & Filtering
Make it feel like home for a vim user. The filter system
is the real star here: strategy prefixes, pipeline
chaining, incremental caching.
**Implementation order:**
1. Mode system in core (Insert/Normal, Ctrl+N/Ctrl+E
to switch)
2. Normal mode vim nav (j/k/gg/G/Ctrl+D/U/Ctrl+F/B)
3. `/` in normal mode enters insert mode with filter
focused
4. Filter strategy engine (prefix parsing: `'`, `!`, `!'`,
`/pattern/`, `!/pattern/`)
5. `fancy-regex` integration for the regex strategy
6. Filter pipeline (`|` chaining between stages,
incremental caching)
**Deliverables:**
- Insert mode / normal mode (Ctrl+N to normal, Ctrl+E
to insert)
- Escape always cancels and exits, in any mode
- Normal mode: j/k, gg, G, Ctrl+D/U, Ctrl+F/B
- `/` in normal mode enters filter (insert) mode
- `--start-mode normal` flag
- Filter strategies via inline prefixes: fuzzy (default),
exact (`'term`), inverse (`!term`, `!'term`), regex
(`/pattern/`, `!/pattern/`)
- fancy-regex integration (unlimited capture groups,
lookaround)
- Filter pipeline with `|`: each stage narrows the
previous stage's output
- Incremental filter caching: each stage caches item
indices, only recomputes from the edited stage forward
- Arrow keys to navigate within the filter text and edit
earlier pipeline segments
- `\|` escapes a literal pipe in the query
- Case rules: fuzzy = smart case, exact =
case-insensitive, regex = case-sensitive (use `(?i)`
for insensitive)
**Deferred to later:**
- H/M/L (viewport-relative jumps): nice-to-have, not
essential
- Filter syntax highlighting in the input field: deferred
to theming work
**Done when:** You can navigate and filter a large list
with vim muscle memory.
`'log | !temp | /[0-9]+/` works as a pipeline.
## Phase 3: Structured I/O & Hooks
The structured data pipeline.
**Deliverables:**
- JSON line input parsing (label, sublabel, meta, icon,
group)
- JSON output with action context
- Full hook lifecycle: on-open, on-close, on-hover,
on-select, on-cancel, on-filter
- Hook debouncing
- Bidirectional hooks (hook stdout modifies menu state)
- `--format` template strings for display
- Field filters (`meta.res:3840`)
- Filter scoping (`--filter-fields`)
**Done when:** The wallpaper picker use case works entirely
through hooks and structured I/O.
## Phase 4: Multi-Select & Registers
Power selection features.
**Deliverables:**
- Space to toggle select, V for visual line mode
- Multi-select output (multiple JSON lines)
- Named registers (`"a` through `"z`)
- Marks (`m{a-z}`, `'{a-z}`)
- `--multi` flag to enable multi-select mode
**Done when:** You can select multiple items, store them
in registers, and get them all in the output.
## Phase 5: Table Mode & CSV
Columnar data display.
**Deliverables:**
- `--columns` flag for table layout
- Auto-alignment
- Column sorting keybinds
- `--input-format csv` and `--input-format tsv`
- Column-specific filtering
**Done when:** `ps aux | pikl --input-format tsv --columns 1,10,2`
renders a clean table.
## Phase 6: Sessions & IPC
Persistence and external control.
**Deliverables:**
- `--session name` for state persistence
- Session state: filter, scroll position, selections,
marks, registers
- Session history log file
- Unix socket IPC while running
- IPC commands: push/remove/update items, set filter,
read selection, close
- Protocol: newline-delimited JSON
**Done when:** You can close and reopen a session and find
your state intact. External scripts can push items into a
running pikl instance.
## Phase 7: Streaming & Watched Sources
Live, dynamic menus.
**Deliverables:**
- Async/streaming stdin (items arrive over time, list
updates progressively)
- Streaming output (on-hover events emitted to stdout)
- `--watch path` for file/directory watching
- `--watch-extensions` filter
- notify crate integration (inotify on Linux, FSEvents
on macOS)
**Done when:** `find / -name '*.log' | pikl` populates
progressively. A watched directory updates the list live.
## Phase 8: GUI Frontend (Wayland + X11)
The graphical overlay.
**Deliverables:**
- pikl-gui crate with iced
- Wayland: layer-shell overlay via iced_layershell
- X11: override-redirect window or EWMH hints
- Auto-detection of Wayland vs X11 vs fallback to TUI
- `--mode gui` / `--mode tui` override
- Theming (TOML-based, a few built-in themes)
- Image/icon rendering in item list
- Preview pane (text + images)
**Done when:** pikl looks and feels like a native Wayland
overlay with keyboard-first interaction.
## Phase 9: Drill-Down & Groups
Hierarchical navigation.
**Deliverables:**
- `on-select` hook returning
`{"action": "replace", "items": [...]}` for drill-down
- Backspace / `h` in normal mode to go back
- Navigation history stack
- Item groups with headers
- Tab to cycle groups
- `za` to collapse/expand groups
**Done when:** A file browser built on pikl-menu can
navigate directories without spawning new processes.
## Open Design Notes
- **Viewport cursor preservation on filter change.** When
the filter narrows and the highlighted item is still in
the result set, keep it highlighted (like fzf/rofi).
When it's gone, fall back to the top. Needs the filter
to report whether a specific original index survived the
query, or the viewport to do a lookup after each filter
pass.
## Future Ideas (Unscheduled)
These are things we've talked about or thought of. No
commitment, no order.
- Lua scripting frontend (mlua + LuaJIT): stateful/
conditional automation, natural follow-on to action-fd
and IPC. Lua runtime is just another frontend pushing
Actions and subscribing to MenuEvents. Deliberately
deferred until after IPC (phase 6) so the event/action
API is battle-tested before exposing it to a scripting
language. See "Scripting Ladder" in DESIGN.md.
- WASM plugin system for custom filter strategies
- `pcre2` feature flag for JIT-compiled regex
- Frecency sorting (track selection frequency, boost
common picks)
- Manifest file format for reusable pikl configurations
- Sixel / kitty graphics protocol support in TUI mode
- Custom action keybinds (Ctrl+1 through Ctrl+9) with
distinct exit codes
- Accessibility / screen reader support
- Sections as a first-class concept separate from groups
- Network input sources (HTTP, WebSocket)
- Shell completion generation (bash, zsh, fish)
- Man page generation
- Homebrew formula + AUR PKGBUILD
- App launcher use case: global hotkey opens pikl as GUI
overlay, fuzzy-filters PATH binaries, launches selection
(optionally into a tmux session). Needs GUI frontend
(phase 8) and frecency sorting.
See `docs/use-cases/app-launcher.md`.

View File

@@ -0,0 +1,137 @@
# Use Case: App Launcher
A global-hotkey application launcher that replaces
Spotlight, Alfred, or rofi's drun mode. Bind a key
combo to open pikl as a GUI overlay, fuzzy-filter
binaries from PATH, hit Enter to launch.
## What It Looks Like
1. User presses a global hotkey (Super on Linux,
Cmd+Space on macOS).
2. pikl opens as a centered overlay.
3. The list is pre-populated with binaries from PATH
(or parsed .desktop entries).
4. User types to fuzzy-filter. Frecency sorting puts
frequently launched apps at the top.
5. Enter launches the selection. Escape dismisses.
This should feel instant. Sub-100ms to first paint.
## How It Works With pikl
### Basic Version
```sh
# Collect PATH binaries, open pikl, run selection
compgen -c | sort -u \
| pikl --mode gui \
| xargs -I{} sh -c '{} &'
```
### With tmux Integration
On select, create a new tmux session running the
chosen binary, then switch to it. Keeps everything
inside tmux for window management.
```sh
compgen -c | sort -u \
| pikl --mode gui \
--on-select 'cmd=$(cat);
tmux new-session -d -s "$cmd" "$cmd" &&
tmux switch-client -t "$cmd"'
```
### With .desktop Files
Parse XDG .desktop entries for proper app names and
icons instead of raw binary names:
```sh
# Hypothetical helper that emits JSON items
desktop-entries --json \
| pikl --mode gui \
--format '{icon} {label} <dim>{sublabel}</dim>'
```
Input would look like:
```jsonl
{"label": "Firefox", "sublabel": "Web Browser",
"meta": {"exec": "firefox", "desktop": "firefox.desktop"},
"icon": "/usr/share/icons/.../firefox.png"}
{"label": "Alacritty", "sublabel": "Terminal",
"meta": {"exec": "alacritty"},
"icon": "/usr/share/icons/.../alacritty.png"}
```
### Hyprland / Sway Keybinding
```
bind = SUPER, SPACE, exec, app-launcher.sh
```
On macOS, use skhd or a similar hotkey daemon.
### Fallback: TUI Mode
Before the GUI frontend exists, this works today in
a drop-down terminal:
```sh
# Bind to a hotkey that opens a terminal running:
compgen -c | sort -u | pikl | xargs -I{} sh -c '{} &'
```
## What pikl Features This Exercises
| Feature | Phase | How It's Used |
|---|---|---|
| GUI overlay | 8 | Centered layer-shell popup |
| Fuzzy filtering | 2 | Filter thousands of binaries |
| Frecency sorting | future | Boost frequently launched apps |
| on-select hook | 3 | Spawn or exec the selection |
| Structured I/O | 3 | .desktop metadata, icons |
| Fast startup | 1 | Must feel instant |
| Icon rendering | 8 | App icons in the list |
| Groups | 9 | Categories (dev, media, etc) |
## Stretch Goals
- **Frecency:** track launch counts per binary, sort
by frequency so your top apps float to the top.
This is the single biggest UX improvement over a
plain sorted list.
- **Categories:** group items by type. Dev tools,
browsers, media, system. Parsed from .desktop
Categories field or manually tagged.
- **Recent files:** a second section below apps
showing recently opened files. Needs a separate
data source (zeitgeist, custom tracker, etc).
- **Calculator / snippets:** if the query doesn't
match any app, treat it as a math expression or
snippet expansion. Scope creep, but it is what
makes launchers sticky.
## Platform Notes
- **Linux (Wayland):** layer-shell overlay. Global
hotkey via the compositor (Hyprland bind, Sway
bindsym). This is the primary target.
- **Linux (X11):** override-redirect window. Global
hotkey via xbindkeys or similar.
- **macOS:** no layer-shell. Needs a borderless
window with proper focus handling. Global hotkey
via skhd or a native Swift shim. Secondary target.
## Open Questions
- How to handle apps that need a terminal (e.g.
htop, vim). Detect from .desktop Terminal=true and
wrap in the user's preferred terminal emulator?
- Should the launcher persist as a background process
for faster re-open, or cold-start each time?
Background process is snappier but uses memory.
- PATH scanning: rescan on every open, or cache with
inotify/FSEvents on PATH directories?

View File

@@ -0,0 +1,145 @@
# Use Case: Wallpaper Picker
A keyboard-driven wallpaper picker for Hyprland that
previews wallpapers live on the desktop as you browse.
This is a first-class use case for pikl-menu: it
exercises structured I/O, lifecycle hooks, debouncing,
and the GUI overlay.
## What It Looks Like
1. User presses a keybind. pikl opens as a layer-shell
overlay on the focused monitor.
2. A list of wallpapers appears, filterable by typing.
3. Navigating the list applies each wallpaper live to
the desktop behind the overlay (via hyprpaper IPC).
4. Enter confirms. The wallpaper stays, overlay closes.
5. Escape cancels. The wallpaper reverts to whatever
was set before the picker opened.
The overlay is semi-transparent so the user sees the
real wallpaper behind it. No preview pane needed.
## How It Works With pikl
The wallpaper picker is not a standalone app. It is a
shell script (or a small wrapper) that pipes items into
pikl and uses hooks for the live preview behaviour.
### Basic Version
```sh
# List wallpapers as JSON, pipe to pikl with hooks
find ~/Pictures/walls -type f \
-name '*.jpg' -o -name '*.png' -o -name '*.webp' \
| jq -Rc '{label: (. | split("/") | last),
sublabel: (. | split("/") | .[:-1]
| join("/")), meta: {path: .}}' \
| pikl --mode gui \
--on-open 'hyprctl hyprpaper listactive
| head -1 > /tmp/pikl-wall-orig' \
--on-hover 'jq -r .meta.path
| xargs -I{} hyprctl hyprpaper
wallpaper "DP-4, {}"' \
--on-cancel 'cat /tmp/pikl-wall-orig
| cut -d: -f2 | xargs -I{}
hyprctl hyprpaper wallpaper "DP-4, {}"' \
--on-hover-debounce 100
```
### With a Manifest
For a reusable setup, use a pikl manifest file:
```toml
# ~/.config/pikl/wallpaper.toml
[hooks]
on-open = "save-current-wallpaper.sh"
on-hover = '''
jq -r .meta.path \
| xargs -I{} hyprctl hyprpaper \
wallpaper "DP-4, {}"
'''
on-cancel = "restore-wallpaper.sh"
on-hover-debounce = 100
[display]
format = "{label} <dim>{sublabel}</dim>"
```
```sh
find ~/Pictures/walls -type f \( \
-name '*.jpg' -o -name '*.png' \) \
| to-json-items \
| pikl --manifest ~/.config/pikl/wallpaper.toml \
--mode gui
```
### Modes
Different invocations cover different workflows:
**Browse:** open the picker, filter and select.
```sh
find ~/Pictures/walls -type f ... | pikl ...
```
**History:** pipe wallpaper history instead of a
directory scan. Same hooks, different input source.
```sh
cat ~/.local/state/wallpaper-history.jsonl \
| pikl --manifest ~/.config/pikl/wallpaper.toml \
--mode gui
```
**Random:** no pikl needed, just a shell one-liner.
```sh
find ~/Pictures/walls -type f ... \
| shuf -n1 \
| xargs -I{} hyprctl hyprpaper wallpaper "DP-4, {}"
```
**Restore:** read the last state file, apply it.
Also just a shell script, no pikl.
### Hyprland Keybindings
```
bind = $mod CTRL, B, exec, wallpicker browse
bind = $mod SHIFT, B, exec, wallpicker random
bind = $mod, B, exec, wallpicker copy
```
## What pikl Features This Exercises
| Feature | Phase | How It's Used |
|---|---|---|
| GUI overlay | 8 | Layer-shell on Wayland |
| Structured I/O | 3 | JSON items with metadata |
| on-hover hook | 3 | Live wallpaper preview |
| on-open hook | 3 | Save current wallpaper |
| on-cancel hook | 3 | Revert wallpaper |
| Hook debouncing | 3 | Don't flood hyprctl |
| Fuzzy filtering | 2 | Filter by filename |
| Manifest files | 3 | Reusable config |
| Watched sources | 7 | Live update on new files |
| Sessions | 6 | Remember last position |
## Open Questions
- Should pikl support image thumbnails in the item
list? Useful here but adds complexity. Could be a
GUI-only feature using the icon field.
- Debounce timing: 100ms feels right for hyprctl IPC.
Might need tuning for slower wallpaper daemons.
- Multi-monitor: the on-hover hook needs to know which
monitor to target. Could come from a CLI flag or
from hyprctl's focused monitor.
- History management: pikl sessions handle state, but
wallpaper history (which wallpapers were set when)
is the wrapper script's responsibility. Keep it
simple: append to a JSONL file.