lotabout.skim/ARCHITECTURE.md
LoricAndre 17c517fd02
Some checks are pending
Pull request update / autoupdate (push) Waiting to run
Release PR / prepare (push) Waiting to run
Release PR / update-pr (push) Blocked by required conditions
Release PR / tag (push) Waiting to run
chore: nitpicks after multithreading review (#1172)
* chore: nitpicks after multithreading review

* chore: remove call sites from ARCHITECTURE.md

* chore: minor reliability corrections
2026-09-05 20:35:27 +02:00

1402 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Skim Architecture
## Table of Contents
1. [High-Level Overview](#high-level-overview)
2. [Workspace & Crate Layout](#workspace--crate-layout)
3. [Entry Points](#entry-points)
4. [Core Data Flow](#core-data-flow)
5. [Operating Modes](#operating-modes)
- [Normal Interactive Mode](#normal-interactive-mode)
- [Filter Mode (`--filter`)](#filter-mode---filter)
- [Interactive / Command Mode (`--interactive`)](#interactive--command-mode---interactive)
- [Select-1 / Exit-0 / Sync Modes](#select-1--exit-0--sync-modes)
- [ANSI Mode (`--ansi`)](#ansi-mode---ansi)
- [Popup Mode (`--popup` / `--tmux`)](#popup-mode---popup----tmux)
6. [Item Ingestion Pipeline](#item-ingestion-pipeline)
7. [The Matching Subsystem](#the-matching-subsystem)
- [Match Engine Hierarchy](#match-engine-hierarchy)
- [Fuzzy Algorithms](#fuzzy-algorithms)
- [Parallel Matching](#parallel-matching)
- [Ranking & Sorting](#ranking--sorting)
8. [TUI Subsystem](#tui-subsystem)
- [Backend & Terminal Setup](#backend--terminal-setup)
- [Event Loop](#event-loop)
- [App State](#app-state)
- [Widget System](#widget-system)
- [Layout Engine](#layout-engine)
9. [Individual Widgets](#individual-widgets)
- [Input Widget](#input-widget)
- [ItemList Widget](#itemlist-widget)
- [ItemRenderer](#itemrenderer)
- [Preview Widget](#preview-widget)
- [Header Widget](#header-widget)
- [StatusLine / Info](#statusline--info)
10. [Key Bindings & Action System](#key-bindings--action-system)
11. [Preview System](#preview-system)
12. [Output & Result Collection](#output--result-collection)
13. [IPC / Listen Socket](#ipc--listen-socket)
14. [Theming](#theming)
15. [History](#history)
16. [Pre-Selection](#pre-selection)
17. [Threading Model](#threading-model)
18. [Important Call Sites (Cross-Reference)](#important-call-sites-cross-reference)
19. [Public Library API](#public-library-api)
---
## High-Level Overview
Skim (`sk`) is a terminal fuzzy-finder written in Rust, equivalent in spirit to `fzf`. It can operate as a standalone CLI binary or as an embedded library crate. At runtime it orchestrates four concurrent activities:
```
stdin / command
┌──────────┐ batched items ┌──────────────┐
│ Reader │──────────────────▶│ ItemPool │
└──────────┘ │ (Arc<…>) │
└──────┬───────┘
│ take()
┌──────────────┐
│ Matcher │◀── query string
│ (parallel) │
└──────┬───────┘
│ ProcessedItems
┌──────────────────────────────────┐
│ TUI │
│ ┌────────┐ ┌────────┐ │
│ │ Input │ │Preview │ │
│ ├────────┤ ├────────┤ │
│ │ItemList│ │ Header │ │
│ └────────┘ └────────┘ │
└──────────────────────────────────┘
SkimOutput
```
The **Reader** pulls raw text from stdin or a shell command and converts it into `Arc<dyn SkimItem>` batches, depositing them into the shared `ItemPool`.
The **Matcher** picks items up from the pool, evaluates every item against the current query string using a configured engine, and writes ranked `MatchedItem` results into `ProcessedItems`.
The **TUI** renders four composable widgets (Input, ItemList, Preview, Header), drives a `crossterm`-based event loop, and converts user keystrokes into typed `Action` values that are dispatched back to the `App` state machine.
---
## Workspace & Crate Layout
```
skim/ ← workspace root
├── src/ ← single `skim` crate (lib + binary)
│ ├── bin/
│ │ └── main.rs ← `sk` binary entry point
│ ├── lib.rs ← library root; re-exports public types
│ ├── skim.rs ← Skim<Backend> orchestrator
│ ├── options.rs ← SkimOptions (all CLI / library options)
│ ├── output.rs ← SkimOutput (returned to callers) + BinOptions/write_output (CLI serialization)
│ ├── reader.rs ← Reader + ReaderControl + CommandCollector trait
│ ├── matcher.rs ← Matcher + MatcherControl (parallel worker dispatcher)
│ ├── item.rs ← ItemPool, MatchedItem, Rank, RankBuilder
│ ├── skim_item.rs ← SkimItem trait
│ ├── binds.rs ← KeyMap, parse_key, parse_action_chain
│ ├── theme.rs ← ColorTheme, named palettes
│ ├── thread_pool.rs ← ThreadPool + parallel_work_queue
│ ├── field.rs ← field range parsing (--nth / --with-nth / --hide-nth)
│ ├── spinlock.rs ← lightweight SpinLock<T>
│ ├── util.rs ← printf helper, misc utilities
│ ├── popup/ ← tmux & zellij popup integration
│ │ ├── mod.rs ← SkimPopup trait, run_with(), check_env(), SkimPopupOutput
│ │ ├── tmux.rs ← TmuxPopup (builds/runs tmux display-popup)
│ │ └── zellij.rs ← ZellijPopup (builds/runs zellij action new-floating-pane)
│ ├── prelude.rs ← convenience re-exports
│ ├── manpage.rs ← man-page generation (cli feature); action list generated from ACTION_CATALOG
│ ├── shell.rs ← shell completion generation (cli feature)
│ ├── engine/ ← match engine implementations
│ │ ├── mod.rs
│ │ ├── factory.rs ← ExactOrFuzzyEngineFactory, AndOrEngineFactory, RegexEngineFactory
│ │ ├── andor.rs ← AndEngine, OrEngine
│ │ ├── exact.rs ← ExactEngine (prefix/postfix/inverse/exact string)
│ │ ├── fuzzy.rs ← FuzzyEngine + FuzzyAlgorithm enum
│ │ ├── all.rs ← MatchAllEngine (match-all / empty query)
│ │ ├── normalized.rs ← NormalizedEngine (Unicode normalization wrapper)
│ │ ├── regexp.rs ← RegexEngine (regex-mode)
│ │ ├── split.rs ← SplitMatchEngine (--split-match)
│ │ └── util.rs ← normalization helpers
│ ├── fuzzy_matcher/ ← raw fuzzy scoring algorithms
│ │ ├── mod.rs ← FuzzyMatcher trait, MatchIndices type alias
│ │ ├── skim.rs ← SkimMatcherV2
│ │ ├── clangd.rs ← ClangdMatcher
│ │ ├── fzy.rs ← FzyMatcher
│ │ ├── frizbee.rs ← FrizbeeMatcher (typo-resistant)
│ │ └── arinae/ ← ArinaeMatcher (default; Smith-Waterman based)
│ ├── helper/ ← higher-level item helpers
│ │ ├── mod.rs
│ │ ├── item.rs ← DefaultSkimItem (ANSI parsing, field transforms)
│ │ ├── item_reader.rs ← SkimItemReader + SkimItemReaderOption (stdin/cmd → items)
│ │ ├── selector.rs ← DefaultSkimSelector (pre-selection)
│ │ └── macros.rs ← helper macros
│ └── tui/ ← terminal UI
│ ├── mod.rs ← Size (fixed/percent/negative), Direction, BorderType, re-exports
│ ├── app.rs ← App struct + render + event dispatch (central state machine)
│ ├── backend.rs ← Tui<B> (ratatui terminal wrapper + crossterm event pump)
│ ├── event.rs ← Event enum (re-exports Action, ActionCallback, parse_action)
│ ├── actions.rs ← Action enum + name + parse_action + ACTION_CATALOG, all generated by define_action_catalog!
│ ├── widget.rs ← SkimWidget trait + SkimRender result type
│ ├── input.rs ← Input widget (query box + cursor + status info)
│ ├── item_list.rs ← ItemList widget (scrollable match result list)
│ ├── item_renderer.rs ← ItemRenderer (per-item ANSI/highlight rendering)
│ ├── preview.rs ← Preview widget (plain text, PTY, or image preview pane)
│ ├── header.rs ← Header widget (--header / --header-lines)
│ ├── statusline.rs ← Info / InfoDisplay status bar modes
│ ├── layout.rs ← LayoutTemplate + AppLayout (pre-computed areas)
│ ├── options.rs ← TuiLayout enum, PreviewLayout struct
│ └── util.rs ← cursor helpers, style merging
├── tests/ ← integration & snapshot tests
│ ├── common/
│ │ ├── insta.rs ← snap! / insta_test! macros for TUI snapshot testing
│ │ └── zellij.rs ← ZellijController + sk_test! DSL: cross-platform e2e harness driving sk in a Zellij pane
│ ├── snapshots/ ← committed .snap files
│ ├── ansi.rs ← ANSI rendering tests
│ ├── options.rs ← option coverage tests
│ ├── preview.rs ← preview pane tests
│ └── …
├── benches/ ← criterion benchmarks
└── Cargo.toml
```
The single crate exports:
- A **library** (`lib`): all types under `skim::*`, suitable for embedding.
- A **binary** (`sk`, requires feature `cli`): the `clap`-based CLI.
The `cli` feature gates `clap`, `clap_complete`, `shlex`, `env_logger`, and `clap_mangen`.
The `image` feature (enabled by default) gates image preview support, including the
`image` and `ratatui-image` dependencies, the `ImageProtocol` enum, the
`SkimOptions::image` / `SkimOptions::image_picker` fields, and the
`PreviewContent::Image` rendering path. With the feature off, the `--image` flag and
its supporting code are compiled out entirely and neither image crate is pulled in.
The `image` crate is built with only the common decoders enabled (`png`, `jpeg`,
`gif`, `webp`) rather than its full default set, keeping the binary small; previewing
other formats (TIFF, OpenEXR, QOI, BMP, …) will fail.
The `listen` feature (enabled by default) gates the IPC socket that lets other processes
drive skim via `--listen` / `--remote`, including the `interprocess`, `ron`, and `serde`
dependencies, the `SkimOptions::listen` / `SkimOptions::remote` fields, and the `serde`
derives on `Action`. See [IPC / Listen Socket](#ipc--listen-socket).
---
## Entry Points
### Binary (`src/bin/main.rs`)
```
main()
├─ SkimOptions::from_env() ← parses argv via clap (feature=cli)
├─ opts.build() ← applies defaults, loads history files
├─ if opts.shell → generate_completions() ← early exit
├─ if opts.man → manpage::generate() ← early exit
├─ if opts.remote → IPC relay mode ← early exit
├─ sk_main(opts)
│ ├─ SkimItemReader::new(reader_opts) ← configure stdin reader
│ ├─ opts.cmd_collector = cmd_collector
│ │
│ ├─ if --popup/--tmux && check_env() → popup::run_with(&opts)
│ │
│ └─ else:
│ ├─ if stdin not a TTY (piped) → cmd_collector.of_bufread(stdin)
│ └─ Skim::run_with(opts, rx_item?)
└─ print output / write history / exit
```
### Library (`src/lib.rs` + `src/skim.rs`)
Two public entry points exist on `Skim`:
| Method | Use case |
| --- | --- |
| `Skim::run_with(options, source)` | Takes a `SkimItemReceiver` channel (or `None` to use the configured command collector). The canonical entry point. |
| `Skim::run_items(options, items)` | Convenience wrapper: accepts any `IntoIterator<Item: SkimItem>`, batches them through a bounded channel, and calls `run_with`. |
Advanced embedders and tests can also drive the lifecycle manually: `Skim::init`, `start`, `init_tui` / `init_tui_with`, `enter`, `run`, `output`, plus accessors such as `app`, `app_mut`, `tui_ref`, `tui_mut`, `app_and_tui`, and `event_sender`.
The two high-level helpers return `Result<SkimOutput>`.
---
## Core Data Flow
### Initialisation sequence
```
Skim::run_with(options, source)
├─ Skim::init(options, source)
│ ├─ parse height (Size enum)
│ ├─ ColorTheme::init_from_options(&options)
│ ├─ Reader::from_options(&options).source(source)
│ ├─ resolve cmd / expand initial_cmd (interactive mode)
│ └─ App::from_options(options, theme, cmd)
│ ├─ Input::from_options(…)
│ ├─ Preview::from_options(…)
│ ├─ Header::from_options(…)
│ ├─ ItemList::from_options(…)
│ ├─ ItemPool::from_options(…)
│ ├─ Matcher::from_options(…)
│ └─ LayoutTemplate::from_options(…)
├─ Skim::start()
│ ├─ reader.collect(item_pool, initial_cmd) ← spawns reader thread(s)
│ └─ app.restart_matcher(force=true) ← kicks off first match pass
├─ Skim::should_enter() → decides whether to open TUI
│ (handles filter / select-1 / exit-0 / sync blocking)
├─ if should_enter:
│ ├─ Skim::init_tui() → Tui::new_with_height(height)
│ ├─ Skim::enter() → tui.enter_terminal(); resolve image picker; listener; event task
│ └─ Skim::run() → async event loop (tick())
└─ Skim::output() ← collect results + kill reader
```
### Steady-state loop (`Skim::tick()`)
Each call to `tick()` runs a `tokio::select!` on four concurrent futures:
| Branch | Source | Action |
| --- | --- | --- |
| `tui.next()` | crossterm keyboard/mouse/resize/paste events | Dispatch to `app.handle_event()` |
| `matcher_interval.tick()` | 10 ms periodic timer (adaptive: disabled once reader finishes and all items are matched) | `app.restart_matcher(false)` |
| `items_available.notified()` | `Notify` set by `ItemPool::append` | `app.restart_matcher(false)` |
| `listener.accept()` | IPC socket (when `--listen`) | Parse RON-encoded `Action`, push to event queue |
---
## Operating Modes
### Normal Interactive Mode
The default mode. The TUI is shown in full. Items arrive from stdin or a command, are matched against the live query, and displayed in the list. The user navigates with keyboard/mouse and presses Enter to accept.
**Key files:** `src/skim.rs`, `src/tui/app.rs`, `src/tui/backend.rs`
### Filter Mode (`--filter`)
When `--filter <query>` is set, skim never opens the TUI.
`Skim::should_enter()` enters a busy-wait loop:
```
loop {
if matcher.stopped() && reader.is_done() && pool.num_not_taken() == 0 {
break;
}
sleep(1ms);
app.restart_matcher(false);
}
```
Then `app.item_list.items` is populated from `processed_items` and `output()` is called immediately. The matched items are printed to stdout by the binary, one per line (or null-delimited with `--print0`).
In filter mode the `FuzzyEngine` is built with `filter_mode = true`, which uses `fuzzy_match_range` instead of `fuzzy_indices` to skip the per-character index computation and run faster.
**Key files:** `src/skim.rs` (`should_enter()`), `src/engine/fuzzy.rs` (`filter_mode` fast path), `src/bin/main.rs` (output loop)
### Interactive / Command Mode (`--interactive`)
When `--interactive` is set together with `--cmd <template>`, the query box controls a shell command rather than a fuzzy filter. Every change to the input re-expands the template and issues a `Reload` event.
Template placeholders:
- `{}` — the current query
- `{q}` — alias for `{}`
- `{n}` — ordinal of the current item
`App::expand_cmd()` handles placeholder expansion. On `Action::ToggleInteractive`, the mode flips between the query controlling the fuzzy filter and the query driving the command.
In interactive mode, the initial command is expanded against the initial query:
```rust
// src/skim.rs Skim::init()
let initial_cmd = if app.options.interactive && app.options.cmd.is_some() {
app.expand_cmd(&cmd, true) // true = initial call
} else { cmd.clone() };
```
A `Reload(new_cmd)` event is handled at the `Skim::tick()` level (not `App::handle_event()`), because it must kill the reader and restart cleanly:
```rust
// src/skim.rs tick()
if let Event::Reload(new_cmd) = &evt {
self.handle_reload(&new_cmd.clone());
}
```
`handle_reload()`:
1. Kills `reader_control` (waits for all reader threads to stop)
2. Clears `ItemPool`
3. Clears `ItemList` (unless `no_clear_if_empty`)
4. Calls `app.restart_matcher(force=true)`
5. Starts a new `reader.collect(…)`
**Key files:** `src/skim.rs` (`handle_reload`, `tick`), `src/tui/app.rs` (`expand_cmd`, `handle_action``RefreshCmd`)
### Select-1 / Exit-0 / Sync Modes
All three are handled in `Skim::should_enter()` before opening the TUI:
| Option | Meaning | Behaviour |
| --- | --- | --- |
| `--select-1` | Auto-accept if exactly one match | Waits until ≥ 2 matches or reader/matcher done; returns without TUI if exactly 1 match |
| `--exit-0` | Exit immediately if no matches | Waits until ≥ 1 match or done; returns without TUI if 0 matches |
| `--sync` | Block until all items processed | Waits until `num_matched == usize::MAX` (effectively waits for full scan) |
### ANSI Mode (`--ansi`)
When `--ansi` is set, `SkimItemReaderOption::from_options` sets `use_ansi_color = true`. Each input line then creates a `DefaultSkimItem` with:
1. **Escape detection**: `DefaultSkimItem::contains_ansi_escape()` checks for `\x1b`.
2. **Stripping**: `strip_ansi()` returns `(stripped_text, ansi_info)` where `ansi_info` is a `Vec<(byte_pos, char_pos)>` mapping from stripped to original coordinates.
3. **Matching**: `text()` returns the stripped text; the matcher works on plain text.
4. **Display**: `display()` reconstructs styled `ratatui::text::Line` using `ansi_to_tui::IntoText`, then overlays match-highlight spans on top.
The coordinate mapping is critical: match indices come back in terms of stripped text positions, but the highlight must be applied to the original ANSI-containing string. `DefaultSkimItem::display()` applies a char-index offset conversion using `ansi_info`.
Without `--ansi`, any ANSI escape codes are passed through to `text()` and displayed as literal characters. If the raw input happens to contain escape sequences (but `--ansi` is not set), `escape_ansi()` is called to make them visible.
ANSI input uses the same parallel pipeline as plain input — there is no separate serial path. `DefaultSkimItem::new` handles the stripping inline inside the worker threads.
**Key files:** `src/helper/item.rs` (`DefaultSkimItem::new`, `strip_ansi`, `display`)
### Popup Mode (`--popup` / `--tmux`)
When `--popup [direction[,size[,size]]]` (alias `--tmux`) is set, the binary calls `check_and_run_popup()`, which checks `popup::check_env()` and, if true, delegates to `popup::run_with()` instead of `Skim::run_with()`.
**`check_env()`** returns `true` only when:
- `$_SKIM_POPUP` is **not** set in the environment (prevents the child process from recursing back into popup mode), and
- at least one supported multiplexer is detected: tmux (`$TMUX` set) or Zellij (`$ZELLIJ` set).
The popup flow:
1. Creates a temp directory for IPC (`/tmp/sk-popup-XXXXXXXX/`).
2. If stdin is piped, creates a named FIFO (`tmp_stdin`) and spawns a thread to relay stdin into it incrementally so the child can stream-read.
3. Reconstructs the `sk` command line from `std::env::args()`, shell-quotes every retained argument, strips `--popup`/`--tmux`, `--output-format`, and `--print-cmd`, then appends `--print-query --print-header --print-current --print-score`.
4. Forwards all `SKIM_*`, `RUST*`, and `PATH` environment variables to the child via the multiplexer's `-e` flag, **plus `_SKIM_POPUP=1`** to prevent re-entry.
5. Launches the popup via the appropriate backend:
- **tmux**: `tmux display-popup -E … sh -c <cmd> > stdout_file`
- **Zellij**: `zellij action new-floating-pane … -- sh -c <cmd> > stdout_file`
6. Waits for the popup process to exit.
7. Parses the structured stdout file (`query\nheader\ncurrent_item\nitem1\nscore1\n…`) into a synthetic `SkimOutput`; `cmd` is reconstructed from the parent options because `--print-cmd` is deliberately stripped.
The internal `SkimPopup` trait abstracts the two multiplexer backends:
```rust
trait SkimPopup {
fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup>;
fn add_env(&mut self, key: &str, value: &str);
fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
}
```
`TmuxPopup` and `ZellijPopup` each implement this trait. The active backend is selected at runtime: Zellij takes priority if both are available.
The child `sk` process runs fully independently inside the popup. The parent reads back a synthetic `SkimOutput` from the captured file. Because `_SKIM_POPUP=1` is set in the child's environment, `check_env()` returns `false` in the child, so it runs as a normal interactive skim session regardless of what `SKIM_DEFAULT_OPTIONS` contains. The stdin relay thread stops when the popup exits, avoiding broken-pipe noise if the child closes its FIFO early.
**Key files:** `src/popup/mod.rs` (`run_with`, `check_env`), `src/popup/tmux.rs` (`TmuxPopup`), `src/popup/zellij.rs` (`ZellijPopup`), `src/bin/main.rs` (`check_and_run_popup`)
---
## Item Ingestion Pipeline
All inputs — plain stdin, `--ansi`, `--nth`/`--with-nth`, and shell commands — flow through a
single unified parallel pipeline (`parallel_bufread`). There is no serial fallback path.
```
Source (stdin bytes or child process stdout)
│ SkimItemReader::of_bufread() or CommandCollector::invoke()
└── parallel_bufread() (all inputs)
├─ Thread 1: I/O reader — reads 256 KB chunks, splits at line boundaries,
│ assigns monotonic sequence numbers, sends to MPMC channel
├─ Thread 1: bounded dispatcher — uses in-flight tokens to limit queued/running jobs
├─ Pool workers: receive chunk jobs, validate UTF-8,
│ create DefaultSkimItem::new(line, ansi, trans_fields, matching_fields, delimiter)
│ .hidden_fields(hidden_fields, delimiter)
│ (handles ANSI stripping, --nth / --with-nth / --hide-nth inline),
│ send (seq, items) pairs
├─ Thread 1: reorder — collects (seq, items), emits in order through SkimItemReceiver;
│ drops tx_pipeline_done on exit (signals killer thread)
└─ Thread 1: killer — waits on rx_interrupt OR rx_pipeline_done (whichever fires first);
kills child process if one exists, then exits
SkimItemReceiver channel
│ Reader::collect()
│ collect_items() spawns a thread that blocks on recv_timeout (5ms) from the channel
└── ItemPool::append(items)
├─ respects --tac (reverse order)
├─ respects --header-lines (reserves first N items)
├─ notifies items_available (Notify) to wake matcher
└─ increments atomic length counter
```
### `DefaultSkimItem` construction matrix
| `with_nth` | `ansi` | `text` field | `orig_text` | `stripped_text` |
| --- | --- | --- | --- | --- |
| false | false | original line | None | None |
| false | true | original line | None | stripped (+ `ansi_info`) |
| true | false | transformed | original | None |
| true | true | transformed | original | stripped (+ `ansi_info`) |
Fields `/0` bytes are stripped from `text` (used for display/matching) but preserved in `orig_text` (used for output).
**`--hide-nth`** is orthogonal to the matrix above and applied through the builder method
`DefaultSkimItem::hidden_fields(hidden_fields, delimiter)` after construction (rather than a `new`
parameter). The requested fields are resolved to byte ranges (in the same coordinate space as
`text()` — the stripped text under `--ansi`, otherwise the `text` field) and stored as
`hidden_ranges` in the item metadata, exposed via the `SkimItem::hidden_ranges()` trait method. The
hidden fields **remain part of `text()`**, so they stay searchable and still participate in matching.
They only affect rendering:
- `DefaultSkimItem::display()` removes the hidden characters and remaps the match highlight
positions into the visible coordinate space (`project_visible_text` / `project_match_indices` in
`src/helper/item.rs`). This is integrated into **both** display branches: the plain branch projects
the text through `to_line`, and the ANSI branch drops the hidden characters from the already-parsed
styled spans (`retain_visible_spans`) so surviving characters **keep their ANSI colors**, then runs
the normal highlighting on the remapped visible-coordinate matches.
- `ItemRenderer::render_item` applies the same projection to derive the visible sub-line text and the
match range used for horizontal scrolling, so hidden characters are ignored for hscroll and never
highlighted.
---
## The Matching Subsystem
### Match Engine Hierarchy
Engines are composable through the factory pattern. Starting from `Matcher::create_engine_factory_with_builder()`:
```
options
├── if regex mode:
│ RegexEngineFactory (configured with the same RankBuilder / --tiebreak criteria)
│ └─ if normalize: NormalizedEngineFactory(RegexEngineFactory)
└── else (fuzzy/exact mode):
ExactOrFuzzyEngineFactory
└─ if split_match: SplitMatchEngineFactory(ExactOrFuzzyEngineFactory)
└─ AndOrEngineFactory(SplitMatchEngineFactory | ExactOrFuzzyEngineFactory)
└─ if normalize: NormalizedEngineFactory(AndOrEngineFactory)
```
When `create_engine_with_case(query, case)` is called at match time, the factory chain parses the query string and builds a concrete engine tree:
```
query: "'abc def | ghi ^xyz"
AndOrEngineFactory.parse_andor()
├─ "'abc" → ExactOrFuzzyEngineFactory
│ → ExactEngine (prefix=false, postfix=false, case-insensitive "abc")
├─ "def | ghi" → OrEngine
│ ├─ FuzzyEngine("def")
│ └─ FuzzyEngine("ghi")
└─ "^xyz" → ExactEngine(prefix=true, "xyz")
```
Query prefix semantics handled by `ExactOrFuzzyEngineFactory::create_engine_with_case()`:
| Prefix/suffix | Engine type |
| --- | --- |
| `'abc` | force ExactEngine (toggle from default) |
| `!abc` | ExactEngine with `inverse = true` |
| `^abc` | ExactEngine with `prefix = true` |
| `abc$` | ExactEngine with `postfix = true` |
| `!^abc` | ExactEngine inverse+prefix |
| `!^abc$` | ExactEngine inverse+prefix+postfix (exact string, inverted) |
| plain `abc` | FuzzyEngine (or ExactEngine if `--exact`) |
| empty / `!` | MatchAllEngine |
### Fuzzy Algorithms
All algorithms implement the `FuzzyMatcher` trait with two methods:
- `fuzzy_indices(choice, pattern) → Option<(score, Vec<usize>)>` — full match with per-character highlights
- `fuzzy_match_range(choice, pattern) → Option<(score, begin, end)>` — fast path without highlight indices (used in filter mode)
| Algorithm | Flag | Notes |
| --- | --- | --- |
| `Arinae` | `--algorithm arinae` (default) | Smith-Waterman with affine gaps; typo-resistant; picks last occurrence on ties when `--last-match` |
| `SkimV2` | `--algorithm skim_v2` | Skim's classic dynamic-programming scorer |
| `Clangd` | `--algorithm clangd` | Clangd-style subsequence scoring |
| `Fzy` | `--algorithm fzy` | Port of the `fzy` C algorithm; supports `--typos` |
| `Frizbee` | `--algorithm frizbee` | Edit-distance based; explicitly typo-tolerant |
Typo tolerance is configured via `Typos`:
- `Typos::Disabled` — no tolerance (default)
- `Typos::Smart` — adaptive: `query.len() / 4` typos allowed
- `Typos::Fixed(n)` — exactly n typos
### Parallel Matching
`Matcher::run()` dispatches work across the thread pool using `thread_pool::parallel_work_queue()`:
```
Matcher::run(query, item_pool, thread_pool, …)
├─ create matcher_engine from factory (synchronous)
├─ take items from pool synchronously (avoids race with restart)
└─ std::thread::spawn(coordinator closure)
├─ shares items as Arc<[Arc<dyn SkimItem>]>
├─ parallel_work_queue(pool, num_workers, items, CHUNK_SIZE=4096, …)
│ │
│ ├─ Worker threads (num_cpus - 1):
│ │ ├─ atomically grab next chunk
│ │ ├─ for each item: matcher_engine.match_item(item)
│ │ ├─ flush processed/matched counters (Relaxed atomic)
│ │ └─ accumulate into worker-local Vec<MatchedItem>
│ │ └─ sort_unstable() on worker thread (parallel sort)
│ │
│ ├─ AtomicCounter barrier (lock-free AtomicUsize + thread::park/unpark)
│ │
│ └─ coordinator:
│ └─ merge_worker_results(worker_results, no_sort, …)
│ ├─ concatenate k sorted runs
│ ├─ sort() (stable; driftsort detects k runs → O(n log k))
│ └─ validate query generation and write into Mutex<Option<ProcessedItems>>
└─ stopped.store(true)
```
Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before processing. `MatcherControl::kill()` sets `interrupt = true`; `MatcherControl::drop()` also calls `kill()`. Each forced restart increments a shared query generation. Published `ProcessedItems` carry that generation; both publication and consumption reject stale generations, so a cancelled matcher cannot replace newer results.
### Ranking & Sorting
`MatchedItem` implements `Ord` through a lazy sort key computed by `Rank::sort_key(criteria)`. Items can also be disabled: `SkimItem::disabled()` returns `false` by default, and `--disable-pattern <regex>` marks matching items as disabled in the default item type. Disabled items stay visible but are dimmed by `ItemRenderer` and cannot be selected.
`Rank` fields:
| Field | Description |
| --- | --- |
| `score` | Raw match score (higher = better) |
| `begin` | First matched character index |
| `end` | Last matched character index |
| `length` | Total item text length in bytes |
| `index` | Ordinal position in the input stream |
| `path_name_offset` | Byte offset after last `/` or `\` (for path-name tiebreak) |
`RankCriteria` variants (configurable via `--tiebreak`): `Score`, `NegScore`, `Begin`, `NegBegin`, `End`, `NegEnd`, `Length`, `NegLength`, `Index`, `NegIndex`, `PathName`, `NegPathName`.
`MergeStrategy` (in `item_list.rs`):
| Strategy | When used |
| --- | --- |
| `Replace` | Fresh match pass (query changed, full re-sort) |
| `SortedMerge` | New items arrived during a running match (merge-insert) |
| `Append` | Incremental `--no-sort` mode |
| `Prepend` | Incremental `--tac --no-sort` mode, preserving global reverse-input order |
`Rank::index` always records the item's stable ordinal in the original input stream. `RankBuilder` makes both configured index criteria and the implicit final index tiebreak descending under `--tac`, so normal `SortedMerge` remains valid across independently reversed batches.
---
## TUI Subsystem
### Backend & Terminal Setup
`Tui<B>` (in `src/tui/backend.rs`) wraps `ratatui::Terminal<B>` and owns:
- A `tokio::sync::mpsc` channel (`event_tx` / `event_rx`) of capacity 1 M for events.
- A `JoinHandle` for a background Tokio task that reads `crossterm::event::EventStream` and sends `Event` values.
- A `CancellationToken` to stop the background task.
- An `is_fullscreen` flag that determines the `ratatui::Viewport`.
- The fixed viewport `Rect` for inline mode, which can move when the terminal scrolls.
**Viewport selection** (`Tui::new_with_height_and_backend()`):
- `Size::Percent(100)``Viewport::Fullscreen` (enters alternate screen).
- `Size::Fixed(lines)``Viewport::Fixed(Rect)` with that many rows.
- `Size::Percent(p)` → fixed viewport with `terminal_height * p / 100` rows.
- `Size::Neg(lines)` → fixed viewport with `terminal_height - lines` rows, saturating at zero.
Any fixed viewport is anchored at the current cursor position; the terminal is scrolled if needed to make room. After construction, `Tui::min_height()` can increase an inline viewport to `--min-height`. It limits the height to the terminal height and scrolls the terminal before it moves and resizes the viewport when there are too few rows below it.
The default backend is `CrosstermBackend<BufWriter<Stderr>>`. Skim always draws to **stderr** so stdout remains clean for piped output.
**Terminal lifecycle:**
```
Tui::enter()
├─ enable_raw_mode()
├─ execute!(EnableMouseCapture, EnableBracketedPaste)
├─ if fullscreen: execute!(EnterAlternateScreen, cursor::Hide)
└─ Tui::start() ← spawns event pump task
Tui::exit()
├─ Tui::stop() / cancel()
├─ cleanup_terminal()
│ ├─ execute!(DisableMouseCapture, DisableBracketedPaste, LeaveAlternateScreen, Show)
│ └─ disable_raw_mode()
└─ if inline: clear() + reset cursor to top of drawing area
```
A panic hook is installed once (`PANIC_HOOK_SET: Once`) to ensure `cleanup_terminal()` runs even on panics.
**Foreground `execute` actions:** the `execute(cmd)` action must hand the terminal to a child process (e.g. an editor or an interactive TUI like `ncdu`). `handle_action` only expands the command and returns `Event::RunExecute(cmd)`; the actual run happens in `App::handle_event` (which owns the `Tui`) via the `run_foreground(tui, cmd)` helper in `src/tui/app.rs`. `run_foreground` calls `Tui::stop_and_join()` — which cancels the event-pump task **and blocks until it has dropped its `EventStream`** — so skim's reader stops consuming terminal input before the child starts; otherwise the two race for keystrokes and interactive children appear to freeze. It then leaves the alternate screen / raw mode, spawns the child with its **own** stdin opened from the controlling terminal (`/dev/tty`, or `CONIN$` on Windows; falls back to inheriting skim's stdin), waits for it, restores terminal modes, and calls `Tui::start()` to respawn the reader. Giving the child its own tty stdin is what lets `execute` work when skim's own stdin is a pipe (`find | sk`). `execute-silent(cmd)` needs no terminal and is still spawned directly inside `handle_action` with stdout/stderr sent to `/dev/null`.
Two subtleties make the resume correct. First, `Tui::start` installs a **fresh** `CancellationToken` on every call: a token stays cancelled once cancelled, so reusing the one `stop_and_join` cancelled would make the respawned reader observe the cancellation immediately and exit without reading input. Second, the post-execute repaint uses `Tui::force_full_redraw` (which resets both of ratatui's diff buffers) followed by `Event::Render`, rather than `Event::Redraw`/`tui.clear()`: ratatui's `Terminal::clear` first queries the cursor position, and crossterm writes that query (`ESC [ 6 n`) to **stdout**. Since skim renders to stderr and its stdout is routinely redirected (`sk > file`, `find | sk | …`), that query would reach no terminal, get no reply, and stall the UI for seconds before erroring out. `force_full_redraw` performs no cursor query and works for both fullscreen and inline viewports.
Image preview protocol detection is owned by `Skim::enter()`, not `Tui::enter_terminal()`: `--image=detect` temporarily ensures an alternate screen is active, queries `ratatui_image::picker::Picker::from_query_stdio()`, falls back to `Picker::halfblocks()` on failure, then stores the picker in both `SkimOptions.image_picker` and the `Preview` widget. `--image=halfblocks` skips detection and installs `Picker::halfblocks()` directly.
### Event Loop
The crossterm event pump (background Tokio task in `Tui::start()`):
```
loop {
select! {
cancelled → break
crossterm_event →
Key(press) → send Event::Key(key)
Paste(text) → send Event::Paste(text)
Mouse(m) → send Event::Mouse(m)
Resize(c,r) → send Event::Resize + Event::Render
Error(e) → send Event::Error(e)
tick (1/12 s) → send Event::Heartbeat
}
}
```
The main loop (`Skim::run()`) calls `tick()` in a loop, which `select!`s on the same channel plus the matcher interval and IPC listener.
Frame rate is capped at 120 fps (`FRAME_TIME_MS = 1000/120`). `App::handle_event(Heartbeat)` checks `needs_render` (an `AtomicBool` set by the matcher when new results arrive) and emits `Event::Render` only when the last render was more than `FRAME_TIME_MS` ago.
### App State
`App` (in `src/tui/app.rs`) is the single mutable application state. It contains:
| Field | Type | Role |
| --- | --- | --- |
| `item_pool` | `Arc<ItemPool>` | Shared with reader; accumulates raw items |
| `matcher` | `Matcher` | Engine factory + case + rank config |
| `matcher_control` | `MatcherControl` | Handle to stop/query current match pass |
| `item_list` | `ItemList` | Display list + selection state |
| `input` | `Input` | Query text + cursor |
| `preview` | `Preview` | Preview pane state + process handle |
| `header` | `Header` | Static + dynamic header lines |
| `thread_pool` | `Arc<ThreadPool>` | Worker threads for matching |
| `options` | `SkimOptions` | Full configuration snapshot |
| `layout_template` | `LayoutTemplate` | Pre-computed widget constraints |
| `layout` | `AppLayout` | Last-frame widget areas (updated in render) |
| `needs_render` | `Arc<AtomicBool>` | Signal from matcher → event loop |
| `yank_register` | `String` | Cut/yank buffer |
| `query_history` / `cmd_history` | `Vec<String>` | History for ↑/↓ navigation |
| `last_left_click` | `Option<Instant>` | Detect two left clicks within the 500 ms `double-click` window |
**`App::handle_event()`** dispatches on `Event`:
```
Event::Render → tui.draw(|f| f.render_widget(&mut self, f.area()))
Event::Heartbeat → update_spinner(); check pending_matcher_restart; throttled render
Event::RunPreview → run_preview(tui)
Event::RunExecute(cmd) → run_foreground(tui, cmd); force_full_redraw(); send Render
Event::Key(k) → handle_key(k) → [Action…] → tui.event_tx.send(Event::Action)
Event::Action(a) → handle_action(a) → [Event…] → tui.event_tx.send(…)
Event::Paste(t) → input.insert_str(cleaned); on_query_changed()
Event::Resize(…) → app.resize(); run_preview()
Event::Mouse(…) → handle_mouse() → normal handling + optional `double-click` key event
Event::PreviewReady → apply preview offset; needs_render()
Event::AppendItems → item_pool.append(); restart_matcher(false)
Event::ClearItems → item_pool.clear(); restart_matcher(true)
Event::Quit/Close → tui.exit(); should_quit = true
Event::Reload(_) → (handled by Skim::tick, not here)
```
**`App::restart_matcher(force)`:**
```
restart_matcher(force)
├─ if !force && matcher not stopped → skip (debounce)
├─ if pool has no un-taken items && !force → skip
├─ kill existing matcher_control
├─ determine MergeStrategy
│ ├─ Replace → if query changed / force
│ ├─ Prepend → incremental --tac --no-sort
│ └─ SortedMerge / Append otherwise
└─ matcher.run(query, pool, thread_pool, processed_items, strategy, no_sort, needs_render)
→ returns new MatcherControl
```
A 200 ms debounce (`restart_matcher_debounced`) is applied to query-change events to avoid thrashing the matcher on rapid typing.
### Widget System
All TUI widgets implement `SkimWidget`:
```rust
pub trait SkimWidget: Sized {
fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self;
fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender;
}
pub struct SkimRender {
pub items_updated: bool,
pub run_preview: bool, // signals that preview should be (re)spawned
}
```
`App` itself implements `ratatui::widgets::Widget` via `impl Widget for &mut App`, which calls each sub-widget's `render()` and ORs their `SkimRender` results. After render, `App` sets `cursor_pos` to the absolute screen coordinates of the input cursor.
### Layout Engine
`LayoutTemplate` pre-computes area splits from `SkimOptions` once and stores constraint trees.
`apply(area: Rect) → AppLayout` is then a cheap, allocation-free split. Bordered
widgets share adjacent border rows and columns by default;
`SkimOptions::border_no_collapse` keeps their areas separate.
`AppLayout` has four optional areas:
```rust
pub struct AppLayout {
pub list_area: Rect,
pub input_area: Rect,
pub header_area: Option<Rect>,
pub preview_area: Option<Rect>,
}
```
Layout is rebuilt on `Event::Resize`, when the header height changes (multiline header items arriving), and on `TogglePreview`.
**Layout orientations** (`TuiLayout`):
| Mode | Description |
| --- | --- |
| `Default` | Input at bottom, list above, header above list (bottom-to-top reading) |
| `Reverse` | Input at top, list below (top-to-bottom reading) |
| `ReverseList` | List at top, input at bottom |
**Preview placement** is parsed from `--preview-window`:
- Direction: `left` / `right` / `up` / `down`
- Size: `50%` (default), fixed cells, or negative cells (`-N`, meaning the non-preview side keeps `N` cells)
- Modifiers: `hidden`, `wrap`, `pty`, `+offset`
---
## Individual Widgets
### Input Widget
`Input` (`src/tui/input.rs`) maintains:
- `value: String` — the query text (primary mode)
- `alternate_value: String` — the command text (interactive mode)
- `cursor_pos: usize` — character-level cursor position
Text operations (used by `handle_action`):
- `insert(char)` / `insert_str(&str)` — insert at cursor
- `delete(n)` — delete n characters forward
- `delete_backward_word()` / `delete_to_beginning()` / `delete_forward_word()`
- `move_cursor(delta)` / `move_cursor_to(pos)` / `move_to_end()`
- `move_cursor_forward_word()` / `move_cursor_backward_word()`
- `switch_mode()` — swaps primary and alternate buffers (interactive mode toggle)
The `StatusInfo` struct rendered inside the input line shows:
`{spinner} {matched}/{total} ({processed}) [{matcher_mode}] [{multi_count}]`
### ItemList Widget
`ItemList` (`src/tui/item_list.rs`) maintains:
- `items: Vec<MatchedItem>` — the currently displayed matched items
- `processed_items: Arc<Mutex<Option<ProcessedItems>>>` — shared with matcher
- `matcher_generation: Arc<AtomicUsize>` — identifies the active query generation
- `selection: Vec<usize>` — indices of multi-selected items
- `current: usize` — focused item index (0 = bottom in default layout)
- `offset: usize` — scroll offset (number of items scrolled)
- `manual_hscroll: i16` — user-driven horizontal scroll
On each render, `ItemList::render()` checks `processed_items`, rejects results from an old query generation, and swaps current results in through the mutex. Depending on `MergeStrategy`:
- `Replace`: replaces `items` entirely.
- `SortedMerge`: performs an O(n+m) merge preserving order.
- `Append`: extends `items`.
- `Prepend`: places a reversed incremental `--tac` batch before existing items; the cursor follows the head unless the user moved away from it.
**Selection state management:**
- `toggle_at(idx)` / `toggle()` / `toggle_all()` / `select_all()` / `clear_selection()`
- `scroll_by_rows(n)` — scroll by terminal rows (accounting for multiline items)
- `scroll_by(n)` — scroll by item count
- `jump_to_first()` / `jump_to_last()`
Pre-selection is applied when items first appear: `DefaultSkimSelector::should_select(index, item)` is checked for each item and matching items are added to `selection`.
### ItemRenderer
`ItemRenderer` (`src/tui/item_renderer.rs`) is an ephemeral struct created per render frame. It handles all per-item display concerns:
1. **Selector icon rendering**`>` (single-select cursor) or configurable icon.
2. **Multi-select icon** — space / `>` or configurable icon per selection state.
3. **Match highlight** — builds a `DisplayContext` (`score`, `Matches`, width, base style, highlight style) and calls `item.display(context)` so custom items can render their own styled `Line<'_>`.
4. **Horizontal scroll**`calc_hscroll()` finds the first matched character and auto-scrolls to show it; `apply_hscroll()` clips spans accordingly.
5. **Tab expansion**`expand_tabs()` replaces `\t` with spaces at configurable width.
6. **Ellipsis truncation** — replaces overflowing content with `…` (or custom `--ellipsis`).
7. **Multiline items** — when `--multiline <sep>` is set, splits item text on the separator and renders sub-lines.
8. **Score / index display** — when feature flags `ShowScore` / `ShowIndex` are set.
9. **Disabled state** — dims all spans when `item.disabled()` is true.
### Preview Widget
`Preview` (`src/tui/preview.rs`) renders a side/top/bottom pane showing expanded information about the focused item. Its stored content is one of three variants:
**Plain text mode** (no `pty`): spawns `sh -c <cmd>` on Unix or `cmd /c <cmd>` on Windows. On Windows, `Command::raw_arg` is used so `cmd.exe` receives shell metacharacters exactly as written. The worker drains stdout and stderr concurrently, but retains at most `PREVIEW_MAX_BYTES` from each stream. Cancellation terminates the child process group (the process tree on Windows), so selection changes do not leave old preview commands running. Successful stdout or failed stderr is parsed with `ansi_to_tui::IntoText`, stored as `PreviewContent::Text`, and followed by `Event::PreviewReady`.
**PTY mode** (`--preview-window pty`): creates a real pseudo-terminal pair via `portable_pty`. The child process sees a properly sized terminal (via `ROWS`/`COLUMNS` env and PTY dimensions). Output is parsed by a `vt100::Parser` with a scrollback buffer, stored as `PreviewContent::Terminal(Arc<RwLock<vt100::Parser>>)`. This enables interactive preview programs (e.g. `bat`, `delta`).
**Image mode** (`--image[=detect|halfblocks]`, requires the default `image` feature): treats the expanded preview command as an image path instead of executing it. A worker thread decodes the image with the `image` crate and stores `PreviewContent::Image { source, protocol, size }`. Rendering uses `ratatui_image`; `detect` builds an image protocol picker after entering the alternate screen, while `halfblocks` skips terminal capability detection and uses the portable half-block renderer. The protocol is rebuilt when the preview area changes so the image keeps its aspect ratio within the pane.
`Preview::spawn()`:
```
kill() ← kill any running preview
reset scroll_y / scroll_x
if image mode:
decode <cmd> as image path
thread: content.write() = PreviewContent::Image or failure text
→ Event::PreviewReady
else if pty mode:
init_pty() ← create PtyPair
resize PTY to current (rows, cols)
spawn sh -c <cmd> in slave
thread: read master → filter_and_respond_to_queries → vt100::Parser::process
→ Event::PreviewReady when EOF
else:
start shell in a dedicated process group with piped stdout + stderr
thread: drain both streams with bounded retention; poll child status
→ cancellation kills the process group
→ content.write() = PreviewContent::Text(…)
→ Event::PreviewReady
```
Scroll state: `scroll_y`, `scroll_x` (in lines/columns) and `total_lines` use `usize`; conversion to ratatui's `u16` coordinates saturates at render time. `page_up/down`, `scroll_up/down/left/right` modify these. `PreviewPosition` supports fixed, percentage, and negative offsets. When `PreviewReady` fires, an optional offset expression (from `--preview-window +expr`) is evaluated to auto-scroll to the matched line.
### Header Widget
`Header` (`src/tui/header.rs`) renders two kinds of content:
- **Static** (`--header <text>`): shown at the top or bottom depending on layout; expanded for tab characters once at init.
- **Dynamic** (`--header-lines N`): first N items from `ItemPool::reserved()` are treated as header lines instead of selectable items.
When `--multiline <sep>` is active, each header-line item may span multiple terminal rows. The `height()` method returns the total rows needed; if this changes between frames (multiline items arrive), `App::render()` detects it and rebuilds `LayoutTemplate`.
### StatusLine / Info
`StatusInfo` is computed inside `Input::render()` from the current `App` state:
```
Left side: "> " + spinner + " " + matched + "/" + total [+ "(N%)"]
Inline sep: " < " (when inline_info)
Right side: multi-select count when multi mode
```
`InfoDisplay` has six modes:
- `Default` — separate line above the prompt
- `Left` — separate line above the prompt, with all info left-aligned
- `Right` — separate line above the prompt, with all info right-aligned
- `Inline` — inside the prompt line (after the query text)
- `InlineRight` — inside the prompt line, right-aligned
- `Hidden` — not shown
---
## Key Bindings & Action System
### Key Parsing
`parse_key(key_str)` (in `src/binds.rs`) converts strings like `"ctrl-a"`, `"alt-shift-f"`, `"f10"`, `"enter"` into `crossterm::event::KeyEvent { code, modifiers }`.
`parse_action(raw)` (in `src/tui/actions.rs`, re-exported from `src/tui/event.rs`) converts strings like `"down:2"`, `"execute(ls {})"`, `"if-query-empty:reload+up"` into `Action` variants.
`parse_action_chain(chain)` splits on `+` (respecting `if-*{…+…}` syntax) into `Vec<Action>`.
`parse_keymap("key:action+action")``(&str, Vec<Action>)`.
### Default Key Map (`get_default_key_map`)
Notable defaults:
| Key | Action |
| --- | --- |
| `Enter` | `Accept(None)` |
| `Esc` | `Abort` |
| `Ctrl-C` / `Ctrl-D` / `Ctrl-G` | `Abort` |
| `↑` / `Ctrl-K` / `Ctrl-P` | `Up(1)` |
| `↓` / `Ctrl-J` / `Ctrl-N` | `Down(1)` |
| `Tab` | `Toggle` + `Down(1)` |
| `Shift-Tab` / `BackTab` | `Toggle` + `Up(1)` |
| `Ctrl-A` | `BeginningOfLine` |
| `Ctrl-E` | `EndOfLine` |
| `Ctrl-U` | `UnixLineDiscard` |
| `Ctrl-W` | `UnixWordRubout` |
| `Ctrl-Y` | `Yank` |
| `Ctrl-Q` | `ToggleInteractive` |
| `Ctrl-R` | `RotateMode` |
| `Shift-↑` / `Shift-↓` | `PreviewUp(1)` / `PreviewDown(1)` |
| `Alt-H` / `Alt-L` | `ScrollLeft(1)` / `ScrollRight(1)` |
User bindings from `--bind key:action[+action]` are parsed at startup and merged via `KeyMap::add_keymaps()`.
### Mouse Bindings
| Bind | Default | Fired when |
| --- | --- | --- |
| `double-click` | `Accept(None)` | two left-button presses occur within 500 ms; the first press still performs normal item selection |
`App::handle_mouse` recognizes the gesture after normal click handling and routes
it through the keymap using the reserved `SkimEvent::DoubleClick` key code.
### Synthetic Events (`SkimEvent`)
Besides real key presses, skim fires a few *synthetic* events that can be bound
to actions just like keys. Because the keymap is keyed by
`crossterm::event::KeyEvent`, these events are represented *transparently* as
reserved function-key codes in the high-`F` range that no real terminal emits.
The [`SkimEvent`](src/binds.rs) enum gives them named variants so the reserved
codes live in one place rather than being scattered as magic `F(255)` literals,
and `parse_key` accepts the friendly names below:
| Bind name | `SkimEvent` | Reserved code | Fired when |
| --- | --- | --- | --- |
| `change` | `SkimEvent::Change` | `F(255)` | the query changes |
| `start` | `SkimEvent::Start` | `F(254)` | skim has started and entered its event loop (once) |
| `load` | `SkimEvent::Load` | `F(253)` | the reader finishes producing items (once per read; a `reload` fires it again) |
| `result` | `SkimEvent::Result` | `F(252)` | filtering for the current query completes and its results are ready |
| `focus` | `SkimEvent::Focus` | `F(251)` | the focused item changes on cursor movement or a result update |
| `zero` | `SkimEvent::Zero` | `F(250)` | the reader is done and the final search has no matches |
| `one` | `SkimEvent::One` | `F(249)` | the reader is done and the final search has exactly one match |
Events are injected from the nearest state-change site:
- **`change`** — `App::on_query_changed`.
- **`focus`** — `App::on_selection_changed` handles cursor movement;
`Event::Render` checks again after matcher output is merged into the list so
result-driven focus changes are also observed. `take_focus_event` de-duplicates
both paths.
- **`start`** — `Skim::fire_start_event` (`src/skim.rs`). `Skim::check_reader`
only records the `reader_done` state; it does not itself emit `load`.
- **`load`/`result`/`zero`/`one`** — these track *async* reader/matcher
completion, which has no synchronous callback, so `App::poll_completion_events`
owns and edge-triggers them from the `Heartbeat` handler (not the render path). A
`Render` is queued just before them so a binding that inspects the list (e.g.
`load:first`) sees the finished results. `result` may fire for intermediate
matcher passes while input is streaming; `zero`/`one` wait for `reader_done`
before reading `MatcherControl::get_num_matched()`, so a transient empty or
one-item pass cannot terminate the finder before later input arrives.
Each event flows through `handle_key` and its keymap lookup like any other key,
so an unbound event is a harmless no-op.
### Actions as Events (follow-up bindings)
Any **action** can also be bound as if it were an event: after the action runs,
a follow-up chain bound to its name is dispatched directly. For example,
`reload:first` runs `first` right after a `reload`, and `first:last` ends on the
last item.
- **Keys win.** If a bind's "key" resolves to a real key it stays in the key
map, so a name shared by a key and an action (e.g. `up`) always binds the key.
Prefix with `act-` to target the action instead: `act-up:down`.
- **Non-recursive.** Follow-up actions use `noremap` semantics: actions in the
right-hand chain do not trigger their own follow-up bindings.
- **`suppress`.** Including [`Action::Suppress`] in the follow-up chain cancels
only the triggering action's default behaviour. Thus
`act-up:suppress+down+up` executes `down` then `up` once. On its own,
`suppress` is a no-op (equivalent to `ignore`).
Follow-up chains are parsed by `binds::parse_action_binds` into
`SkimOptions::action_binds` (keyed by `Action::name`), and applied in
`App::handle_action`, which dispatches each chain member through the private
per-variant `App::dispatch_action` without re-entering `handle_action`.
Conditional actions likewise dispatch their selected subaction chain immediately
through `dispatch_action`, preserving the same non-recursive semantics. When a
directly dispatched subaction accepts or aborts, `App::final_action` records it
and `Skim::tick` copies it to `Skim::final_event`, so output and exit status
reflect the actual terminating action rather than its outer trigger.
The runtime `bind`/`unbind` actions manage action triggers as well as keys:
`bind(act-up:last)` merges into `action_binds` and `unbind(act-up)` removes the
trigger (resolved via `binds::action_trigger_name`), with the same keys-win
precedence as `--bind`.
### Action Dispatch
```
Event::Key(k) → handle_key(k)
├─ lookup k in options.keymap → [Action…]
├─ fallback: Ctrl-C → Quit
├─ fallback: printable char → AddChar(c) or AddChar(uppercase)
└─ emit Vec<Event::Action(a)> to event queue
Event::Action(a) → handle_action(a) → Vec<Event>
```
`handle_action` is a large match statement covering all ~70+ `Action` variants. The local
`define_action_catalog!` macro (in `src/tui/actions.rs`) is the single source for the `Action` enum itself, each
variant's canonical bind name, its parser arm, and the `ACTION_CATALOG` documentation list the manpage's action
section is generated from — so the enum, `Action::name`, `parse_action` and the manpage cannot drift. Key action categories:
| Category | Actions |
| --- | --- |
| Navigation | `Up/Down(n)`, `HalfPageUp/Down`, `PageUp/Down`, `First/Last/Top` |
| Text editing | `AddChar`, `BackwardChar/DeleteChar/Word`, `ForwardChar/Word`, `KillLine`, `Yank`, `UnixLineDiscard/WordRubout` |
| Selection | `Toggle`, `ToggleAll`, `ToggleIn/Out`, `Select`, `SelectAll`, `DeselectAll`, `AppendAndSelect` |
| Query | `SetQuery`, `NextHistory`, `PreviousHistory` |
| Preview | `TogglePreview`, `PreviewUp/Down/Left/Right`, `PreviewPageUp/Down`, `RefreshPreview`, `SetPreviewCmd` |
| Command | `Execute(cmd)`, `ExecuteSilent(cmd)`, `Reload(cmd?)`, `RefreshCmd` |
| Mode | `ToggleInteractive`, `ToggleSort`, `RotateMode` |
| Conditional | `IfQueryEmpty(then, else?)`, `IfQueryNotEmpty(then, else?)`, `IfNonMatched(then, else?)` |
| Lifecycle | `Accept(key?)`, `Abort`, `Cancel` |
| UI | `ClearScreen`, `Redraw`, `SetHeader(text?)`, `SelectRow(n)` |
| Bindings | `Bind(spec)` — add `trigger:action[+action]` bindings (keys or action triggers) at runtime; `Unbind(triggers)` — remove bindings for a comma-separated list of keys or action triggers |
| Custom | `Custom(ActionCallback)` — async or sync closure receiving `&mut App` |
`Action::Custom(ActionCallback)` is the library extension point: callers can inject arbitrary async logic into the action pipeline without forking skim.
---
## Preview System
The preview command string supports placeholder substitution via `App::expand_cmd()`:
| Placeholder | Expands to |
| --- | --- |
| `{}` | text of the focused item |
| `{q}` | current query string |
| `{n}` | index of the focused item |
| `{+}` | space-separated texts of all selected items |
| `{+n}` | space-separated indices of selected items |
Preview execution is debounced (`DEBOUNCE_MS` in `run_preview`). A change in the focused item or query triggers `pending_preview_run = true`; the next `Heartbeat` after the debounce window calls `Preview::spawn()`.
The `ItemPreview` enum (returned by `SkimItem::preview()`) gives library users full control:
```rust
pub enum ItemPreview {
Command(String), // run command, capture stdout
Text(String), // display plain text
AnsiText(String), // display ANSI-colored text
CommandWithPos(String, PreviewPosition),
TextWithPos(String, PreviewPosition),
AnsiWithPos(String, PreviewPosition),
Global, // fall back to --preview option
}
```
---
## Output & Result Collection
`Skim::output()` is called after the event loop exits:
```
Skim::tick()
└─ app.final_action.take() → final_event ← includes nested follow-up/conditional actions
Skim::output()
├─ reader_control.kill() ← stop reader threads
├─ is_abort = !matches!(final_event, Action::Accept)
├─ selected_items = app.results()
│ └─ item_list.items[selection indices] or [current] if no multi
├─ query = app.input.to_string()
├─ cmd = (interactive? input : options.cmd_query? : initial_cmd)
├─ current = item_list.selected() ← focused item
└─ header = app.header.header
```
`SkimOutput` fields returned to caller:
```rust
pub struct SkimOutput {
pub final_event: Event, // Action::Accept or Action::Abort
pub is_abort: bool,
pub final_key: crossterm::event::KeyEvent,
pub query: String,
pub cmd: String,
pub selected_items: Vec<MatchedItem>,
pub current: Option<MatchedItem>,
pub header: String,
}
```
The output phase is `SkimOutput::write_output(&mut out, &BinOptions)` (`src/output.rs`), called by the CLI binary with a buffered stdout. `BinOptions` (also in `src/output.rs`, built via `BinOptions::from_opts`) captures the output-related flags. Keeping the serialization independent of stdout lets it be unit-tested by passing a `Vec<u8>`. It writes, in order:
1. `query` if `--print-query`
2. `cmd` if `--print-cmd`
3. `header` if `--print-header`
4. current item text if `--print-current`
5. `accept_key` if `--expect` matched
6. For each selected item: strips ANSI if `--ansi && !--no-strip-ansi`, prints text + score if `--print-score`
7. If `--output-format <template>`: uses `printf()` to expand a format string with placeholders (this path is exclusive — it replaces steps 16)
Exit codes: `0` = items selected, `1` = no items selected, `130` = abort, `135` = tmux launch failed.
---
## IPC / Listen Socket
This subsystem is gated behind the default `listen` Cargo feature, which also pulls in the
`interprocess`, `ron`, and `serde` dependencies. With the feature off, the `--listen` /
`--remote` flags, the `Skim::listener` field, the `select!` listener branch, and the
`serde` derives on `Action` are all compiled out. The `select!` branch cannot carry a
`#[cfg]` attribute (tokio's macro rejects it), so it stays in place but its future becomes
a never-resolving `pending()` and its handler is unreachable (the stream type alias
`RemoteStream` is uninhabited).
When `--listen <socket_name>` is set, `Skim::init_listener()` creates an `interprocess` local socket. The main event loop's `select!` accepts connections and spawns Tokio tasks to read RON-encoded `Action` values line by line:
```
listener.accept() → stream
tokio::spawn:
BufReader(stream).lines()
for each line:
ron::from_str::<Action>(&line)
→ tui.event_tx.send(Event::Action(act))
```
The remote client mode (`--remote <socket_name>`) reads action strings from stdin and sends them to an existing skim instance:
```
// src/bin/main.rs main()
if let Some(remote) = opts.remote {
stream = LocalSocket::connect(socket_name)
loop: read_line → parse_action_chain → ron::to_string → stream.write_all
}
```
This enables scripted control of a running skim session.
---
## Theming
`ColorTheme` (`src/theme.rs`) holds 13 named `ratatui::style::Style` values:
| Field | Covers |
| --- | --- |
| `normal` | Default item text |
| `matched` | Highlighted match characters |
| `current` | Focused item background |
| `current_match` | Match highlights on focused item |
| `query` | Query text in input box |
| `spinner` | Spinner animation character |
| `info` | Status info line |
| `prompt` | Prompt character |
| `cursor` | Cursor indicator |
| `selected` | Multi-selected item marker |
| `header` | Header text |
| `border` | Border lines |
| `scrollbar` | Item list scrollbar thumb |
Built-in palettes: `none`, `bw`, `default16`, `dark256`, `molokai256`, `light256`, `catppuccin_mocha`, `catppuccin_macchiato`, `catppuccin_latte`, `catppuccin_frappe`.
Selected via `--color base_theme[,component:color[:modifier]]`. Individual component overrides use CSS-style RGB hex (`#RRGGBB`), ANSI 256-color indices, or named modifiers (`bold`, `italic`, `underline`, `dim`, `reverse`).
`BorderType` mirrors Ratatui's border styles but adds two internal no-border states:
- `None` is the default `--border=none`; widgets do not draw boxes, but preview separators may still be drawn between panes.
- `ForceOff` is set by `--no-border`; it disables all borders, including tmux/zellij popup borders.
Passing `--border` without a value means `plain`. Passing a value accepts Ratatui styles such as `rounded`, `double`, `thick`, dashed variants, and quadrant variants.
---
## History
Query and command histories are managed in `SkimOptions`:
- Loaded at startup via `SkimOptions::init_histories()` from files specified by `--history-file` / `--cmd-history-file`.
- Stored in `App::query_history` / `App::cmd_history`.
- Navigation with `Action::NextHistory` / `Action::PreviousHistory` uses `history_index: Option<usize>` and `saved_input: String` to restore the original input when returning to the live query.
- Written back to file at exit in `sk_main` via `write_history_to_file()`, which deduplicates the last entry and enforces `--history-size`.
---
## Pre-Selection
`DefaultSkimSelector` (`src/helper/selector.rs`) implements the `Selector` trait:
```rust
pub trait Selector {
fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool;
}
```
Three modes (combinable):
- `first_n(N)` — selects the first N items by index
- `preset(iter)` — selects items whose `text()` is in a `HashSet`
- `regex(pattern)` — selects items matching a regex
Configured via `--pre-select-n`, `--pre-select-items`, `--pre-select-pat`, `--pre-select-file`.
Applied during `ItemList::from_options()` and re-applied when items are appended.
---
## Threading Model
Skim uses a mix of OS threads (for blocking I/O) and Tokio async tasks:
```
Main thread (Tokio runtime)
├─ Skim::run() async loop → tick() → select!
│ ├─ process events synchronously (App::handle_event is sync)
│ └─ block_in_place for ActionCallback::call (async closures)
└─ Tokio task: Tui event pump (crossterm EventStream + tick timer)
Matcher ThreadPool (persistent)
└─ Worker threads process atomic match chunks; one separate coordinator thread waits for completion
Reader ThreadPool (persistent)
└─ Short chunk jobs parse items; an in-flight token limit bounds the work queue
Reader threads (OS threads, per invocation):
├─ collect_items thread: blocks on SkimItemReceiver (recv_timeout 1ms), calls ItemPool::append
├─ I/O reader thread: reads large byte chunks, splits lines, assigns sequence numbers
├─ Bounded dispatcher thread: submits chunk jobs only while an in-flight token is available
├─ Reorder thread: sequence-ordered output; drops tx_pipeline_done on EOF
└─ Killer thread: waits for rx_interrupt or rx_pipeline_done; kills a command child if present
Preview threads (OS threads, per preview spawn):
├─ PTY reader, image decoder, or plain-child monitor
└─ Plain mode also has bounded stdout and stderr drain threads
→ writes PreviewContent Arc<RwLock> and sends Event::PreviewReady
IPC handler task (Tokio, per connection):
└─ reads RON actions → sends Event::Action to TUI channel
Popup stdin relay thread (OS thread, only in --popup/--tmux mode):
└─ copies stdin → FIFO for child sk process
```
**Synchronization primitives used:**
- `Arc<Mutex<Option<ProcessedItems>>>` — matcher-to-ItemList result handoff without CPU-spinning under merge contention
- `Arc<AtomicBool>``needs_render` (matcher → event loop), `stopped` / `interrupt` (MatcherControl)
- `Arc<AtomicUsize>``processed` / `matched` counters, matcher query generation, reader `components_to_stop`
- `Arc<tokio::sync::Notify>``items_available` (ItemPool → Skim::tick wakeup)
- `Arc<std::sync::RwLock<PreviewContent>>` — preview thread → Preview widget
- `kanal::Sender/Receiver<Vec<Arc<dyn SkimItem>>>` — item batches through pipeline
The global allocator is `mimalloc` (v3), chosen for its low-latency multi-threaded allocation characteristics critical to the concurrent item-creation pipeline.
---
---
## Public Library API
The minimum surface to embed skim in a Rust application:
```rust
use skim::prelude::*;
// 1. Build options
let options = SkimOptionsBuilder::default()
.height("40%")
.multi(true)
.preview(Some("cat {}"))
.build()
.unwrap();
// 2a. Run with a static item slice
let output = Skim::run_items(options, ["foo", "bar", "baz"]).unwrap();
// 2b. Or stream items through a channel
let (tx, rx) = unbounded::<Vec<Arc<dyn SkimItem>>>();
// … send batches to tx from another thread …
let output = Skim::run_with(options, Some(rx)).unwrap();
// 3. Inspect results
if !output.is_abort {
for item in &output.selected_items {
println!("{}", item.output());
}
}
```
**Implementing `SkimItem`** for custom types:
```rust
struct MyItem { id: u32, label: String }
impl SkimItem for MyItem {
fn text(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.label) // used for matching
}
fn display(&self, context: DisplayContext) -> ratatui::text::Line<'_> {
context.to_line(self.text()) // default implementation; override for custom styling
}
fn output(&self) -> Cow<'_, str> {
Cow::Owned(self.id.to_string()) // printed on accept
}
fn preview(&self, _ctx: PreviewContext<'_>) -> ItemPreview {
ItemPreview::Text(format!("ID: {}\nLabel: {}", self.id, self.label))
}
fn disabled(&self) -> bool {
false // disabled items are visible but cannot be selected
}
}
```
`DisplayContext` exposes the current score, match spans (`Matches::CharIndices`, `CharRange`, `ByteRange`, or `None`), container width, and base/highlight styles. `PreviewContext` exposes the current query, command query, preview dimensions, current index/selection, and multi-selection lists.
**Custom `ActionCallback`** for inline async logic:
```rust
let cb = ActionCallback::new(|app| async move {
// mutate app state, return follow-up events
Ok(vec![Event::Action(Action::Accept(None))])
});
// bind it: options.keymap.insert(key, vec![Action::Custom(cb)]);
```
**`CommandCollector` trait** for custom item sources (e.g. async databases):
```rust
impl CommandCollector for MySource {
fn invoke(&mut self, cmd: &str, components_to_stop: Arc<AtomicUsize>)
-> (SkimItemReceiver, Sender<i32>)
{
// spawn a thread, send batches of Arc<dyn SkimItem>, return rx + kill-tx
}
}
```
Set `options.cmd_collector = Rc::new(RefCell::new(my_source))` before calling `Skim::run_with`.