Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,10 +676,12 @@ Inline entry and resume clear the final viewport through Ratatui's `resize()` ev

`Skim::init_tui_with()` sets the private `Tui::no_clear` flag when attaching either the default or a caller-provided backend, before event handling can exit the terminal. `Tui::exit()` records a completed exit so repeated calls, including `Drop`, do not repeat cleanup or cursor movement. Entering or resuming the terminal resets this state. `--no-clear` preserves the final interface but still restores terminal input modes. In both inline and fullscreen modes, exit moves the cursor to the last viewport row and writes a newline before stdout or the shell resumes. This prevents output from overwriting the retained interface. Scrolling can move its top rows off screen, particularly in fullscreen mode. A panic hook is installed once (`PANIC_HOOK_SET: Once`) to ensure `cleanup_terminal(false)` always restores the screen 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`.
**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, env, unset)` with the environment snapshot taken at dispatch; 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.

**Child-process environment (issue #1195):** `App::child_env()` snapshots the TUI state (query, current and selected item text, counts, cursor position, area geometry, list direction, input state, prompt/pointer, item wrap, preview geometry, last action and key) and returns it as `(name, value)` pairs. `App::dispatch_action` records the canonical name of the last action, and `App::handle_key` records the fzf-style name of the last real key press (via `binds::key_name`); synthetic events routed through the keymap do not overwrite it. Every value is exported under both a `SKIM_` and an `FZF_` prefix so fzf-style scripts work unchanged. Variables that do not apply are omitted rather than exported empty, matching fzf: `CURRENT_ITEM` when there is no current item, `NTH`/`WITH_NTH` when unset, `WRAP` when wrapping is off, and the `PREVIEW_*` group when no preview is configured. `App::child_env_unset` returns the names the snapshot omits; internal command paths use `App::child_env_snapshot` to build both lists once, and `tui::util::apply_child_env` removes them from the inherited environment before setting the pairs, so an enclosing skim/fzf process cannot leak stale values into a child. The plain preview and `Tui::run_execute` call it; PTY previews apply the same removals and assignments with `CommandBuilder`, `execute-silent` calls it inline, and the reload / `--cmd` path forwards both lists through `Reader::set_child_env` → `CommandCollector::set_env` (a defaulted trait method), which `SkimItemReader` applies to the command it spawns. `Skim::start` snapshots the initial state before the reader starts. Reload events capture their state in `App::reload_event` before actions clear the selection; `Skim::handle_reload` takes the next snapshot from `App::pending_reload_env`, or captures the current state for a direct call. Reload snapshots stay in dispatch order in `App::pending_reload_env`, so later queued actions cannot overwrite them; `execute` carries its snapshot inside `Event::RunExecute(cmd, env, unset)`. `LINES`/`COLUMNS` come from `App::term_area`, recorded on each render.

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
Expand Down Expand Up @@ -724,6 +726,7 @@ Frame rate is capped at 120 fps (`FRAME_TIME_MS = 1000/120`). `App::handle_event
| `layout` | `AppLayout` | Last-frame widget areas (updated in render) |
| `needs_render` | `Arc<AtomicBool>` | Signal from matcher → event loop |
| `yank_register` | `String` | Cut/yank buffer |
| `term_area` | `Rect` | Area recorded on every render; source of `LINES`/`COLUMNS` in `child_env` |
| `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 |

Expand All @@ -733,7 +736,7 @@ Frame rate is capped at 120 fps (`FRAME_TIME_MS = 1000/120`). `App::handle_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::RunExecute(cmd, env, unset) → 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()
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ insta = "1.47"
rand = "0.10.0"
serde_json = { version = "=1.0.154" }
serial_test = "=3.5.0"
shlex = "2.0.1"

[[bench]]
name = "read_and_match"
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,43 @@ You can configure key bindings to start external processes without leaving Skim
sk --bind 'f1:execute(less -f {}),ctrl-y:execute-silent(echo {} | pbcopy)+abort'
```

### Environment variables exported to child processes

Commands run by `--preview`, `execute`, `execute-silent` and `reload` inherit a snapshot of
skim's state as environment variables. Each value is exported twice: once under a `SKIM_`
prefix and once under the same name with an `FZF_` prefix, so fzf-style scripts work
unchanged. This avoids quoting issues and keeps commands short.

| Variable | Value |
|---|---|
| `QUERY` | Current query input |
| `CURRENT_ITEM` | Text of the current item (ANSI-stripped); unset if there is none |
| `SELECTED_ITEMS` | Selected items, newline-separated |
| `POS` | 1-based position of the current item |
| `TOTAL_COUNT` | Total number of items |
| `MATCH_COUNT` | Number of matched items |
| `SELECT_COUNT` | Number of selected items |
| `LINES`, `COLUMNS` | Height and width of the skim area |
| `DIRECTION` | List direction (`up` or `down`) |
| `INPUT_STATE` | `enabled` or `disabled` |
| `PROMPT` | Current prompt string |
| `POINTER` | Current pointer (selector) string |
| `ACTION`, `KEY` | Name of the running action and of the last key pressed (empty before any input); a follow-up such as `act-up:execute-silent(...)` reports `execute-silent` |
| `WRAP` | `char` when item wrapping is on |
| `PREVIEW_LINES`, `PREVIEW_COLUMNS`, `PREVIEW_TOP`, `PREVIEW_LEFT` | Geometry of the preview window (only set when a preview is configured) |
| `NTH`, `WITH_NTH` | Current `--nth` and `--with-nth` values (only set when configured) |

```sh
# Equivalent to --preview 'echo {}', without the quoting pitfalls of {}
sk --preview 'printf "%s" "$SKIM_CURRENT_ITEM"'
```

A variable that does not apply to the current state (no preview, no `--nth`, an oversized value,
…) is removed from the child's environment instead of being left at its inherited value, so a skim
started inside another skim or fzf never leaks the enclosing process's values into its children.
The same variables are passed to the very first `--cmd` and to a `reload`, the latter capturing the
focused and selected items as they were when the reload was triggered.

## Algorithms

Skim offers multiple algorithms, check the help or manpage for an exhaustive list. Among them are:
Expand Down
12 changes: 11 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,17 @@
export VAGRANT_LIBVIRT_OVMF_CODE="${pkgs.OVMF.fd}/FV/OVMF_CODE.fd"
'';

mkShell = packages: shellHook: pkgs.mkShell { inherit packages shellHook; };
mkShell =
packages: shellHook:
pkgs.mkShell {
inherit packages;
shellHook = ''
echo "Making sure the rust toolchains are available"
${lib.getExe pkgs.rustup} -q install --no-update --no-self-update
${lib.getExe pkgs.rustup} -q install --no-update --no-self-update nightly
Comment thread
LoricAndre marked this conversation as resolved.
Resolved
${shellHook}
'';
};
in
{
default = mkShell base "";
Expand Down
54 changes: 54 additions & 0 deletions man/man1/sk.1
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,10 @@ In the `preview` flag, `execute`, `reload`, `set\-query`... binds, sk will expan
item(s).
.br

.br
Instead of placeholders, child commands can also read the same state from the `SKIM_*`/`FZF_*` variables listed under ENVIRONMENT VARIABLES.
.br

.SS "Field index expression"

.br
Expand Down Expand Up @@ -1227,6 +1231,56 @@ Example:
If set to a non\-empty value, will disable coloring
.br

.SS "Variables exported to child processes"

.br
Commands spawned by `\-\-preview`, `execute`, `execute\-silent` and `reload` inherit these variables.
.br
Every value is exported twice: under a `SKIM_` prefix and under the same name with an `FZF_` prefix.
.br
* `QUERY`: current query input.
.br
* `CURRENT_ITEM`: text of the current item, with ANSI sequences stripped; unset when there is none.
.br
* `SELECTED_ITEMS`: selected items, newline\-separated.
.br
* `POS`: 1\-based position of the current item in the list.
.br
* `TOTAL_COUNT`: total number of items.
.br
* `MATCH_COUNT`: number of matched items.
.br
* `SELECT_COUNT`: number of selected items.
.br
* `LINES`, `COLUMNS`: height and width of the skim area.
.br
* `DIRECTION`: list direction, `up` or `down`.
.br
* `INPUT_STATE`: `enabled` or `disabled`.
.br
* `PROMPT`, `POINTER`: current prompt and pointer (selector) strings.
.br
* `ACTION`, `KEY`: name of the last action performed and last key pressed (empty before any input).
.br
`ACTION` is the action that is running, so a child spawned by a follow\-up
.br
(`act\-up:execute\-silent(...)`) sees `execute\-silent`.
.br
* `WRAP`: `char` when item wrapping is enabled, otherwise unset.
.br
* `PREVIEW_LINES`, `PREVIEW_COLUMNS`, `PREVIEW_TOP`, `PREVIEW_LEFT`: geometry of the preview
.br
window; only set when a preview is configured.
.br
* `NTH`, `WITH_NTH`: current `\-\-nth` and `\-\-with\-nth` values, when set.
.br
A variable that does not apply to the current state is removed from the child\*(Aqs environment, so a
.br
skim started inside another skim or fzf never passes the enclosing process\*(Aqs values through.
.br
Example: `sk \-\-preview \*(Aqprintf %s "$SKIM_QUERY"\*(Aq`.
.br

.SH THEME

.br
Expand Down
101 changes: 101 additions & 0 deletions src/binds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,40 @@ impl SkimEvent {
_ => None,
}
}

/// The event's name, as accepted by [`Self::from_name`].
#[must_use]
pub const fn name(self) -> &'static str {
match self {
SkimEvent::Start => "start",
SkimEvent::Load => "load",
SkimEvent::Change => "change",
SkimEvent::Result => "result",
SkimEvent::Focus => "focus",
SkimEvent::Zero => "zero",
SkimEvent::One => "one",
SkimEvent::DoubleClick => "double-click",
}
}

/// Reverse of [`Self::key_code`]: the event carried by a reserved key code.
///
/// Returns `None` for real keys, so callers can tell a key press from a
/// synthetic event routed through the keymap.
#[must_use]
pub const fn from_key_code(code: KeyCode) -> Option<Self> {
match code {
KeyCode::F(255) => Some(SkimEvent::Change),
KeyCode::F(254) => Some(SkimEvent::Start),
KeyCode::F(253) => Some(SkimEvent::Load),
KeyCode::F(252) => Some(SkimEvent::Result),
KeyCode::F(251) => Some(SkimEvent::Focus),
KeyCode::F(250) => Some(SkimEvent::Zero),
KeyCode::F(249) => Some(SkimEvent::One),
KeyCode::F(248) => Some(SkimEvent::DoubleClick),
_ => None,
}
}
}

impl From<SkimEvent> for KeyEvent {
Expand Down Expand Up @@ -297,6 +331,73 @@ pub fn parse_key(key: &str) -> Result<KeyEvent> {
Ok(KeyEvent::new(keycode, mods))
}

/// Formats a key event as an fzf-style name (`ctrl-a`, `alt-b`, `shift-tab`,
/// `up`, `f1`, `space`).
///
/// Synthetic `SkimEvent`s are named after the event (see
/// [`SkimEvent::from_key_code`]). This is the value exported as
/// `SKIM_KEY`/`FZF_KEY` to child processes.
#[must_use]
pub fn key_name(key: &KeyEvent) -> String {
if let Some(event) = SkimEvent::from_key_code(key.code) {
return event.name().to_string();
}

// Crossterm can report back-tab with every modifier set; that is still shift-tab.
if key.code == KeyCode::BackTab && key.modifiers == KeyModifiers::all() {
return "shift-tab".to_string();
}

let mut name = String::new();
if key.modifiers.contains(KeyModifiers::CONTROL) {
name.push_str("ctrl-");
}
if key.modifiers.contains(KeyModifiers::ALT) {
name.push_str("alt-");
}
// `BackTab` already means shift-tab, so don't double it up.
if key.modifiers.contains(KeyModifiers::SHIFT) && !matches!(key.code, KeyCode::Char(_) | KeyCode::BackTab) {
name.push_str("shift-");
}

let body = match key.code {
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(c) => {
// `parse_key` folds a shift into an upper-case letter, so reproduce
// that; a shift on a non-letter keeps the explicit prefix.
if key.modifiers.contains(KeyModifiers::SHIFT) {
if c.is_ascii_alphabetic() {
c.to_ascii_uppercase().to_string()
} else {
name.push_str("shift-");
c.to_string()
}
} else {
c.to_string()
}
}
KeyCode::Enter => "enter".to_string(),
KeyCode::Backspace => "backspace".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::BackTab => "shift-tab".to_string(),
Comment thread
LoricAndre marked this conversation as resolved.
Resolved
KeyCode::Esc => "esc".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::PageUp => "page-up".to_string(),
KeyCode::PageDown => "page-down".to_string(),
KeyCode::Insert => "insert".to_string(),
KeyCode::Delete => "delete".to_string(),
KeyCode::F(n) => format!("f{n}"),
other => format!("{other:?}").to_lowercase(),
};
name.push_str(&body);
name
}

/// Parse an iterator of keymaps into a `KeyMap`
pub fn parse_keymaps<'a, T>(maps: T) -> KeyMap
where
Expand Down
51 changes: 51 additions & 0 deletions src/binds_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,57 @@ fn skim_event_name_roundtrip() {
}
}

#[test]
fn key_name_formats_fzf_style() {
// Canonical names round-trip through `parse_key`.
for name in [
"a",
"A",
"1",
"ctrl-a",
"alt-b",
"ctrl-alt-c",
"ctrl-space",
"space",
"alt-enter",
"shift-tab",
"esc",
"up",
"down",
"left",
"right",
"home",
"end",
"f10",
] {
let key = parse_key(name).unwrap();
assert_eq!(key_name(&key), name, "{name}");
}
// Keys that `parse_key` only knows by an alias still get the canonical name.
assert_eq!(key_name(&parse_key("pgup").unwrap()), "page-up");
assert_eq!(key_name(&parse_key("bspace").unwrap()), "backspace");
assert_eq!(key_name(&parse_key("alt-bspace").unwrap()), "alt-backspace");
// A shift on a non-character key keeps the prefix.
assert_eq!(key_name(&KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)), "shift-tab");
assert_eq!(
key_name(&KeyEvent::new(KeyCode::BackTab, KeyModifiers::all())),
"shift-tab"
);
// Synthetic events report their event name.
for event in [
SkimEvent::Start,
SkimEvent::Load,
SkimEvent::Change,
SkimEvent::Result,
SkimEvent::Focus,
SkimEvent::Zero,
SkimEvent::One,
SkimEvent::DoubleClick,
] {
assert_eq!(key_name(&event.key_event()), event.name());
}
}

#[test]
fn double_click_accepts_by_default() {
assert_eq!(
Expand Down
Loading
Loading