From dce26d622a7caec4649b2d379c029a64e4fad66e Mon Sep 17 00:00:00 2001 From: LoricAndre <57358788+LoricAndre@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:13:43 +0200 Subject: [PATCH] feat: add `--hide-nth` to hide fields from display but keep them searchable (#1122) * Add --hide-nth flag to hide fields while keeping them searchable Introduce a `--hide-nth ` option that takes the same comma-separated field index expressions as `--nth`/`--with-nth`. The listed fields are removed from the displayed line but remain part of the text used for matching, so a query can still match them. Characters in the hidden fields are ignored for match highlighting and horizontal scrolling. Implementation: - Resolve the fieldspec to byte ranges in the same coordinate space as the matching/display text and store them as `hidden_ranges` in DefaultSkimItem metadata, exposed via a new `SkimItem::hidden_ranges()` trait method. text()/output() keep the full text so hidden fields stay searchable and are preserved on output. - DefaultSkimItem::display() removes hidden characters and remaps match highlight positions into visible coordinates (project_visible_text / project_match_indices); this path takes precedence over ANSI styling. - ItemRenderer::render_item applies the same projection to derive the visible sub-line text and hscroll match range, so hidden characters are ignored for horizontal scrolling. Add unit tests for range normalization/projection and item behavior, plus insta snapshot tests covering display removal, searchability, and hscroll. Update ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BCunXeAFMYAFduTc8SNjSM * Preserve ANSI colors for surviving text under --hide-nth Previously the hidden-field rendering path was applied ahead of the ANSI display branch and rebuilt the line from the ANSI-stripped text, so combining --hide-nth with --ansi dropped the colors of the visible fields. Integrate hidden-field removal into the ANSI branch instead: after parsing the styled spans, drop the hidden characters while preserving each span's style (retain_visible_spans) and remap the match positions into the resulting visible coordinate space, then run the normal highlighting. The plain (non-ANSI) branch keeps its project-and-to_line handling. Surviving characters now keep their ANSI colors while hidden fields stay searchable. Add unit tests for ANSI color preservation and remapped highlighting, plus ANSI color-snapshot integration tests. Update ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BCunXeAFMYAFduTc8SNjSM * Set hidden fields via builder instead of DefaultSkimItem::new param Remove the `hidden_fields` parameter from `DefaultSkimItem::new` and set the hidden fields through a `hidden_fields(&[FieldRange], &Regex)` builder method instead. The builder resolves the fields against the item's own `text()` (the same coordinate space `new` would have used), so the result is identical while keeping `new`'s signature unchanged for its many existing call sites. The reader chains `.hidden_fields(&opt.hidden_fields, &opt.delimiter)` onto construction. Revert the extra `&[]` argument at the other call sites (selector, fuzz target, tests) and update the hide-nth tests to use the builder. Update ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BCunXeAFMYAFduTc8SNjSM * chore: generate files --------- Co-authored-by: Claude --- ARCHITECTURE.md | 31 ++- flake.nix | 1 + man/man1/sk.1 | 12 +- shell/completion.bash | 6 +- shell/completion.fish | 1 + shell/completion.nu | 1 + shell/completion.zsh | 1 + src/helper/item.rs | 182 +++++++++++++++++- src/helper/item_reader.rs | 27 ++- src/helper/item_tests.rs | 168 ++++++++++++++++ src/options.rs | 22 +++ src/skim_item.rs | 11 ++ src/tui/item_renderer.rs | 25 ++- tests/ansi.rs | 27 +++ tests/options.rs | 40 ++++ tests/snapshots/ansi__ansi_hide_nth@001.snap | 29 +++ .../ansi__ansi_hide_nth@color002.snap | 11 ++ .../ansi__ansi_hide_nth_searchable@001.snap | 29 +++ ...si__ansi_hide_nth_searchable@color002.snap | 11 ++ .../options__opt_hide_nth_1@001.snap | 29 +++ .../options__opt_hide_nth_2@001.snap | 29 +++ .../options__opt_hide_nth_hscroll@001.snap | 29 +++ .../options__opt_hide_nth_last@001.snap | 29 +++ .../options__opt_hide_nth_multi@001.snap | 29 +++ .../options__opt_hide_nth_range@001.snap | 29 +++ ...ns__opt_hide_nth_still_searchable@001.snap | 29 +++ ...ns__opt_hide_nth_still_searchable@002.snap | 29 +++ 27 files changed, 854 insertions(+), 13 deletions(-) create mode 100644 tests/snapshots/ansi__ansi_hide_nth@001.snap create mode 100644 tests/snapshots/ansi__ansi_hide_nth@color002.snap create mode 100644 tests/snapshots/ansi__ansi_hide_nth_searchable@001.snap create mode 100644 tests/snapshots/ansi__ansi_hide_nth_searchable@color002.snap create mode 100644 tests/snapshots/options__opt_hide_nth_1@001.snap create mode 100644 tests/snapshots/options__opt_hide_nth_2@001.snap create mode 100644 tests/snapshots/options__opt_hide_nth_hscroll@001.snap create mode 100644 tests/snapshots/options__opt_hide_nth_last@001.snap create mode 100644 tests/snapshots/options__opt_hide_nth_multi@001.snap create mode 100644 tests/snapshots/options__opt_hide_nth_range@001.snap create mode 100644 tests/snapshots/options__opt_hide_nth_still_searchable@001.snap create mode 100644 tests/snapshots/options__opt_hide_nth_still_searchable@002.snap diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8e96ee94..3b2c3184 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,7 +102,7 @@ skim/ ← workspace root │ ├── 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) +│ ├── field.rs ← field range parsing (--nth / --with-nth / --hide-nth) │ ├── spinlock.rs ← lightweight SpinLock │ ├── util.rs ← printf helper, misc utilities │ ├── popup/ ← tmux & zellij popup integration @@ -428,7 +428,8 @@ Source (stdin bytes or child process stdout) │ assigns monotonic sequence numbers, sends to MPMC channel ├─ Thread N: workers — receive chunks, validate UTF-8, │ create DefaultSkimItem::new(line, ansi, trans_fields, matching_fields, delimiter) - │ (handles ANSI stripping, --nth / --with-nth transforms inline), + │ .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) @@ -458,6 +459,24 @@ SkimItemReceiver channel 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 @@ -1216,10 +1235,10 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread | `merge_worker_results` | `src/matcher.rs:28` | Merge k sorted runs → ProcessedItems | | `ItemPool::append` | `src/item.rs:469` | Add items, notify matcher | | `ItemPool::take` | `src/item.rs:502` | Take un-matched items for matcher | -| `DefaultSkimItem::new` | `src/helper/item.rs:58` | ANSI strip, field transform, ranges, disable pattern | -| `SkimItemReader::parallel_bufread` | `src/helper/item_reader.rs:263` | Unified parallel pipeline (all inputs) | -| `spawn_io_reader` | `src/helper/item_reader.rs:354` | I/O reader thread: chunk reads + line splitting | -| `spawn_reorder_thread` | `src/helper/item_reader.rs:458` | Reorder thread: ordered output + pipeline-done signal | +| `DefaultSkimItem::new` | `src/helper/item.rs:64` | ANSI strip, field transform, matching ranges (hidden ranges set later via `hidden_fields` builder) | +| `SkimItemReader::parallel_bufread` | `src/helper/item_reader.rs:287` | Unified parallel pipeline (all inputs) | +| `spawn_io_reader` | `src/helper/item_reader.rs:378` | I/O reader thread: chunk reads + line splitting | +| `spawn_reorder_thread` | `src/helper/item_reader.rs:483` | Reorder thread: ordered output + pipeline-done signal | | `Preview::spawn` | `src/tui/preview.rs:319` | Start image, PTY, or plain preview worker | | `Tui::new_with_height_and_backend` | `src/tui/backend.rs:77` | Terminal init + viewport sizing | | `Tui::enter` | `src/tui/backend.rs:126` | Enable raw mode + terminal setup | diff --git a/flake.nix b/flake.nix index 6ce90240..6e223642 100644 --- a/flake.nix +++ b/flake.nix @@ -46,6 +46,7 @@ gnuplot llvm cargo-bloat + cargo-public-api ]; gungraun = with pkgs; [ valgrind diff --git a/man/man1/sk.1 b/man/man1/sk.1 index 5cb601a8..011a1957 100644 --- a/man/man1/sk.1 +++ b/man/man1/sk.1 @@ -8,7 +8,7 @@ sk \- Fuzzy Finder in rust! .ie \n(.g .ds Aq \(aq .el .ds Aq ' .SH SYNOPSIS -\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-typos\fR] [\fB\-\-no\-typos\fR] [\fB\-\-normalize\fR] [\fB\-\-split\-match\fR] [\fB\-\-last\-match\fR] [\fB\-\-scheme\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-highlight\-line\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-disable\-pattern\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-border\-no\-collapse\fR] [\fB\-\-no\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-multiline\fR] [\fB\-\-scrollbar\fR] [\fB\-\-no\-scrollbar\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-\-image\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-print\-current\fR] [\fB\-\-output\-format\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-remote\fR] [\fB\-\-popup\fR] [\fB\-\-log\-level\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] +\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-\-hide\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-typos\fR] [\fB\-\-no\-typos\fR] [\fB\-\-normalize\fR] [\fB\-\-split\-match\fR] [\fB\-\-last\-match\fR] [\fB\-\-scheme\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-highlight\-line\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-disable\-pattern\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-border\-no\-collapse\fR] [\fB\-\-no\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-multiline\fR] [\fB\-\-scrollbar\fR] [\fB\-\-no\-scrollbar\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-\-image\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-print\-current\fR] [\fB\-\-output\-format\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-remote\fR] [\fB\-\-popup\fR] [\fB\-\-log\-level\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] .ie \n(.g .ds Aq \(aq .el .ds Aq ' .SH OPTIONS @@ -94,6 +94,16 @@ Fields to be transformed See nth for the details .TP +\fB\-\-hide\-nth\fR \fI\fR [default: ] +Fields to hide from display while keeping them searchable + +Takes the same comma\-separated field index expressions as **nth**. The listed +fields are removed from the displayed line but remain part of the text used for +matching, so a query can still match them. Characters in the hidden fields are +ignored for match highlighting and horizontal scrolling. + +See **nth** for the field index expression syntax. +.TP \fB\-d\fR, \fB\-\-delimiter\fR \fI\fR [default: [\\t\\n ]+] Delimiter between fields diff --git a/shell/completion.bash b/shell/completion.bash index 0797a531..207598ef 100644 --- a/shell/completion.bash +++ b/shell/completion.bash @@ -23,7 +23,7 @@ _sk() { case "${cmd}" in sk) - opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --typos --no-typos --normalize --split-match --last-match --scheme --bind --multi --no-multi --no-mouse --cmd --interactive --color --highlight-line --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --disable-pattern --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --ellipsis --info --no-info --inline-info --header --header-lines --border --border-no-collapse --no-border --wrap --multiline --scrollbar --no-scrollbar --history --history-size --cmd-history --cmd-history-size --preview --preview-window --image --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --print-current --output-format --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --remote --popup --log-level --log-file --flags --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --tail --style --no-color --padding --border-label --border-label-pos --wrap-sign --no-multi-line --raw --track --gap --gap-line --freeze-left --freeze-right --scroll-off --gutter --gutter-raw --marker-multi-line --list-border --list-label --list-label-pos --no-input --info-command --separator --no-separator --ghost --input-border --input-label --input-label-pos --preview-label --preview-label-pos --header-first --header-border --header-lines-border --footer --footer-border --footer-label --footer-label-pos --with-shell --expect --help --version" + opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --hide-nth --delimiter --exact --regex --algo --case --typos --no-typos --normalize --split-match --last-match --scheme --bind --multi --no-multi --no-mouse --cmd --interactive --color --highlight-line --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --disable-pattern --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --ellipsis --info --no-info --inline-info --header --header-lines --border --border-no-collapse --no-border --wrap --multiline --scrollbar --no-scrollbar --history --history-size --cmd-history --cmd-history-size --preview --preview-window --image --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --print-current --output-format --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --remote --popup --log-level --log-file --flags --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --tail --style --no-color --padding --border-label --border-label-pos --wrap-sign --no-multi-line --raw --track --gap --gap-line --freeze-left --freeze-right --scroll-off --gutter --gutter-raw --marker-multi-line --list-border --list-label --list-label-pos --no-input --info-command --separator --no-separator --ghost --input-border --input-label --input-label-pos --preview-label --preview-label-pos --header-first --header-border --header-lines-border --footer --footer-border --footer-label --footer-label-pos --with-shell --expect --help --version" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -53,6 +53,10 @@ _sk() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --hide-nth) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --delimiter) COMPREPLY=($(compgen -f "${cur}")) return 0 diff --git a/shell/completion.fish b/shell/completion.fish index 9c2bbd9a..c9ef6235 100644 --- a/shell/completion.fish +++ b/shell/completion.fish @@ -13,6 +13,7 @@ pathname\t'' -pathname\t''" complete -c sk -s n -l nth -d 'Fields to be matched' -r complete -c sk -l with-nth -d 'Fields to be transformed' -r +complete -c sk -l hide-nth -d 'Fields to hide from display while keeping them searchable' -r complete -c sk -s d -l delimiter -d 'Delimiter between fields' -r complete -c sk -l algo -d 'Fuzzy matching algorithm' -r -f -a "arinae\t'Arinae: typo-resistant & natural algorithm, default' clangd\t'Clangd fuzzy matching algorithm' diff --git a/shell/completion.nu b/shell/completion.nu index aa820cbe..97d580c7 100644 --- a/shell/completion.nu +++ b/shell/completion.nu @@ -44,6 +44,7 @@ module completions { --tiebreak(-t): string@"nu-complete sk tiebreak" # Comma-separated list of sort criteria to apply when the scores are tied. --nth(-n): string # Fields to be matched --with-nth: string # Fields to be transformed + --hide-nth: string # Fields to hide from display while keeping them searchable --delimiter(-d): string # Delimiter between fields --exact(-e) # Run in exact mode --regex # Start in regex mode instead of fuzzy-match diff --git a/shell/completion.zsh b/shell/completion.zsh index 46ed863e..6fbbbb9a 100644 --- a/shell/completion.zsh +++ b/shell/completion.zsh @@ -21,6 +21,7 @@ _sk() { '*-n+[Fields to be matched]:NTH:_default' \ '*--nth=[Fields to be matched]:NTH:_default' \ '*--with-nth=[Fields to be transformed]:WITH_NTH:_default' \ +'*--hide-nth=[Fields to hide from display while keeping them searchable]:HIDE_NTH:_default' \ '-d+[Delimiter between fields]:DELIMITER:_default' \ '--delimiter=[Delimiter between fields]:DELIMITER:_default' \ '--algo=[Fuzzy matching algorithm]:ALGORITHM:((arinae\:"Arinae\: typo-resistant & natural algorithm, default" diff --git a/src/helper/item.rs b/src/helper/item.rs index 1995eaa2..a3b385a3 100644 --- a/src/helper/item.rs +++ b/src/helper/item.rs @@ -2,7 +2,7 @@ //! Including the `DefaultSkimItem` use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields}; use crate::tui::util::merge_styles; -use crate::{DisplayContext, SkimItem}; +use crate::{DisplayContext, Matches, SkimItem}; use ansi_to_tui::IntoText; use ratatui::text::{Line, Span}; use regex::Regex; @@ -48,6 +48,12 @@ pub struct DefaultSkimItemMetadata { /// The ranges on which to perform matching matching_ranges: Option>, + /// Byte ranges (in the display/matching text) of fields hidden via `--hide-nth`. + /// Characters inside these ranges are removed from the rendered line and ignored + /// for match highlighting and horizontal scrolling, but remain part of the text + /// used for matching so they stay searchable. + hidden_ranges: Option>, + /// Whether the item should be disabled or not disabled: bool, } @@ -162,6 +168,7 @@ impl DefaultSkimItem { stripped_text: stripped_text.map(std::string::String::into_boxed_str), ansi_info, matching_ranges, + hidden_ranges: None, disabled: false, })) } else { @@ -174,6 +181,32 @@ impl DefaultSkimItem { } } + /// Builder-style setter for the fields hidden from display (via `--hide-nth`). + /// + /// The fields are resolved against the item's display/matching text — which is + /// exactly what [`text()`](Self::text) returns (the ANSI-stripped text under + /// `--ansi`, otherwise the raw text) — so this must be called after construction. + /// The requested fields stay part of `text()` (and therefore searchable); they are + /// only removed from the rendered line and ignored for highlighting and hscroll. + /// + /// A no-op when `hidden_fields` is empty or resolves to no ranges. + #[must_use] + pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self { + if hidden_fields.is_empty() { + return self; + } + // Resolve the ranges before touching `self.metadata`; the `text()` borrow must + // end before the mutable borrow below. + let ranges = { + let text = self.text(); + normalize_ranges(&parse_matching_fields(delimiter, text.as_ref(), hidden_fields)) + }; + if !ranges.is_empty() { + self.metadata.get_or_insert_default().hidden_ranges = Some(ranges); + } + self + } + fn contains_ansi_escape(s: &str) -> bool { memchr::memchr(b'\x1b', s.as_bytes()).is_some() } @@ -228,6 +261,16 @@ impl DefaultSkimItem { None } } + + /// Getter for `hidden_ranges` stored in metadata + #[must_use] + pub fn hidden_ranges(&self) -> Option<&[(usize, usize)]> { + if let Some(meta) = &self.metadata { + meta.hidden_ranges.as_ref().map(|v| v.as_ref() as &[(usize, usize)]) + } else { + None + } + } } impl DefaultSkimItem { @@ -273,6 +316,10 @@ impl SkimItem for DefaultSkimItem { self.matching_ranges() } + fn hidden_ranges(&self) -> Option<&[(usize, usize)]> { + self.hidden_ranges() + } + // The display function handles ANSI stripping, field highlighting, and match // rendering in a single pass; splitting it would require duplicating context handling. #[allow(clippy::too_many_lines)] @@ -289,9 +336,23 @@ impl SkimItem for DefaultSkimItem { // Extract all spans from the parsed text (should be a single line) let all_spans: Vec = parsed_text.lines.into_iter().flat_map(|line| line.spans).collect(); + // When fields are hidden (--hide-nth), drop the hidden characters from the + // parsed spans while preserving their ANSI styling, and remap the match + // positions into the resulting visible coordinate space. The remaining + // highlighting logic then runs unchanged on visible-coordinate CharIndices. + let (all_spans, matches) = if let Some(hidden) = self.hidden_ranges() { + let stripped = self.text(); + let (_, map) = project_visible_text(stripped.as_ref(), hidden); + let visible_spans = retain_visible_spans(all_spans, &map); + let indices = project_match_indices(stripped.as_ref(), &context.matches, &map); + (visible_spans, Matches::CharIndices(indices)) + } else { + (all_spans, context.matches.clone()) + }; + // Now apply highlighting based on matched positions // We need to map match positions from stripped text to original text - match context.matches { + match matches { crate::Matches::CharIndices(ref indices) => { // Indices are already in stripped text coordinates (same as parsed ANSI text) // No need to remap since both matching and ANSI parsing strip the codes @@ -446,6 +507,19 @@ impl SkimItem for DefaultSkimItem { } crate::Matches::None => Line::from(all_spans), } + } else if let Some(hidden) = self.hidden_ranges() { + // Non-ANSI hidden path: remove the hidden characters and remap the match + // highlight positions into the visible coordinate space. + let (visible, map) = project_visible_text(&self.text, hidden); + let indices = project_match_indices(&self.text, &context.matches, &map); + DisplayContext { + score: context.score, + matches: Matches::CharIndices(indices), + container_width: context.container_width, + base_style: context.base_style, + matched_style: context.matched_style, + } + .to_line(Cow::Owned(visible)) } else { // No ANSI mapping needed, use text as-is context.to_line(Cow::Borrowed(&self.text)) @@ -551,6 +625,110 @@ fn escape_ansi(raw: &str) -> String { unsafe { String::from_utf8_unchecked(raw.bytes().map(|b| if b == 27 { b'?' } else { b }).collect()) } } +/// Sort and merge a list of byte ranges into a canonical, non-overlapping form. +/// +/// Empty ranges are dropped. Overlapping or touching ranges are merged so callers +/// can iterate the result assuming disjoint, ascending ranges. +#[must_use] +pub(crate) fn normalize_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> { + let mut sorted: Vec<(usize, usize)> = ranges.iter().copied().filter(|(s, e)| e > s).collect(); + sorted.sort_unstable(); + + let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len()); + for (start, end) in sorted { + if let Some(last) = merged.last_mut() + && start <= last.1 + { + last.1 = last.1.max(end); + } else { + merged.push((start, end)); + } + } + merged +} + +/// Remove the hidden byte ranges from `text`, returning the visible string and a +/// map from each original char index to its `Some(visible char index)`, or `None` +/// when that char falls inside a hidden range. +/// +/// `hidden` must be normalized (see [`normalize_ranges`]): sorted, disjoint byte ranges. +#[must_use] +pub(crate) fn project_visible_text(text: &str, hidden: &[(usize, usize)]) -> (String, Vec>) { + let mut visible = String::with_capacity(text.len()); + let mut map = Vec::new(); + let mut vis_idx = 0usize; + let mut hi = 0usize; + + for (byte_pos, ch) in text.char_indices() { + while hi < hidden.len() && byte_pos >= hidden[hi].1 { + hi += 1; + } + let is_hidden = hi < hidden.len() && byte_pos >= hidden[hi].0 && byte_pos < hidden[hi].1; + if is_hidden { + map.push(None); + } else { + map.push(Some(vis_idx)); + visible.push(ch); + vis_idx += 1; + } + } + + (visible, map) +} + +/// Drop hidden characters from already-parsed styled spans while preserving each +/// span's style, keeping the ANSI colors of the surviving characters intact. +/// +/// `map` is the per-char index map produced by [`project_visible_text`]; the spans +/// are iterated in the same (stripped-text) char order the map is indexed by. Spans +/// that become empty after filtering are dropped. +#[must_use] +pub(crate) fn retain_visible_spans(spans: Vec>, map: &[Option]) -> Vec> { + let mut out = Vec::with_capacity(spans.len()); + let mut char_idx = 0usize; + for span in spans { + let mut content = String::new(); + for ch in span.content.chars() { + if map.get(char_idx).copied().flatten().is_some() { + content.push(ch); + } + char_idx += 1; + } + if !content.is_empty() { + out.push(Span::styled(content, span.style)); + } + } + out +} + +/// Convert the matched character positions of `matches` (in full-text coordinates) +/// into visible-text char indices, dropping any that fall inside hidden ranges. +/// +/// `map` is the per-char index map produced by [`project_visible_text`]. The result +/// is sorted ascending and deduplicated, ready to feed a `Matches::CharIndices`. +#[must_use] +pub(crate) fn project_match_indices(text: &str, matches: &Matches, map: &[Option]) -> Vec { + let full_indices: Vec = match matches { + Matches::CharIndices(indices) => indices.clone(), + Matches::CharRange(start, end) => (*start..*end).collect(), + Matches::ByteRange(start, end) => text + .char_indices() + .enumerate() + .filter(|(_, (byte_pos, _))| *byte_pos >= *start && *byte_pos < *end) + .map(|(char_idx, _)| char_idx) + .collect(), + Matches::None => Vec::new(), + }; + + let mut visible: Vec = full_indices + .into_iter() + .filter_map(|ci| map.get(ci).copied().flatten()) + .collect(); + visible.sort_unstable(); + visible.dedup(); + visible +} + #[cfg(test)] #[path = "item_tests.rs"] mod test; diff --git a/src/helper/item_reader.rs b/src/helper/item_reader.rs index e88cb923..f5de1c2d 100644 --- a/src/helper/item_reader.rs +++ b/src/helper/item_reader.rs @@ -30,6 +30,7 @@ pub struct SkimItemReaderOption { use_ansi_color: bool, transform_fields: Vec, matching_fields: Vec, + hidden_fields: Vec, delimiter: Regex, line_ending: u8, show_error: bool, @@ -44,6 +45,7 @@ impl Default for SkimItemReaderOption { use_ansi_color: false, transform_fields: Vec::new(), matching_fields: Vec::new(), + hidden_fields: Vec::new(), delimiter: Regex::new(DELIMITER_STR).unwrap(), show_error: false, disable_pattern: None, @@ -69,6 +71,11 @@ impl SkimItemReaderOption { .iter() .filter_map(|f| if f.is_empty() { None } else { FieldRange::from_str(f) }) .collect(), + hidden_fields: options + .hide_nth + .iter() + .filter_map(|f| if f.is_empty() { None } else { FieldRange::from_str(f) }) + .collect(), delimiter: options.delimiter.clone(), show_error: options.show_cmd_error, disable_pattern: options.disable_pattern.clone(), @@ -137,6 +144,23 @@ impl SkimItemReaderOption { self } + /// Sets the fields to hide from display (while keeping them searchable) + #[must_use] + pub fn hide_nth<'a, T>(mut self, hide_nth: T) -> Self + where + T: Iterator, + { + self.hidden_fields = hide_nth.filter_map(FieldRange::from_str).collect(); + self + } + + /// Sets the hidden fields directly + #[must_use] + pub fn hidden_fields(mut self, hidden_fields: Vec) -> Self { + self.hidden_fields = hidden_fields; + self + } + /// Enables reading null-terminated lines instead of newline-terminated #[must_use] pub fn read0(mut self, enable: bool) -> Self { @@ -441,7 +465,8 @@ impl SkimItemReader { &opt.transform_fields, &opt.matching_fields, &opt.delimiter, - ); + ) + .hidden_fields(&opt.hidden_fields, &opt.delimiter); if opt.disable_pattern.as_ref().is_some_and(|re| re.is_match(line)) { item.disable(); } diff --git a/src/helper/item_tests.rs b/src/helper/item_tests.rs index 9afd398e..c3ed352a 100644 --- a/src/helper/item_tests.rs +++ b/src/helper/item_tests.rs @@ -487,3 +487,171 @@ fn test_display_ansi_item_with_no_matches() { assert!(text.contains("red")); assert!(text.contains("text")); } + +#[test] +fn test_normalize_ranges_sorts_and_merges() { + // Overlapping and touching ranges are merged; empty ranges dropped; result sorted. + assert_eq!(normalize_ranges(&[(5, 8), (0, 3)]), vec![(0, 3), (5, 8)]); + assert_eq!(normalize_ranges(&[(0, 4), (2, 6)]), vec![(0, 6)]); + assert_eq!(normalize_ranges(&[(0, 3), (3, 6)]), vec![(0, 6)]); + assert_eq!(normalize_ranges(&[(2, 2), (0, 1)]), vec![(0, 1)]); + assert!(normalize_ranges(&[]).is_empty()); +} + +#[test] +fn test_project_visible_text_removes_hidden_ranges() { + // Hide bytes 6..10 ("RED ") from "apple RED 001". + let (visible, map) = project_visible_text("apple RED 001", &[(6, 10)]); + assert_eq!(visible, "apple 001"); + // Chars 0..6 ("apple ") map to themselves, 6..10 ("RED ") are hidden, + // and the trailing "001" is shifted left by 4 positions. + assert_eq!(map[0], Some(0)); // 'a' + assert_eq!(map[5], Some(5)); // ' ' + assert_eq!(map[6], None); // 'R' + assert_eq!(map[9], None); // ' ' + assert_eq!(map[10], Some(6)); // '0' + assert_eq!(map[12], Some(8)); // '1' +} + +#[test] +fn test_project_match_indices_drops_hidden_and_remaps() { + use crate::Matches; + let (_visible, map) = project_visible_text("apple RED 001", &[(6, 10)]); + // A match spanning both a visible char ('e' at 4) and hidden chars (7,8) keeps + // only the visible one, remapped into visible coordinates (unchanged here). + let indices = project_match_indices("apple RED 001", &Matches::CharIndices(vec![4, 7, 8]), &map); + assert_eq!(indices, vec![4]); + // A byte range covering "001" (bytes 10..13) maps to visible chars 6,7,8. + let indices = project_match_indices("apple RED 001", &Matches::ByteRange(10, 13), &map); + assert_eq!(indices, vec![6, 7, 8]); + // A match entirely inside the hidden field yields nothing. + let indices = project_match_indices("apple RED 001", &Matches::CharRange(6, 9), &map); + assert!(indices.is_empty()); +} + +#[test] +fn test_hidden_ranges_keep_text_searchable() { + use crate::field::FieldRange; + use regex::Regex; + let delimiter = Regex::new(r"\s+").unwrap(); + // Hide field 2 ("RED") but keep it searchable. + let item = DefaultSkimItem::new("apple RED 001", false, &[], &[], &delimiter) + .hidden_fields(&[FieldRange::Single(2)], &delimiter); + // text() (used for matching) retains the hidden field, so it stays searchable. + assert_eq!(item.text(), "apple RED 001"); + // hidden_ranges exposes the field's byte range (including its trailing delimiter). + assert_eq!(item.hidden_ranges(), Some(&[(6, 10)][..])); +} + +#[test] +fn test_hidden_field_removed_from_display() { + use crate::field::FieldRange; + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::Style; + use regex::Regex; + let delimiter = Regex::new(r"\s+").unwrap(); + let item = DefaultSkimItem::new("apple RED 001", false, &[], &[], &delimiter) + .hidden_fields(&[FieldRange::Single(2)], &delimiter); + let context = DisplayContext { + score: 0, + matches: Matches::None, + container_width: 80, + base_style: Style::default(), + matched_style: Style::default(), + }; + let line = item.display(context); + let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + // The hidden field is gone from what is displayed, but the rest remains. + assert_eq!(rendered, "apple 001"); + assert!(!rendered.contains("RED")); +} + +#[test] +fn test_hidden_field_match_not_highlighted() { + use crate::field::FieldRange; + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::{Color, Style}; + use regex::Regex; + let delimiter = Regex::new(r"\s+").unwrap(); + let item = DefaultSkimItem::new("apple RED 001", false, &[], &[], &delimiter) + .hidden_fields(&[FieldRange::Single(2)], &delimiter); + // Simulate a match on the hidden "RED" (chars 6,7,8 in the full text). + let context = DisplayContext { + score: 0, + matches: Matches::CharIndices(vec![6, 7, 8]), + container_width: 80, + base_style: Style::default(), + matched_style: Style::default().bg(Color::Yellow), + }; + let line = item.display(context); + let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(rendered, "apple 001"); + // No span carries the highlight background, since the matched chars are hidden. + assert!(line.spans.iter().all(|span| span.style.bg != Some(Color::Yellow))); +} + +#[test] +fn test_hidden_field_preserves_ansi_colors() { + use crate::field::FieldRange; + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::{Color, Style}; + use regex::Regex; + let delimiter = Regex::new(r"\s+").unwrap(); + // Two colored, space-separated fields: green "one" and red "two". + let item = DefaultSkimItem::new( + "\x1b[32mone\x1b[0m \x1b[31mtwo\x1b[0m", + true, // ansi_enabled + &[], + &[], + &delimiter, + ) + .hidden_fields(&[FieldRange::Single(1)], &delimiter); // hide the first (green) field + // The hidden field is still part of the matchable text. + assert_eq!(item.text(), "one two"); + let context = DisplayContext { + score: 0, + matches: Matches::None, + container_width: 80, + base_style: Style::default(), + matched_style: Style::default(), + }; + let line = item.display(context); + let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + // "one " (field 1 plus its trailing delimiter) is gone; "two" remains. + assert_eq!(rendered, "two"); + // The surviving field keeps its ANSI red foreground; the hidden green is gone. + assert!( + line.spans.iter().any(|span| span.style.fg == Some(Color::Red)), + "surviving field should keep its ANSI red foreground" + ); + assert!( + line.spans.iter().all(|span| span.style.fg != Some(Color::Green)), + "hidden field's ANSI green foreground should not appear" + ); +} + +#[test] +fn test_hidden_field_ansi_highlight_remapped() { + use crate::field::FieldRange; + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::{Color, Style}; + use regex::Regex; + let delimiter = Regex::new(r"\s+").unwrap(); + let item = DefaultSkimItem::new("\x1b[32mone\x1b[0m \x1b[31mtwo\x1b[0m", true, &[], &[], &delimiter) + .hidden_fields(&[FieldRange::Single(1)], &delimiter); // hide green "one" + // Match "two" — chars 4,5,6 in the full stripped text "one two". + let context = DisplayContext { + score: 0, + matches: Matches::CharIndices(vec![4, 5, 6]), + container_width: 80, + base_style: Style::default(), + matched_style: Style::default().bg(Color::Yellow), + }; + let line = item.display(context); + let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(rendered, "two"); + // The match on the visible field is highlighted (remapped to visible coords 0..3) + // while its ANSI red foreground is preserved alongside the highlight background. + assert!(line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow))); + assert!(line.spans.iter().any(|span| span.style.fg == Some(Color::Red))); +} diff --git a/src/options.rs b/src/options.rs index 230b48f4..51eb4359 100644 --- a/src/options.rs +++ b/src/options.rs @@ -200,6 +200,27 @@ pub struct SkimOptions { )] pub with_nth: Vec, + /// Fields to hide from display while keeping them searchable + /// + /// Takes the same comma-separated field index expressions as **nth**. The listed + /// fields are removed from the displayed line but remain part of the text used for + /// matching, so a query can still match them. Characters in the hidden fields are + /// ignored for match highlighting and horizontal scrolling. + /// + /// See **nth** for the field index expression syntax. + #[cfg_attr( + feature = "cli", + arg( + long, + default_value = "", + help_heading = "Search", + verbatim_doc_comment, + value_delimiter = ',', + allow_hyphen_values = true, + ) + )] + pub hide_nth: Vec, + /// Delimiter between fields /// /// In regex format, defaults to AWK-style. Escape sequences like \x00, \t, \n are supported. @@ -1115,6 +1136,7 @@ impl Default for SkimOptions { tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End], nth: Default::default(), with_nth: Default::default(), + hide_nth: Default::default(), delimiter: Regex::new(r"[\t\n ]+").unwrap(), exact: Default::default(), regex: Default::default(), diff --git a/src/skim_item.rs b/src/skim_item.rs index 37738632..a81b06fa 100644 --- a/src/skim_item.rs +++ b/src/skim_item.rs @@ -74,6 +74,17 @@ pub trait SkimItem: AsAny + Send + Sync + 'static { None } + /// Byte ranges of `text()` that are hidden from display (via `--hide-nth`). + /// + /// Characters inside these ranges are removed from the rendered line and ignored + /// for match highlighting and horizontal scrolling, but stay part of `text()` so + /// they remain searchable. Ranges are expressed as (`start_byte`, `end_byte`) and + /// are expected to be sorted and non-overlapping. Returns `None` when nothing is + /// hidden. + fn hidden_ranges(&self) -> Option<&[(usize, usize)]> { + None + } + /// Returns true if the item should be disabled /// Disabled items cannot be selected fn disabled(&self) -> bool { diff --git a/src/tui/item_renderer.rs b/src/tui/item_renderer.rs index 639b9318..4d37ce83 100644 --- a/src/tui/item_renderer.rs +++ b/src/tui/item_renderer.rs @@ -92,8 +92,29 @@ impl<'a> ItemRenderer<'a> { out: &mut Vec>, ) -> usize { let item_text = item.item.text(); - let sub_lines = self.split_sub_lines(item_text.as_ref()); - let (match_start_char, match_end_char) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref()); + // When fields are hidden (--hide-nth), project the display text and match + // positions into the visible coordinate space so hidden characters are ignored + // for sub-line splitting, highlighting, and horizontal scrolling. `display()` + // performs the same projection, keeping the styled line consistent with these + // positions. + let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) = + match item.item.hidden_ranges() { + Some(hidden) if !hidden.is_empty() => { + let (visible, map) = crate::helper::item::project_visible_text(item_text.as_ref(), hidden); + let matches = Self::display_matches(item.matched_range.as_ref()); + let indices = crate::helper::item::project_match_indices(item_text.as_ref(), &matches, &map); + let (start, end) = match (indices.first(), indices.last()) { + (Some(first), Some(last)) => (*first, *last + 1), + _ => (0, 0), + }; + (std::borrow::Cow::Owned(visible), start, end) + } + _ => { + let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref()); + (item_text, start, end) + } + }; + let sub_lines = self.split_sub_lines(display_text.as_ref()); let mut added = 0usize; // Collect rows for this item into a temporary buffer so we can reverse diff --git a/tests/ansi.rs b/tests/ansi.rs index 24254f8f..074187c5 100644 --- a/tests/ansi.rs +++ b/tests/ansi.rs @@ -47,3 +47,30 @@ insta_test!(test_prompt_ansi, ["a"], &["--prompt", "\x1b[1;34mprompt\x1b[0m noco @snap; @snap_color; }); + +// --ansi combined with --hide-nth: the hidden (red) middle field is removed from +// the rendered line, while the surviving green/plain fields keep their ANSI colors. +// The color snapshot confirms the green foreground survives and the red one is gone. +insta_test!( + test_ansi_hide_nth, + @bytes b"\x1b[32mgreen\x1b[0m \x1b[31mred\x1b[0m plain\n", + &["--ansi", "--delimiter", " ", "--hide-nth", "2"], + { + @snap; + @snap_color; + } +); + +// The hidden ANSI field stays searchable: matching its text ("red") still selects +// the item even though the field is not shown, and no highlight leaks onto the +// visible text. +insta_test!( + test_ansi_hide_nth_searchable, + @bytes b"\x1b[32mgreen\x1b[0m \x1b[31mred\x1b[0m plain\n", + &["--ansi", "--delimiter", " ", "--hide-nth", "2"], + { + @type "red"; + @snap; + @snap_color; + } +); diff --git a/tests/options.rs b/tests/options.rs index cf7977aa..1ba971e2 100644 --- a/tests/options.rs +++ b/tests/options.rs @@ -87,6 +87,46 @@ insta_test!(opt_with_nth_range_desc, ["f1,f2,f3,f4"], &["--delimiter", ",", "--w @snap; }); +insta_test!(opt_hide_nth_1, ["f1,f2,f3,f4"], &["--delimiter", ",", "--hide-nth", "1"], { + @snap; +}); + +insta_test!(opt_hide_nth_2, ["f1,f2,f3,f4"], &["--delimiter", ",", "--hide-nth", "2"], { + @snap; +}); + +insta_test!(opt_hide_nth_last, ["f1,f2,f3,f4"], &["--delimiter", ",", "--hide-nth=-1"], { + @snap; +}); + +insta_test!(opt_hide_nth_range, ["f1,f2,f3,f4"], &["--delimiter", ",", "--hide-nth", "2..3"], { + @snap; +}); + +insta_test!(opt_hide_nth_multi, ["f1,f2,f3,f4"], &["--delimiter", ",", "--hide-nth", "1,3"], { + @snap; +}); + +// A hidden field stays searchable: querying "f2" still matches the item even though +// the field is not displayed, and the hidden characters carry no highlight. +insta_test!(opt_hide_nth_still_searchable, ["f1,f2,f3,f4"], &["--delimiter", ",", "--hide-nth", "2"], { + @snap; + @type "f2"; + @snap; +}); + +// A very wide hidden field is ignored for horizontal scrolling: the short visible +// field renders in full with no scroll ellipsis, even though the raw line is 1000+ +// columns wide. +insta_test!( + opt_hide_nth_hscroll, + [&format!("{} target", ["a"; 1000].join(""))], + &["--delimiter", " ", "--hide-nth", "1"], + { + @snap; + } +); + insta_test!(opt_nth_1, ["f1,f2,f3,f4"], &["--delimiter", ",", "--nth", "1"], { @snap; @char '1'; diff --git a/tests/snapshots/ansi__ansi_hide_nth@001.snap b/tests/snapshots/ansi__ansi_hide_nth@001.snap new file mode 100644 index 00000000..7d0e52f8 --- /dev/null +++ b/tests/snapshots/ansi__ansi_hide_nth@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/ansi.rs +description: "input: bytes b\"\\x1b[32mgreen\\x1b[0m \\x1b[31mred\\x1b[0m plain\\n\"\noptions: --ansi --delimiter --hide-nth 2" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> green plain " +" 1/1 0/0" +"> " +cursor: (24, 3) diff --git a/tests/snapshots/ansi__ansi_hide_nth@color002.snap b/tests/snapshots/ansi__ansi_hide_nth@color002.snap new file mode 100644 index 00000000..fbb74a92 --- /dev/null +++ b/tests/snapshots/ansi__ansi_hide_nth@color002.snap @@ -0,0 +1,11 @@ +--- +source: tests/ansi.rs +description: "input: bytes b\"\\x1b[32mgreen\\x1b[0m \\x1b[31mred\\x1b[0m plain\\n\"\noptions: --ansi --delimiter --hide-nth 2" +--- +(21, 0..1) ">" fg=Indexed(161) +(21, 1..2) " " fg=Indexed(168) +(21, 2..7) "green" fg=Green bg=Indexed(236) +(21, 7..13) " plain" bg=Indexed(236) +(22, 0..5) " 1/1" fg=Indexed(144) +(22, 77..80) "0/0" fg=Indexed(144) +(23, 0..2) "> " fg=Indexed(110) diff --git a/tests/snapshots/ansi__ansi_hide_nth_searchable@001.snap b/tests/snapshots/ansi__ansi_hide_nth_searchable@001.snap new file mode 100644 index 00000000..65868546 --- /dev/null +++ b/tests/snapshots/ansi__ansi_hide_nth_searchable@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/ansi.rs +description: "input: bytes b\"\\x1b[32mgreen\\x1b[0m \\x1b[31mred\\x1b[0m plain\\n\"\noptions: --ansi --delimiter --hide-nth 2\nafter:\n @type \"red\"" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> green plain " +" 1/1 0/0" +"> red " +cursor: (24, 6) diff --git a/tests/snapshots/ansi__ansi_hide_nth_searchable@color002.snap b/tests/snapshots/ansi__ansi_hide_nth_searchable@color002.snap new file mode 100644 index 00000000..fbb74a92 --- /dev/null +++ b/tests/snapshots/ansi__ansi_hide_nth_searchable@color002.snap @@ -0,0 +1,11 @@ +--- +source: tests/ansi.rs +description: "input: bytes b\"\\x1b[32mgreen\\x1b[0m \\x1b[31mred\\x1b[0m plain\\n\"\noptions: --ansi --delimiter --hide-nth 2" +--- +(21, 0..1) ">" fg=Indexed(161) +(21, 1..2) " " fg=Indexed(168) +(21, 2..7) "green" fg=Green bg=Indexed(236) +(21, 7..13) " plain" bg=Indexed(236) +(22, 0..5) " 1/1" fg=Indexed(144) +(22, 77..80) "0/0" fg=Indexed(144) +(23, 0..2) "> " fg=Indexed(110) diff --git a/tests/snapshots/options__opt_hide_nth_1@001.snap b/tests/snapshots/options__opt_hide_nth_1@001.snap new file mode 100644 index 00000000..91e0b341 --- /dev/null +++ b/tests/snapshots/options__opt_hide_nth_1@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/options.rs +description: "input: items [\"f1,f2,f3,f4\"]\noptions: --delimiter , --hide-nth 1" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> f2,f3,f4 " +" 1/1 0/0" +"> " +cursor: (24, 3) diff --git a/tests/snapshots/options__opt_hide_nth_2@001.snap b/tests/snapshots/options__opt_hide_nth_2@001.snap new file mode 100644 index 00000000..017f3c4e --- /dev/null +++ b/tests/snapshots/options__opt_hide_nth_2@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/options.rs +description: "input: items [\"f1,f2,f3,f4\"]\noptions: --delimiter , --hide-nth 2" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> f1,f3,f4 " +" 1/1 0/0" +"> " +cursor: (24, 3) diff --git a/tests/snapshots/options__opt_hide_nth_hscroll@001.snap b/tests/snapshots/options__opt_hide_nth_hscroll@001.snap new file mode 100644 index 00000000..f1cb83e8 --- /dev/null +++ b/tests/snapshots/options__opt_hide_nth_hscroll@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/options.rs +description: "input: items [&format!(\"{} target\", [\"a\"; 1000].join(\"\"))]\noptions: --delimiter --hide-nth 1" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> target " +" 1/1 0/0" +"> " +cursor: (24, 3) diff --git a/tests/snapshots/options__opt_hide_nth_last@001.snap b/tests/snapshots/options__opt_hide_nth_last@001.snap new file mode 100644 index 00000000..aef9aab0 --- /dev/null +++ b/tests/snapshots/options__opt_hide_nth_last@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/options.rs +description: "input: items [\"f1,f2,f3,f4\"]\noptions: --delimiter , --hide-nth=-1" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> f1,f2,f3, " +" 1/1 0/0" +"> " +cursor: (24, 3) diff --git a/tests/snapshots/options__opt_hide_nth_multi@001.snap b/tests/snapshots/options__opt_hide_nth_multi@001.snap new file mode 100644 index 00000000..a9fa75f4 --- /dev/null +++ b/tests/snapshots/options__opt_hide_nth_multi@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/options.rs +description: "input: items [\"f1,f2,f3,f4\"]\noptions: --delimiter , --hide-nth 1,3" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> f2,f4 " +" 1/1 0/0" +"> " +cursor: (24, 3) diff --git a/tests/snapshots/options__opt_hide_nth_range@001.snap b/tests/snapshots/options__opt_hide_nth_range@001.snap new file mode 100644 index 00000000..4d043608 --- /dev/null +++ b/tests/snapshots/options__opt_hide_nth_range@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/options.rs +description: "input: items [\"f1,f2,f3,f4\"]\noptions: --delimiter , --hide-nth 2..3" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> f1,f4 " +" 1/1 0/0" +"> " +cursor: (24, 3) diff --git a/tests/snapshots/options__opt_hide_nth_still_searchable@001.snap b/tests/snapshots/options__opt_hide_nth_still_searchable@001.snap new file mode 100644 index 00000000..017f3c4e --- /dev/null +++ b/tests/snapshots/options__opt_hide_nth_still_searchable@001.snap @@ -0,0 +1,29 @@ +--- +source: tests/options.rs +description: "input: items [\"f1,f2,f3,f4\"]\noptions: --delimiter , --hide-nth 2" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> f1,f3,f4 " +" 1/1 0/0" +"> " +cursor: (24, 3) diff --git a/tests/snapshots/options__opt_hide_nth_still_searchable@002.snap b/tests/snapshots/options__opt_hide_nth_still_searchable@002.snap new file mode 100644 index 00000000..4530e99a --- /dev/null +++ b/tests/snapshots/options__opt_hide_nth_still_searchable@002.snap @@ -0,0 +1,29 @@ +--- +source: tests/options.rs +description: "input: items [\"f1,f2,f3,f4\"]\noptions: --delimiter , --hide-nth 2\nafter:\n @type \"f2\"" +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> f1,f3,f4 " +" 1/1 0/0" +"> f2 " +cursor: (24, 5)