diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md new file mode 100644 index 0000000..089aac4 --- /dev/null +++ b/docs/CODING_STANDARDS.md @@ -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` 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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..3878f29 --- /dev/null +++ b/docs/DESIGN.md @@ -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} {sublabel}' +``` + +### 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 ` 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 ` | 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 ` | 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 ` 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) diff --git a/docs/DEVPLAN.md b/docs/DEVPLAN.md new file mode 100644 index 0000000..1b85c15 --- /dev/null +++ b/docs/DEVPLAN.md @@ -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 `: 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 `: 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`. diff --git a/docs/use-cases/app-launcher.md b/docs/use-cases/app-launcher.md new file mode 100644 index 0000000..f590559 --- /dev/null +++ b/docs/use-cases/app-launcher.md @@ -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} {sublabel}' +``` + +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? diff --git a/docs/use-cases/wallpaper-picker.md b/docs/use-cases/wallpaper-picker.md new file mode 100644 index 0000000..5033c79 --- /dev/null +++ b/docs/use-cases/wallpaper-picker.md @@ -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} {sublabel}" +``` + +```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.