# 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. Retained stdout is parsed with `ansi_to_tui::IntoText` and published while the command runs. Cancellation terminates the child process group (the process tree on Windows) and invalidates its output writer, so an old reader cannot replace content from a newer preview. At exit, successful stdout or failed stderr is 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; stream active stdout; poll child status
β cancellation invalidates the writer and 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 1β6)
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`.