mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 15:26:18 -04:00
Compare commits
38 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3b09a687 | ||
|
|
672639fd80 | ||
|
|
a177153700 | ||
|
|
801d1ab036 | ||
|
|
17c517fd02 | ||
|
|
0392eeb70d | ||
|
|
085f86b127 | ||
|
|
16c6f0b386 | ||
|
|
d7b799b9e6 | ||
|
|
a9b0fd4393 | ||
|
|
a53ada910a | ||
|
|
4b962af9ed | ||
|
|
e8f5b3ae65 | ||
|
|
d699733053 | ||
|
|
756b084841 | ||
|
|
52275d3a3e | ||
|
|
2ec03e630c | ||
|
|
c0786aa9d3 | ||
|
|
bb80ac138e | ||
|
|
9de4825e51 | ||
|
|
1b80cff3f7 | ||
|
|
94022921a3 | ||
|
|
59aecd4f2b | ||
|
|
fe45ed36b7 | ||
|
|
01be03f0d5 | ||
|
|
733aaeb148 | ||
|
|
f6a302390c | ||
|
|
5ff1bda145 | ||
|
|
070d0849b2 | ||
|
|
5fc3b0c1a0 | ||
|
|
a5bd0fefa6 | ||
|
|
f3c03000b3 | ||
|
|
1dead2b3c6 | ||
|
|
b17e93890a | ||
|
|
d99d6cecc7 | ||
|
|
7429ca3ec2 | ||
|
|
bc379b5f47 | ||
|
|
d2ae57625f |
4
.github/workflows/package.yml
vendored
4
.github/workflows/package.yml
vendored
|
|
@ -49,7 +49,7 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
- name: Install cargo-generate-rpm
|
||||
uses: taiki-e/install-action@v2
|
||||
uses: taiki-e/install-action@v2.87.1
|
||||
with:
|
||||
tool: cargo-generate-rpm@0.21
|
||||
- name: Setup cargo cache
|
||||
|
|
@ -73,7 +73,7 @@ jobs:
|
|||
cp target/generate-rpm/*.rpm target/linux-packages/
|
||||
ls -l target/linux-packages/
|
||||
- name: Upload packages
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-linux-packages-${{ matrix.arch }}
|
||||
path: target/linux-packages/*
|
||||
|
|
|
|||
13
.github/workflows/pr-auto-update.yml
vendored
Normal file
13
.github/workflows/pr-auto-update.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name: Pull request update
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
autoupdate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: allonsy-studio/actions-pr-auto-update@5fbf9661cbf5c1f092ec33e5a1cfbe70dc5bd81c
|
||||
10
.github/workflows/pr.yml
vendored
10
.github/workflows/pr.yml
vendored
|
|
@ -88,11 +88,11 @@ jobs:
|
|||
cache-all-crates: "true"
|
||||
- name: Generate files
|
||||
run: |
|
||||
cargo run -- --man > ./man/man1/sk.1
|
||||
cargo run -- --shell bash > ./shell/completion.bash
|
||||
cargo run -- --shell zsh > ./shell/completion.zsh
|
||||
cargo run -- --shell fish > ./shell/completion.fish
|
||||
cargo run -- --shell nushell > ./shell/completion.nu
|
||||
cargo run --locked -- --man > ./man/man1/sk.1
|
||||
cargo run --locked -- --shell bash > ./shell/completion.bash
|
||||
cargo run --locked -- --shell zsh > ./shell/completion.zsh
|
||||
cargo run --locked -- --shell fish > ./shell/completion.fish
|
||||
cargo run --locked -- --shell nushell > ./shell/completion.nu
|
||||
- name: Check diff
|
||||
run: |
|
||||
if git diff --exit-code; then
|
||||
|
|
|
|||
6
.github/workflows/release-pr.yml
vendored
6
.github/workflows/release-pr.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2
|
||||
- uses: taiki-e/install-action@742a3317eac7bd62f91cd888b4eead5e784ba833 # v2
|
||||
with:
|
||||
tool: git-cliff
|
||||
- id: version
|
||||
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
git cliff -u -t "v$VERSION" --strip all
|
||||
echo "$delimiter"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.version.outputs.version != ''
|
||||
with:
|
||||
name: release-${{ github.sha }}
|
||||
|
|
@ -86,7 +86,7 @@ jobs:
|
|||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/release
|
||||
|
|
|
|||
40
.github/workflows/test.yml
vendored
40
.github/workflows/test.yml
vendored
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
# installs via winget below.
|
||||
name: Install zellij (Linux/macOS)
|
||||
if: runner.os != 'Windows'
|
||||
uses: taiki-e/install-action@v2
|
||||
uses: taiki-e/install-action@v2.87.1
|
||||
with:
|
||||
tool: zellij@0.44.3
|
||||
- name: Install zellij (Windows)
|
||||
|
|
@ -83,7 +83,7 @@ jobs:
|
|||
run: rustup toolchain install
|
||||
- &nextest-install
|
||||
name: Install nextest
|
||||
uses: taiki-e/install-action@v2
|
||||
uses: taiki-e/install-action@v2.87.1
|
||||
with:
|
||||
tool: nextest@0.9
|
||||
- &cache
|
||||
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
run: cargo test --doc
|
||||
- name: "Run tests"
|
||||
# Do not use `--all-targets` to avoid running benches
|
||||
run: cargo nextest run --release --profile ci --bins --lib --examples --tests
|
||||
run: cargo nextest run --release --locked --profile ci --bins --lib --examples --tests
|
||||
env:
|
||||
LC_ALL: en_US.UTF-8
|
||||
TERM: xterm-256color
|
||||
|
|
@ -123,14 +123,14 @@ jobs:
|
|||
- *checkout
|
||||
- *toolchain
|
||||
- *nextest-install
|
||||
- uses: taiki-e/install-action@v2
|
||||
- uses: taiki-e/install-action@v2.87.1
|
||||
with:
|
||||
tool: cargo-llvm-cov@0.8
|
||||
- *cache
|
||||
- name: "Run tests with coverage"
|
||||
# Do not use `--all-targets` to avoid running benches
|
||||
run: |
|
||||
cargo +nightly llvm-cov nextest --release --profile ci --branch --bins --lib --examples --tests --no-report
|
||||
cargo +nightly llvm-cov nextest --locked --release --profile ci --branch --bins --lib --examples --tests --no-report
|
||||
cargo +nightly llvm-cov report --release --cobertura --output-path coverage.xml
|
||||
cargo +nightly llvm-cov report --release --html
|
||||
echo "COVERAGE_PERCENT=$(cargo +nightly llvm-cov report --release | tail -n1 | awk '{ print $13 }')" | tee --append $GITHUB_ENV
|
||||
|
|
@ -186,11 +186,11 @@ jobs:
|
|||
- *cache
|
||||
- name: Run clippy with specific features
|
||||
run: |
|
||||
cargo clippy --no-default-features -- -Dwarnings
|
||||
cargo clippy --no-default-features --features cli -- -Dwarnings
|
||||
cargo clippy --no-default-features --features image -- -Dwarnings
|
||||
cargo clippy --no-default-features --features listen -- -Dwarnings
|
||||
cargo clippy --no-default-features --features frizbee -- -Dwarnings
|
||||
cargo clippy --locked --no-default-features -- -Dwarnings
|
||||
cargo clippy --locked --no-default-features --features cli -- -Dwarnings
|
||||
cargo clippy --locked --no-default-features --features image -- -Dwarnings
|
||||
cargo clippy --locked --no-default-features --features listen -- -Dwarnings
|
||||
cargo clippy --locked --no-default-features --features frizbee -- -Dwarnings
|
||||
|
||||
|
||||
msrv:
|
||||
|
|
@ -198,11 +198,8 @@ jobs:
|
|||
steps:
|
||||
- *checkout
|
||||
- *toolchain
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-msrv@0.19
|
||||
- name: MSRV Verify
|
||||
run: cargo msrv verify
|
||||
run: cargo +1.91.0 build --release --locked --all-targets
|
||||
|
||||
fuzz:
|
||||
permissions:
|
||||
|
|
@ -226,7 +223,7 @@ jobs:
|
|||
# https://rust-fuzz.github.io/book/cargo-fuzz/windows/setup.html).
|
||||
if: runner.os == 'Windows'
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
- uses: taiki-e/install-action@v2
|
||||
- uses: taiki-e/install-action@v2.87.1
|
||||
with:
|
||||
tool: cargo-fuzz@0.13
|
||||
- name: Run fuzz targets
|
||||
|
|
@ -249,7 +246,7 @@ jobs:
|
|||
done
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fuzz-crashes-${{ runner.os }}
|
||||
path: fuzz/artifacts/
|
||||
|
|
@ -268,12 +265,19 @@ jobs:
|
|||
- *checkout
|
||||
- *toolchain
|
||||
- *cache
|
||||
- uses: taiki-e/install-action@v2
|
||||
- uses: taiki-e/install-action@v2.87.1
|
||||
with:
|
||||
tool: cargo-public-api@0.52
|
||||
- run: rustup toolchain install nightly-x86_64-unknown-linux-gnu
|
||||
name: Install nightly toolchain
|
||||
- run: cargo public-api diff latest --deny removed --deny changed
|
||||
# Compare two repository revisions so cargo-public-api uses their committed
|
||||
# lockfiles. A registry comparison resolves the released crate again and can
|
||||
# fail when a new, broken transitive dependency is published.
|
||||
- run: git fetch --force --tags
|
||||
name: Fetch release tags
|
||||
- run: |
|
||||
latest_tag="$(git tag --list 'v*' --sort=-version:refname | head -n1)"
|
||||
cargo public-api diff "${latest_tag}..HEAD" --deny removed --deny changed
|
||||
name: Run cargo-public-api
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@
|
|||
- Changing the threading model or synchronization primitives
|
||||
- Adding or removing public API surface (`SkimItem`, `SkimOptions`, `SkimOutput`, etc.)
|
||||
- Changing the event/action system or key binding infrastructure
|
||||
- Keep call-site line numbers in the cross-reference table up to date when the referenced functions move.
|
||||
|
||||
|
||||
## Testing
|
||||
|
|
|
|||
122
ARCHITECTURE.md
122
ARCHITECTURE.md
|
|
@ -428,7 +428,8 @@ Source (stdin bytes or child process stdout)
|
|||
└── 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 N: workers — receive chunks, validate UTF-8,
|
||||
├─ 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),
|
||||
|
|
@ -491,7 +492,7 @@ Engines are composable through the factory pattern. Starting from `Matcher::crea
|
|||
options
|
||||
│
|
||||
├── if regex mode:
|
||||
│ RegexEngineFactory
|
||||
│ RegexEngineFactory (configured with the same RankBuilder / --tiebreak criteria)
|
||||
│ └─ if normalize: NormalizedEngineFactory(RegexEngineFactory)
|
||||
│
|
||||
└── else (fuzzy/exact mode):
|
||||
|
|
@ -581,12 +582,12 @@ Matcher::run(query, item_pool, thread_pool, …)
|
|||
│ └─ merge_worker_results(worker_results, no_sort, …)
|
||||
│ ├─ concatenate k sorted runs
|
||||
│ ├─ sort() (stable; driftsort detects k runs → O(n log k))
|
||||
│ └─ write into SpinLock<Option<ProcessedItems>>
|
||||
│ └─ 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()`.
|
||||
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
|
||||
|
||||
|
|
@ -611,7 +612,10 @@ Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before
|
|||
| --- | --- |
|
||||
| `Replace` | Fresh match pass (query changed, full re-sort) |
|
||||
| `SortedMerge` | New items arrived during a running match (merge-insert) |
|
||||
| `Append` | `--no-sort` mode |
|
||||
| `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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -624,7 +628,8 @@ Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before
|
|||
- 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.
|
||||
- A `is_fullscreen` flag that determines the `ratatui::Viewport`.
|
||||
- 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()`):
|
||||
|
||||
|
|
@ -633,7 +638,7 @@ Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before
|
|||
- `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.
|
||||
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.
|
||||
|
||||
|
|
@ -735,6 +740,7 @@ restart_matcher(force)
|
|||
├─ 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
|
||||
|
|
@ -823,17 +829,19 @@ The `StatusInfo` struct rendered inside the input line shows:
|
|||
`ItemList` (`src/tui/item_list.rs`) maintains:
|
||||
|
||||
- `items: Vec<MatchedItem>` — the currently displayed matched items
|
||||
- `processed_items: Arc<SpinLock<Option<ProcessedItems>>>` — shared with matcher
|
||||
- `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` and swaps them in atomically via the `SpinLock`. Depending on `MergeStrategy`:
|
||||
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:**
|
||||
|
||||
|
|
@ -862,7 +870,7 @@ Pre-selection is applied when items first appear: `DefaultSkimSelector::should_s
|
|||
|
||||
`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 child captures stdout (capped at `PREVIEW_MAX_BYTES`), parses it with `ansi_to_tui::IntoText`, stores as `PreviewContent::Text`, and sends `Event::PreviewReady`.
|
||||
**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`).
|
||||
|
||||
|
|
@ -887,12 +895,14 @@ else if pty mode:
|
|||
→ Event::PreviewReady when EOF
|
||||
|
||||
else:
|
||||
sh -c <cmd>
|
||||
thread: wait for output → content.write() = PreviewContent::Text(…)
|
||||
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). `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.
|
||||
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
|
||||
|
||||
|
|
@ -1269,21 +1279,23 @@ Main thread (Tokio runtime)
|
|||
│
|
||||
└─ Tokio task: Tui event pump (crossterm EventStream + tick timer)
|
||||
|
||||
ThreadPool (N = num_cpus OS threads, persistent)
|
||||
├─ Matcher coordinator (1 slot per match run)
|
||||
└─ Worker threads (N-1 slots per match run)
|
||||
Matcher ThreadPool (persistent)
|
||||
└─ Worker threads process atomic match chunks; one separate coordinator thread waits for completion
|
||||
|
||||
Reader threads (OS threads, per-invocation):
|
||||
├─ collect_items thread: blocks on SkimItemReceiver (recv_timeout 5ms), calls ItemPool::append
|
||||
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
|
||||
├─ Worker threads (N): parse lines, create DefaultSkimItem (ANSI strip + field transforms inline)
|
||||
├─ 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 (command inputs only): waits for rx_interrupt or rx_pipeline_done;
|
||||
kills child process when either fires
|
||||
└─ Killer thread: waits for rx_interrupt or rx_pipeline_done; kills a command child if present
|
||||
|
||||
Preview thread (OS thread, per preview spawn):
|
||||
└─ reads PTY/child stdout or decodes image path → PreviewContent Arc<RwLock>
|
||||
→ sends Event::PreviewReady
|
||||
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
|
||||
|
|
@ -1294,9 +1306,9 @@ Popup stdin relay thread (OS thread, only in --popup/--tmux mode):
|
|||
|
||||
**Synchronization primitives used:**
|
||||
|
||||
- `Arc<SpinLock<Option<ProcessedItems>>>` — matcher-to-ItemList result handoff
|
||||
- `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, reader `components_to_stop`
|
||||
- `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
|
||||
|
|
@ -1305,64 +1317,6 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread
|
|||
|
||||
---
|
||||
|
||||
## Important Call Sites (Cross-Reference)
|
||||
|
||||
| Call site | File | What it does |
|
||||
| --- | --- | --- |
|
||||
| `Skim::run_with` | `src/skim.rs:70` | Top-level library entry point |
|
||||
| `Skim::run_items` | `src/skim.rs:112` | Convenience wrapper for iterator inputs |
|
||||
| `Skim::init_tui` | `src/skim.rs:136` | Initialize default crossterm TUI backend |
|
||||
| `Skim::init` | `src/skim.rs:155` | Constructs all subsystems from options |
|
||||
| `Skim::start` | `src/skim.rs:199` | Starts reader + initial matcher pass |
|
||||
| `Skim::handle_reload` | `src/skim.rs:231` | Kills reader, clears pool, restarts |
|
||||
| `Skim::init_tui_with` | `src/skim.rs:303` | Install a caller-provided TUI backend |
|
||||
| `Skim::enter` | `src/skim.rs:390` | Enter terminal, resolve image picker, start listener/event pump |
|
||||
| `Skim::should_enter` | `src/skim.rs:434` | Filter/select-1/exit-0/sync gate |
|
||||
| `Skim::output` | `src/skim.rs:539` | Collect & return SkimOutput |
|
||||
| `Skim::tick` | `src/skim.rs:620` | Single async event loop iteration |
|
||||
| `App::from_options` | `src/tui/app.rs:289` | Build all widgets from options |
|
||||
| `App::run_preview` | `src/tui/app.rs:503` | Expand cmd, debounce, call Preview::spawn |
|
||||
| `App::handle_event` | `src/tui/app.rs:628` | Dispatch all Event variants |
|
||||
| `App::handle_action` | `src/tui/app.rs:833` | Apply action follow-up bindings |
|
||||
| `App::dispatch_conditional` | `src/tui/app.rs:852` | Dispatch the selected conditional subaction chain without follow-up bindings |
|
||||
| `App::dispatch_action` | `src/tui/app.rs:875` | Dispatch one Action variant without follow-up bindings |
|
||||
| `Tui::run_execute` | `src/tui/backend.rs:353` | Suspend reader, run `execute` child with its own tty stdin, restart reader |
|
||||
| `App::restart_matcher` | `src/tui/app.rs:1352` | Kill old match pass, start new one |
|
||||
| `App::expand_cmd` | `src/tui/app.rs:1428` | Substitute `{}`, `{q}`, `{n}` etc. |
|
||||
| `App::handle_mouse` | `src/tui/app.rs:1496` | Handle mouse behavior and emit `double-click` |
|
||||
| `Widget::render (App)` | `src/tui/app.rs:151` | Root render; calls all sub-widgets |
|
||||
| `Matcher::run` | `src/matcher.rs:~260` | Parallel match dispatch |
|
||||
| `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: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:78` | Terminal init + viewport sizing |
|
||||
| `Tui::enter` | `src/tui/backend.rs:127` | Enable raw mode + terminal setup |
|
||||
| `Tui::start` | `src/tui/backend.rs:238` | Spawn crossterm EventStream task (fresh cancellation token each call) |
|
||||
| `Tui::stop_and_join` | `src/tui/backend.rs:224` | Cancel event pump and block until `EventStream` is dropped (before `execute`) |
|
||||
| `Tui::force_full_redraw` | `src/tui/backend.rs:205` | Reset ratatui diff buffers for a full repaint with no cursor query (after `execute`) |
|
||||
| `popup::run_with` | `src/popup/mod.rs:86` | Delegate to multiplexer popup + parse output |
|
||||
| `popup::check_env` | `src/popup/mod.rs:72` | Guard: multiplexer present and not already in popup |
|
||||
| `check_and_run_popup` | `src/bin/main.rs:131` | Check popup conditions, dispatch to popup::run_with |
|
||||
| `sk_main` | `src/bin/main.rs:144` | CLI orchestration + output printing |
|
||||
| `SkimEvent` | `src/binds.rs:25` | Bindable synthetic events, including `double-click`, routed through reserved `KeyEvent`s |
|
||||
| `parse_key` | `src/binds.rs:226` | `"ctrl-a"` → `KeyEvent` |
|
||||
| `parse_action_binds` | `src/binds.rs:335` | `"reload:first"`, `"act-up:suppress+down"` → action follow-up map |
|
||||
| `parse_action_chain` | `src/binds.rs:383` | `"down+select"` → `Vec<Action>` |
|
||||
| `Action::name` | `src/tui/actions.rs:254` | `Action` → canonical bind name (generated from the `define_action_catalog!` list that also defines the `Action` enum and `parse_action`) |
|
||||
| `Matcher::create_engine_factory_with_builder` | `src/matcher.rs:189` | Build engine factory chain from options |
|
||||
| `ExactOrFuzzyEngineFactory::create_engine_with_case` | `src/engine/factory.rs:93` | Parse query prefixes, build engine |
|
||||
| `AndOrEngineFactory::parse_andor` | `src/engine/factory.rs:176` | Split query into AND/OR tree |
|
||||
| `FuzzyEngine::match_item` | `src/engine/fuzzy.rs:175` | Fuzzy match a single item |
|
||||
| `LayoutTemplate::from_options` | `src/tui/layout.rs:76` | Compute widget constraint tree |
|
||||
| `LayoutTemplate::apply` | `src/tui/layout.rs:165` | Split Rect into AppLayout |
|
||||
| `ItemRenderer::render_item` | `src/tui/item_renderer.rs:84` | Full per-item render pipeline |
|
||||
| `ColorTheme::init_from_options` | `src/theme.rs:56` | Parse `--color` spec |
|
||||
|
||||
---
|
||||
|
||||
## Public Library API
|
||||
|
|
|
|||
86
CHANGELOG.md
86
CHANGELOG.md
|
|
@ -5,6 +5,92 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [5.7.0] - 2026-09-08
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Stream preview command instead of waiting for completion (#1176)
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Update benchmarks
|
||||
|
||||
### 🤖 CI
|
||||
|
||||
- Lock everything to make sure dependencies match (#1170)
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Nitpicks after multithreading review (#1172)
|
||||
|
||||
## [5.6.7] - 2026-09-04
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Make min-height work again (#1168)
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update dependencies after tinyvec breakage
|
||||
|
||||
## [5.6.6] - 2026-08-22
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(matcher)* An inverse query only checks the first --nth field (#1159)
|
||||
- Do not feed items through a fifo in zsh completions (#1164)
|
||||
- *(filter)* --filter hangs when the query is below --min-query-length (#1158)
|
||||
- Path_name_offset returns bytes while Rank::begin is a char index (#1160)
|
||||
|
||||
### 🤖 CI
|
||||
|
||||
- Automatically update PRs on pushes to master
|
||||
- Fix auto update branch
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Clippy & fmt after cargo update
|
||||
|
||||
## [5.6.5] - 2026-08-16
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Add allow_hyphen_values to --with-nth (#1156)
|
||||
- *(field)* An out-of-range field index silently matches field 1 (#1155)
|
||||
|
||||
### New Contributors
|
||||
* @VXNCXNX made their first contribution in [#1155](https://github.com/skim-rs/skim/pull/1155)
|
||||
|
||||
## [5.6.4] - 2026-08-10
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(image)* Detect picker from tty to support protocol detection with piped input
|
||||
- Platform-dependant timevals
|
||||
- Different suseconds and time per platform
|
||||
- Avoid truncation by casting up instead of down
|
||||
|
||||
## [5.6.3] - 2026-08-07
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Advance the input cursor by byte length in insert_str (#1151)
|
||||
|
||||
### New Contributors
|
||||
* @vimsucks made their first contribution in [#1151](https://github.com/skim-rs/skim/pull/1151)
|
||||
|
||||
## [5.6.2] - 2026-08-07
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Assymetry in char_equal causing panic with some unicode characters
|
||||
- Reorder batches in `--tac` mode (#1150)
|
||||
- Match double-width roman characters (closes #1149)
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Preview command runs with sh/cmd, not SHELL
|
||||
|
||||
## [5.6.1] - 2026-07-27
|
||||
|
||||
### 🤖 CI
|
||||
|
|
|
|||
441
Cargo.lock
generated
441
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
17
Cargo.toml
17
Cargo.toml
|
|
@ -1,12 +1,10 @@
|
|||
[package]
|
||||
name = "skim"
|
||||
version = "5.6.1"
|
||||
version = "5.7.0"
|
||||
authors = ["Loric ANDRE", "Zhang Jinzhou <lotabout@gmail.com>"]
|
||||
description = "Fuzzy Finder in rust!"
|
||||
documentation = "https://docs.rs/skim"
|
||||
homepage = "https://github.com/skim-rs/skim"
|
||||
repository = "https://github.com/skim-rs/skim"
|
||||
readme = "README.md"
|
||||
keywords = ["fuzzy", "menu", "util"]
|
||||
license = "MIT"
|
||||
edition = "2024"
|
||||
|
|
@ -51,6 +49,13 @@ inherits = "release"
|
|||
debug = true
|
||||
strip = false
|
||||
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
[lints.rust]
|
||||
missing_docs = "warn"
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage, coverage_nightly)'] }
|
||||
|
|
@ -73,7 +78,7 @@ crossterm = { version = ">=0.0.0", features = ["event-stream", "use-dev-tty", "l
|
|||
derive_builder = "0.20.2"
|
||||
derive_more = { version = "2.1.1", features = ["debug", "eq"] }
|
||||
env_logger = { version = "0.11.10", optional = true, features = ["humantime"] }
|
||||
frizbee = { version = "=0.11.0", optional = true }
|
||||
frizbee = { version = "=0.13.0", optional = true }
|
||||
futures = "0.3.32"
|
||||
gungraun = { version = "0.19.1", optional = true }
|
||||
image = { version = "0.25.10", optional = true, default-features = false, features = ["png", "jpeg", "gif", "webp"] }
|
||||
|
|
@ -83,7 +88,7 @@ kanal = "0.1.1"
|
|||
log = "0.4.31"
|
||||
memchr = "2.8.1"
|
||||
mimalloc = { version = "0.1.48", features = ["v3"] }
|
||||
nix = { version = "0.31.3", features = ["fs", "poll"] }
|
||||
nix = { version = "0.31.3", features = ["fs", "poll", "signal"] }
|
||||
portable-pty = "0.9.0"
|
||||
ratatui = "0.30.0"
|
||||
ratatui-image = { version = "11.0.4", features = ["crossterm"], default-features = false, optional = true }
|
||||
|
|
@ -91,7 +96,7 @@ regex = "1.12.3"
|
|||
roff = "1.1.1"
|
||||
ron = { version = "0.12.1", optional = true }
|
||||
serde = { version = "1.0.228", features = ["derive"], optional = true }
|
||||
shell-quote = "0.7.2"
|
||||
shell-quote = "0.8.0"
|
||||
shlex = { version = "2.0.1", optional = true }
|
||||
tempfile = "3.27.0"
|
||||
thiserror = "2.0.18"
|
||||
|
|
|
|||
BIN
bench.png
BIN
bench.png
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 181 KiB |
|
|
@ -1462,8 +1462,8 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
|
|||
let y_hi = mx * 2.0;
|
||||
let axes = fg.axes2d();
|
||||
axes.set_title("Total Time", lbl)
|
||||
.set_x_label("Items", lbl)
|
||||
.set_y_label("Time (s)", lbl)
|
||||
.set_x_label("Items, log", lbl)
|
||||
.set_y_label("Time (s, log)", lbl)
|
||||
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
|
||||
.set_x_log(Some(10.0))
|
||||
.set_y_log(Some(10.0))
|
||||
|
|
@ -1487,7 +1487,7 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
|
|||
let y_hi = (mx * 1.25).max(100.0);
|
||||
let axes = fg.axes2d();
|
||||
axes.set_title("Peak CPU", lbl)
|
||||
.set_x_label("Items", lbl)
|
||||
.set_x_label("Items, log", lbl)
|
||||
.set_y_label("CPU (%)", lbl)
|
||||
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
|
||||
.set_x_log(Some(10.0))
|
||||
|
|
@ -1506,16 +1506,18 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
|
|||
|
||||
// ── Panel 2: Peak Memory — log x, linear y ───────────────────────────────
|
||||
{
|
||||
let (_, mx) = y_extent(&mem_bands);
|
||||
let (mn, mx) = y_extent(&mem_bands);
|
||||
let y_lo = (mn * 0.5).max(1e-9);
|
||||
let y_hi = (mx * 1.25).max(1.0);
|
||||
let axes = fg.axes2d();
|
||||
axes.set_title("Peak Memory", lbl)
|
||||
.set_x_label("Items", lbl)
|
||||
.set_y_label("Memory (MB)", lbl)
|
||||
.set_x_label("Items, log", lbl)
|
||||
.set_y_label("Memory (MB, log)", lbl)
|
||||
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
|
||||
.set_x_log(Some(10.0))
|
||||
.set_y_log(Some(10.0))
|
||||
.set_x_range(Fix(x_lo), Fix(x_hi))
|
||||
.set_y_range(Fix(0.0), Fix(y_hi))
|
||||
.set_y_range(Fix(y_lo), Fix(y_hi))
|
||||
.set_x_grid(true)
|
||||
.set_y_grid(true)
|
||||
.set_legend(
|
||||
|
|
@ -1533,7 +1535,7 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
|
|||
let y_hi = (mx * 1.25).max(0.01);
|
||||
let axes = fg.axes2d();
|
||||
axes.set_title("Startup Time", lbl)
|
||||
.set_x_label("Items", lbl)
|
||||
.set_x_label("Items, log", lbl)
|
||||
.set_y_label("Time (s)", lbl)
|
||||
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
|
||||
.set_x_log(Some(10.0))
|
||||
|
|
|
|||
2
fuzz/Cargo.lock
generated
2
fuzz/Cargo.lock
generated
|
|
@ -1613,7 +1613,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
|||
|
||||
[[package]]
|
||||
name = "skim"
|
||||
version = "5.3.2"
|
||||
version = "5.6.3"
|
||||
dependencies = [
|
||||
"ansi-to-tui",
|
||||
"assert_enum_variants",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH sk 1 "sk 5.6.1"
|
||||
.TH sk 1 "sk 5.7.0"
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.SH NAME
|
||||
|
|
@ -346,10 +346,11 @@ Can either be a row count or a percentage A negative row count will use term hei
|
|||
Disable height (force full screen)
|
||||
.TP
|
||||
\fB\-\-min\-height\fR \fI<MIN_HEIGHT>\fR [default: 10]
|
||||
Minimum height of skim\*(Aqs window
|
||||
Minimum height of skim\*(Aqs window as a non\-negative row count
|
||||
|
||||
Useful when the height is set as a percentage
|
||||
Ignored when \-\-height is not specified
|
||||
Must be a non\-negative row count, not a percentage.
|
||||
Useful when the height is set as a percentage.
|
||||
Ignored when \-\-height is not specified.
|
||||
.TP
|
||||
\fB\-\-margin\fR \fI<MARGIN>\fR [default: 0]
|
||||
Screen margin
|
||||
|
|
@ -514,9 +515,9 @@ Maximum number of query history entries to keep
|
|||
\fB\-\-preview\fR \fI<PREVIEW>\fR
|
||||
Preview command
|
||||
|
||||
Execute the given command for the current line and display the result on the preview window. {} in the command
|
||||
is the placeholder that is replaced to the single\-quoted string of the current line. To transform the
|
||||
replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
|
||||
Execute the given command with `sh \-c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
|
||||
`{}` in the command is the placeholder that is replaced to the single\-quoted string of the current line.
|
||||
To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
|
||||
|
||||
**Examples**:
|
||||
|
||||
|
|
@ -1335,4 +1336,4 @@ When using `sk \-\-remote`, pipe in action chains (see the KEYBINDS section), fo
|
|||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.SH VERSION
|
||||
v5.6.1
|
||||
v5.7.0
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ complete -c sk -l layout -d 'Set layout' -r -f -a "default\t'Display from the bo
|
|||
reverse\t'Display from the top of the screen'
|
||||
reverse-list\t'Display from the top of the screen, prompt at the bottom'"
|
||||
complete -c sk -l height -d 'Height of skim\'s window' -r
|
||||
complete -c sk -l min-height -d 'Minimum height of skim\'s window' -r
|
||||
complete -c sk -l min-height -d 'Minimum height of skim\'s window as a non-negative row count' -r
|
||||
complete -c sk -l margin -d 'Screen margin' -r
|
||||
complete -c sk -s p -l prompt -d 'Set prompt' -r
|
||||
complete -c sk -l cmd-prompt -d 'Set prompt in command mode' -r
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ module completions {
|
|||
--reverse # Shorthand for reverse layout
|
||||
--height: string # Height of skim's window
|
||||
--no-height # Disable height (force full screen)
|
||||
--min-height: string # Minimum height of skim's window
|
||||
--min-height: string # Minimum height of skim's window as a non-negative row count
|
||||
--margin: string # Screen margin
|
||||
--prompt(-p): string # Set prompt
|
||||
--cmd-prompt: string # Set prompt in command mode
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ history\:"History scheme\: will force index as the first tiebreak"))' \
|
|||
reverse\:"Display from the top of the screen"
|
||||
reverse-list\:"Display from the top of the screen, prompt at the bottom"))' \
|
||||
'--height=[Height of skim'\''s window]:HEIGHT:_default' \
|
||||
'--min-height=[Minimum height of skim'\''s window]:MIN_HEIGHT:_default' \
|
||||
'--min-height=[Minimum height of skim'\''s window as a non-negative row count]:MIN_HEIGHT:_default' \
|
||||
'--margin=[Screen margin]:MARGIN:_default' \
|
||||
'-p+[Set prompt]:PROMPT:_default' \
|
||||
'--prompt=[Set prompt]:PROMPT:_default' \
|
||||
|
|
|
|||
|
|
@ -267,12 +267,6 @@ _skim_dir_completion() {
|
|||
"" "/" ""
|
||||
}
|
||||
|
||||
_skim_feed_fifo() (
|
||||
command rm -f "$1"
|
||||
mkfifo "$1"
|
||||
cat <&0 > "$1" &
|
||||
)
|
||||
|
||||
_skim_complete() {
|
||||
setopt localoptions ksh_arrays
|
||||
# Split arguments around --
|
||||
|
|
@ -296,20 +290,17 @@ _skim_complete() {
|
|||
rest=("$@")
|
||||
fi
|
||||
|
||||
local fifo lbuf cmd matches post
|
||||
fifo="${TMPDIR:-/tmp}/skim-complete-fifo-$$"
|
||||
local lbuf cmd matches post
|
||||
lbuf=${rest[0]}
|
||||
cmd=$(__skim_extract_command "$lbuf")
|
||||
post="${funcstack[1]}_post"
|
||||
type $post > /dev/null 2>&1 || post=cat
|
||||
|
||||
_skim_feed_fifo "$fifo"
|
||||
matches=$(SKIM_DEFAULT_OPTIONS="--reverse $SKIM_DEFAULT_OPTIONS $SKIM_COMPLETION_OPTS $str_arg" __skim_comprun "$cmd" "${args[@]}" -q "${(Q)prefix}" < "$fifo" | $post | tr '\n' ' ')
|
||||
matches=$(SKIM_DEFAULT_OPTIONS="--reverse $SKIM_DEFAULT_OPTIONS $SKIM_COMPLETION_OPTS $str_arg" __skim_comprun "$cmd" "${args[@]}" -q "${(Q)prefix}" | $post | tr '\n' ' ')
|
||||
if [ -n "$matches" ]; then
|
||||
LBUFFER="$lbuf$matches"
|
||||
fi
|
||||
zle reset-prompt
|
||||
command rm -f "$fifo"
|
||||
}
|
||||
|
||||
_skim_complete_telnet() {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
5.6.1
|
||||
5.7.0
|
||||
|
|
|
|||
|
|
@ -79,24 +79,33 @@ impl MatchEngine for ExactEngine {
|
|||
let mut matched_result = None;
|
||||
let item_text = item.text();
|
||||
let default_range = [(0, item_text.len())];
|
||||
for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
|
||||
let start = min(start, item_text.len());
|
||||
let end = min(end, item_text.len());
|
||||
if self.query_regex.is_none() {
|
||||
matched_result = Some((0, 0));
|
||||
break;
|
||||
let ranges = item.get_matching_ranges().unwrap_or(&default_range);
|
||||
|
||||
if ranges.is_empty() {
|
||||
// Nothing to match against (e.g. every `--nth` index is out of range): the item
|
||||
// stays unmatched, inverse or not.
|
||||
} else if self.query_regex.is_none() {
|
||||
matched_result = Some((0, 0));
|
||||
} else {
|
||||
for &(start, end) in ranges {
|
||||
let start = min(start, item_text.len());
|
||||
let end = min(end, item_text.len());
|
||||
|
||||
matched_result =
|
||||
regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (s + start, e + start));
|
||||
|
||||
if matched_result.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
matched_result =
|
||||
regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (s + start, e + start));
|
||||
|
||||
// An inverse query has to be evaluated over *all* the matching ranges: the item
|
||||
// only matches when none of them contains the query. Inverting inside the loop
|
||||
// would let the first non-matching field short-circuit the scan and wrongly
|
||||
// accept an item whose later fields do contain the query.
|
||||
if self.inverse {
|
||||
matched_result = matched_result.xor(Some((0, 0)));
|
||||
}
|
||||
|
||||
if matched_result.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (begin, end) = matched_result?;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,83 @@ fn inverse_match_excludes_query() {
|
|||
assert!(e.match_item(&"foo".to_string()).is_none());
|
||||
}
|
||||
|
||||
/// An item exposing explicit matching ranges, as `--nth` produces.
|
||||
struct RangedItem {
|
||||
text: String,
|
||||
ranges: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl SkimItem for RangedItem {
|
||||
fn text(&self) -> std::borrow::Cow<'_, str> {
|
||||
std::borrow::Cow::Borrowed(&self.text)
|
||||
}
|
||||
|
||||
fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
|
||||
Some(&self.ranges)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_match_checks_every_matching_range() {
|
||||
// `--nth 1,2` over "foo bar" yields two ranges: "foo" and "bar". An inverse
|
||||
// query `!foo` must reject the item because one of the ranges contains "foo",
|
||||
// even though the *first* range scanned may not.
|
||||
let e = engine(
|
||||
"foo",
|
||||
ExactMatchingParam {
|
||||
inverse: true,
|
||||
case: CaseMatching::Ignore,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let foo_in_first_range = RangedItem {
|
||||
text: "foo bar".to_string(),
|
||||
ranges: vec![(0, 3), (4, 7)],
|
||||
};
|
||||
assert!(
|
||||
e.match_item(&foo_in_first_range).is_none(),
|
||||
"item whose first field contains the query must not match an inverse query"
|
||||
);
|
||||
|
||||
let foo_in_second_range = RangedItem {
|
||||
text: "bar foo".to_string(),
|
||||
ranges: vec![(0, 3), (4, 7)],
|
||||
};
|
||||
assert!(
|
||||
e.match_item(&foo_in_second_range).is_none(),
|
||||
"item whose second field contains the query must not match an inverse query"
|
||||
);
|
||||
|
||||
let no_foo = RangedItem {
|
||||
text: "bar baz".to_string(),
|
||||
ranges: vec![(0, 3), (4, 7)],
|
||||
};
|
||||
assert!(
|
||||
e.match_item(&no_foo).is_some(),
|
||||
"item where no field contains the query must match an inverse query"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_match_with_no_matching_range_does_not_match() {
|
||||
// Every `--nth` index out of range leaves the item with no range at all;
|
||||
// there is nothing to match against, so the item stays unmatched.
|
||||
let e = engine(
|
||||
"foo",
|
||||
ExactMatchingParam {
|
||||
inverse: true,
|
||||
case: CaseMatching::Ignore,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let item = RangedItem {
|
||||
text: "bar baz".to_string(),
|
||||
ranges: vec![],
|
||||
};
|
||||
assert!(e.match_item(&item).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_matches_everything() {
|
||||
let e = engine("", ExactMatchingParam::default());
|
||||
|
|
|
|||
12
src/field.rs
12
src/field.rs
|
|
@ -23,6 +23,14 @@ pub enum FieldRange {
|
|||
Both(i32, i32),
|
||||
}
|
||||
|
||||
/// Parses one side of a field range. The regex only ever hands us `-?\d+`, so the
|
||||
/// single failure mode is overflowing `i32`; saturate instead of silently falling
|
||||
/// back to a different field.
|
||||
fn parse_index(s: &str) -> i32 {
|
||||
s.parse()
|
||||
.unwrap_or(if s.starts_with('-') { i32::MIN } else { i32::MAX })
|
||||
}
|
||||
|
||||
impl FieldRange {
|
||||
/// Parses a field range from a string (e.g., "1", "1..", "..10", "1..10")
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
|
|
@ -32,8 +40,8 @@ impl FieldRange {
|
|||
// "1", "1..", "..10", "1..10", etc.
|
||||
let opt_caps = FIELD_RANGE.captures(range);
|
||||
if let Some(caps) = opt_caps {
|
||||
let opt_left = caps.name("left").map(|s| s.as_str().parse().unwrap_or(1));
|
||||
let opt_right = caps.name("right").map(|s| s.as_str().parse().unwrap_or(-1));
|
||||
let opt_left = caps.name("left").map(|s| parse_index(s.as_str()));
|
||||
let opt_right = caps.name("right").map(|s| parse_index(s.as_str()));
|
||||
let opt_sep = caps.name("sep").map(|s| s.as_str().to_string());
|
||||
|
||||
match (opt_left, opt_right) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,24 @@ fn test_parse_range() {
|
|||
assert_eq!(FieldRange::from_str("a..b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_out_of_i32_range() {
|
||||
// Indices past i32 saturate instead of silently falling back to field 1 / -1.
|
||||
assert_eq!(FieldRange::from_str("2147483648"), Some(Single(i32::MAX)));
|
||||
assert_eq!(FieldRange::from_str("-2147483649"), Some(Single(i32::MIN)));
|
||||
assert_eq!(FieldRange::from_str("99999999999.."), Some(RightInf(i32::MAX)));
|
||||
assert_eq!(FieldRange::from_str("..99999999999"), Some(LeftInf(i32::MAX)));
|
||||
assert_eq!(FieldRange::from_str("2..99999999999"), Some(Both(2, i32::MAX)));
|
||||
|
||||
// ...and a saturated index still resolves to nothing on a short line.
|
||||
assert_eq!(
|
||||
FieldRange::from_str("2147483648").unwrap().to_index_pair(3),
|
||||
Single(i32::MAX).to_index_pair(3)
|
||||
);
|
||||
assert_eq!(FieldRange::from_str("2147483648").unwrap().to_index_pair(3), None);
|
||||
assert_eq!(FieldRange::from_str("-2147483649").unwrap().to_index_pair(3), None);
|
||||
}
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Byte/Char helpers
|
||||
use super::Score;
|
||||
use super::constants::SEPARATOR_TABLE;
|
||||
use crate::fuzzy_matcher::util::char_equal;
|
||||
use memchr::{memchr, memrchr};
|
||||
|
||||
pub(super) trait Atom: PartialEq + Into<char> + Copy {
|
||||
|
|
@ -112,9 +113,15 @@ impl Atom for u8 {
|
|||
}
|
||||
impl Atom for char {
|
||||
#[inline(always)]
|
||||
fn eq_ignore_case(self, b: Self) -> bool {
|
||||
self.to_lowercase().eq(b.to_lowercase())
|
||||
fn eq(self, other: Self, respect_case: bool) -> bool {
|
||||
char_equal(self, other, respect_case)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn eq_ignore_case(self, b: Self) -> bool {
|
||||
char_equal(self, b, false)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn is_lowercase(self) -> bool {
|
||||
self.is_lowercase()
|
||||
|
|
@ -179,6 +186,8 @@ mod tests {
|
|||
fn char_atom_eq_and_case() {
|
||||
assert!('a'.eq('A', false));
|
||||
assert!(!'a'.eq('A', true));
|
||||
assert!('a'.eq('a', true));
|
||||
assert!('A'.eq('a', false));
|
||||
assert!('a'.is_lowercase());
|
||||
assert!(!'A'.is_lowercase());
|
||||
// Default (non-SIMD) find impls for char.
|
||||
|
|
|
|||
|
|
@ -28,18 +28,14 @@ pub(super) fn compute_banding<const ALLOW_TYPOS: bool, C: Atom>(
|
|||
) -> Option<BandingInfo> {
|
||||
let n = pat.len();
|
||||
let m = cho.len();
|
||||
let row_bounds;
|
||||
let j_first;
|
||||
|
||||
if ALLOW_TYPOS {
|
||||
j_first = find_first_char(pat, cho, respect_case)?;
|
||||
row_bounds = None;
|
||||
let (j_first, row_bounds) = if ALLOW_TYPOS {
|
||||
(find_first_char(pat, cho, respect_case)?, None)
|
||||
} else {
|
||||
let fm = compute_first_match_cols(pat, cho, respect_case)?;
|
||||
let lm = compute_last_match_cols(pat, cho, respect_case)?;
|
||||
j_first = fm[0];
|
||||
row_bounds = Some(compute_row_col_bounds(n, m, &fm, &lm));
|
||||
}
|
||||
(fm[0], Some(compute_row_col_bounds(n, m, &fm, &lm)))
|
||||
};
|
||||
|
||||
let bandwidth = if ALLOW_TYPOS { n + TYPO_BAND_SLACK } else { 0 };
|
||||
let min_true_matches = if ALLOW_TYPOS { n.div_ceil(2) } else { 0 };
|
||||
|
|
|
|||
|
|
@ -140,13 +140,14 @@ impl FuzzyMatcher for ClangdMatcher {
|
|||
let mut row = num_pattern_chars;
|
||||
let mut col = num_choice_chars;
|
||||
|
||||
while row > 0 || col > 0 {
|
||||
if last_action == Action::Match {
|
||||
indices_reverse.push((col - 1) as IndexType);
|
||||
}
|
||||
|
||||
while col > 0 {
|
||||
let cell = &dp[row][col];
|
||||
if last_action == Action::Match {
|
||||
if row == 0 {
|
||||
debug_assert!(false, "clangd backtracking hit a match with no pattern left");
|
||||
break;
|
||||
}
|
||||
indices_reverse.push((col - 1) as IndexType);
|
||||
last_action = cell.last_action_match;
|
||||
row -= 1;
|
||||
col -= 1;
|
||||
|
|
|
|||
|
|
@ -85,4 +85,53 @@ mod tests {
|
|||
// Empty pattern yields an empty index list, so begin/end fall back to 0.
|
||||
assert_eq!(StubMatcher.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
|
||||
}
|
||||
|
||||
/// Regression test for a fuzzer-found panic (fuzz target `fuzzy_match`).
|
||||
///
|
||||
/// 'İ' (U+0130) lowercases to two chars, and `char_equal` used to be
|
||||
/// asymmetric for such characters. `cheap_matches` compares
|
||||
/// `(choice, pattern)` while the matchers' `allow_match` helpers compare
|
||||
/// `(pattern, choice)`, so the cheap pre-filter accepted a candidate the DP
|
||||
/// then refused to match. The clangd matcher's backtracking loop walked off
|
||||
/// the start of its matrix, panicking with "attempt to subtract with
|
||||
/// overflow" in debug and an out-of-bounds index in release.
|
||||
#[test]
|
||||
fn multichar_lowercase_does_not_panic() {
|
||||
use crate::fuzzy_matcher::clangd::ClangdMatcher;
|
||||
use crate::fuzzy_matcher::fzy::FzyMatcher;
|
||||
use crate::fuzzy_matcher::skim::SkimMatcherV2;
|
||||
|
||||
let skim = SkimMatcherV2::default();
|
||||
let fzy = FzyMatcher::default();
|
||||
let clangd = ClangdMatcher::default();
|
||||
let matchers: [(&str, &dyn FuzzyMatcher); 3] = [("skim", &skim), ("fzy", &fzy), ("clangd", &clangd)];
|
||||
|
||||
// The exact crashing input from the fuzz artifact, plus related shapes.
|
||||
let cases = [
|
||||
("Jİ:I", "İ:İ"),
|
||||
("I", "İ"),
|
||||
("İ", "I"),
|
||||
("i", "İ"),
|
||||
("İ", "i"),
|
||||
("Jİ:Iİ", "İİ"),
|
||||
("straße", "STRASSE"),
|
||||
("ffly", "ffl"),
|
||||
];
|
||||
|
||||
for (choice, pattern) in cases {
|
||||
let num_chars = choice.chars().count();
|
||||
for (name, matcher) in matchers {
|
||||
// Must not panic, and any returned index must be a valid char
|
||||
// index into `choice` (the invariant asserted by the fuzzer).
|
||||
if let Some((_score, indices)) = matcher.fuzzy_indices(choice, pattern) {
|
||||
for idx in indices {
|
||||
assert!(
|
||||
idx < num_chars,
|
||||
"{name}: match index {idx} out of bounds for {choice:?} ({num_chars} chars)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,15 @@ fn test_match_or_not() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullwidth_ascii_matches_ascii_query() {
|
||||
let matcher = SkimMatcherV2::default();
|
||||
let (_, indices) = matcher
|
||||
.fuzzy_indices("abc", "abc")
|
||||
.expect("fullwidth ASCII should match");
|
||||
assert_eq!(indices, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_quality() {
|
||||
let matcher = SkimMatcherV2::default().ignore_case();
|
||||
|
|
|
|||
|
|
@ -22,26 +22,43 @@ pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) ->
|
|||
}
|
||||
}
|
||||
|
||||
/// Given 2 character, check if they are equal (considering ascii case)
|
||||
/// Convert the Unicode fullwidth form of an ASCII character to ASCII.
|
||||
#[inline]
|
||||
fn narrow_ascii_width(ch: char) -> char {
|
||||
match ch {
|
||||
'\u{3000}' => ' ',
|
||||
'\u{FF01}'..='\u{FF5E}' => char::from_u32(ch as u32 - 0xFEE0).unwrap_or(ch),
|
||||
_ => ch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Given two characters, check if they are equal after folding ASCII width and,
|
||||
/// when requested, case.
|
||||
/// e.g. ('a', 'A', true) => false
|
||||
/// e.g. ('a', 'A', false) => true
|
||||
/// e.g. ('a', 'a', true) => true
|
||||
#[inline]
|
||||
pub fn char_equal(a: char, b: char, case_sensitive: bool) -> bool {
|
||||
if case_sensitive {
|
||||
a == b
|
||||
} else {
|
||||
let a_lower = a.to_lowercase();
|
||||
let mut b_lower = b.to_lowercase();
|
||||
for a_n in a_lower {
|
||||
let Some(b_n) = b_lower.next() else {
|
||||
return false;
|
||||
};
|
||||
if a_n != b_n {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
|
||||
let a = narrow_ascii_width(a);
|
||||
let b = narrow_ascii_width(b);
|
||||
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
|
||||
if case_sensitive {
|
||||
return false;
|
||||
}
|
||||
|
||||
if a.is_ascii() && b.is_ascii() {
|
||||
return a.eq_ignore_ascii_case(&b);
|
||||
}
|
||||
|
||||
a.to_lowercase().eq(b.to_lowercase())
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
|
|
@ -158,11 +175,56 @@ mod tests {
|
|||
assert!(!char_equal('a', 'b', false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_equal_folds_fullwidth_ascii() {
|
||||
assert!(char_equal('a', 'a', true));
|
||||
assert!(char_equal('A', 'A', true));
|
||||
assert!(!char_equal('A', 'a', true));
|
||||
assert!(char_equal('A', 'a', false));
|
||||
assert!(char_equal('1', '1', true));
|
||||
assert!(char_equal(' ', ' ', true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_equal_multichar_lowercase_mismatch() {
|
||||
// 'İ' (U+0130) lowercases to two chars ("i" + combining dot), so it is
|
||||
// not equal to the single char 'i' — exercising the length-mismatch path.
|
||||
assert!(!char_equal('İ', 'i', false));
|
||||
// ...and the comparison must be symmetric. This direction used to
|
||||
// return `true` because the shorter sequence was exhausted first, which
|
||||
// made `cheap_matches` and `allow_match` disagree and drove the clangd
|
||||
// matcher's backtracking past the start of the DP matrix.
|
||||
assert!(!char_equal('i', 'İ', false));
|
||||
assert!(!char_equal('I', 'İ', false));
|
||||
assert!(!char_equal('İ', 'I', false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_equal_is_symmetric() {
|
||||
// Exhaustively check symmetry against chars with multi-char or
|
||||
// otherwise unusual lowercase mappings.
|
||||
let probes = ['i', 'I', 'İ', 'ı', 'ß', 'ẞ', 'ffl', 'ς', 'Σ', 'K', 'İ', 'Dž'];
|
||||
for u in 0..=0x2FFFu32 {
|
||||
let Some(ch) = char::from_u32(u) else { continue };
|
||||
for p in probes {
|
||||
for case_sensitive in [true, false] {
|
||||
assert_eq!(
|
||||
char_equal(ch, p, case_sensitive),
|
||||
char_equal(p, ch, case_sensitive),
|
||||
"char_equal is asymmetric for ({ch:?}, {p:?}, {case_sensitive})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_equal_reflexive() {
|
||||
for u in 0..=0x2FFFu32 {
|
||||
let Some(ch) = char::from_u32(u) else { continue };
|
||||
assert!(char_equal(ch, ch, true));
|
||||
assert!(char_equal(ch, ch, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -265,16 +265,13 @@ impl SkimItemReader {
|
|||
/// 1. **I/O thread** (dedicated) — reads large byte chunks (~256 KB) from
|
||||
/// `source`, splitting on line boundaries, and sends them tagged with
|
||||
/// monotonic sequence numbers into a bounded channel.
|
||||
/// 2. **Dispatcher thread** (dedicated, lightweight) — drains that channel
|
||||
/// and submits one pool job per chunk. The bounded channel provides
|
||||
/// natural back-pressure on the I/O thread when the pool is busy.
|
||||
/// 2. **Bounded dispatcher** — submits chunk jobs to the pool while a token
|
||||
/// limit bounds queued and running work. It stops draining the input
|
||||
/// channel when that limit is reached, which applies back-pressure to I/O.
|
||||
/// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
|
||||
/// field transforms, and create `DefaultSkimItem` + `Arc` per line.
|
||||
/// Because these jobs share the same pool as the matcher, reader and
|
||||
/// matcher compete for the same thread budget rather than over-subscribing
|
||||
/// available CPU cores.
|
||||
/// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from pool
|
||||
/// jobs and emits them in sequence order so downstream index assignment
|
||||
/// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from workers
|
||||
/// and emits them in sequence order so downstream index assignment
|
||||
/// and `--tac` behaviour are correct.
|
||||
///
|
||||
/// When `child` is `Some`, a **killer thread** is also spawned. It waits
|
||||
|
|
@ -303,18 +300,27 @@ impl SkimItemReader {
|
|||
// Stage 1: I/O thread.
|
||||
Self::spawn_io_reader(source, tx_chunks, line_ending);
|
||||
|
||||
// Stage 2: dispatcher thread — bridges the bounded channel to the pool.
|
||||
// Stage 2: dispatch at most a fixed number of queued or running jobs.
|
||||
// A worker returns its token only after it sends the parsed result.
|
||||
let max_in_flight = num_threads * 4;
|
||||
let (tx_permits, rx_permits) = std::sync::mpsc::sync_channel(max_in_flight);
|
||||
for _ in 0..max_in_flight {
|
||||
tx_permits.send(()).expect("permit receiver is alive");
|
||||
}
|
||||
thread::spawn(move || {
|
||||
while let Ok((seq, chunk)) = rx_chunks.recv() {
|
||||
if rx_permits.recv().is_err() {
|
||||
break;
|
||||
}
|
||||
let tx = tx_results.clone();
|
||||
let return_permit = tx_permits.clone();
|
||||
let opt = option.clone();
|
||||
pool.spawn(move || {
|
||||
let result = Self::process_chunk(seq, &chunk, &opt);
|
||||
let _ = tx.send(result);
|
||||
let _ = return_permit.send(());
|
||||
});
|
||||
}
|
||||
// rx_chunks closed → all chunks dispatched; tx_results dropped here
|
||||
// so the reorder thread exits once the last pool job finishes.
|
||||
});
|
||||
|
||||
// A zero-capacity channel used as a completion signal: the reorder
|
||||
|
|
|
|||
58
src/item.rs
58
src/item.rs
|
|
@ -24,12 +24,14 @@ use tokio::sync::Notify;
|
|||
#[derive(Debug)]
|
||||
pub struct RankBuilder {
|
||||
criterion: Vec<RankCriteria>,
|
||||
tac: bool,
|
||||
}
|
||||
|
||||
impl Default for RankBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
|
||||
tac: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +45,13 @@ impl RankBuilder {
|
|||
}
|
||||
|
||||
criterion.dedup();
|
||||
Self { criterion }
|
||||
Self { criterion, tac: false }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn tac(mut self, tac: bool) -> Self {
|
||||
self.tac = tac;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the tiebreak criteria slice.
|
||||
|
|
@ -52,11 +60,40 @@ impl RankBuilder {
|
|||
&self.criterion
|
||||
}
|
||||
|
||||
/// Computes the byte offset of the first character after the last path separator
|
||||
/// (`/` or `\`) in `text`. Returns `0` when no separator is present.
|
||||
fn sort_key(&self, rank: &Rank) -> [i32; 6] {
|
||||
let configured = rank.sort_key(&self.criterion);
|
||||
let mut key = [0; 6];
|
||||
key[..5].copy_from_slice(&configured);
|
||||
|
||||
if self.tac {
|
||||
for (priority, criterion) in self.criterion.iter().take(5).enumerate() {
|
||||
key[priority] = match criterion {
|
||||
RankCriteria::Index => rank.index.saturating_neg(),
|
||||
RankCriteria::NegIndex => rank.index,
|
||||
_ => key[priority],
|
||||
};
|
||||
}
|
||||
}
|
||||
key[5] = if self.tac {
|
||||
rank.index.saturating_neg()
|
||||
} else {
|
||||
rank.index
|
||||
};
|
||||
key
|
||||
}
|
||||
|
||||
/// Computes the **character** index of the first character after the last path
|
||||
/// separator (`/` or `\`) in `text`. Returns `0` when no separator is present.
|
||||
///
|
||||
/// This must be a char index, not a byte offset: the `PathName` tiebreak
|
||||
/// subtracts it from [`Rank::begin`], which is a char index, so counting bytes
|
||||
/// here would mix units and mis-rank any path with a non-ASCII component.
|
||||
fn path_name_offset(text: &str) -> i32 {
|
||||
text.rfind(['/', '\\'])
|
||||
.map_or(0, |pos| i32::try_from(pos).unwrap_or(i32::MAX).saturating_add(1))
|
||||
text.rfind(['/', '\\']).map_or(0, |pos| {
|
||||
i32::try_from(text[..pos].chars().count())
|
||||
.unwrap_or(i32::MAX)
|
||||
.saturating_add(1)
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a `Rank` from raw match measurements.
|
||||
|
|
@ -121,8 +158,9 @@ pub struct MatchedItem {
|
|||
/// Range of characters that matched the pattern
|
||||
pub matched_range: Option<MatchRange>,
|
||||
/// Sort key precomputed at construction time from `rank` and the tiebreak
|
||||
/// criteria. Caching avoids recomputing it on every comparison during sort.
|
||||
sort_key: [i32; 5],
|
||||
/// criteria. The sixth slot is the tac-aware implicit index tiebreak.
|
||||
/// Caching avoids recomputing it on every comparison during sort.
|
||||
sort_key: [i32; 6],
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MatchedItem {
|
||||
|
|
@ -162,7 +200,7 @@ impl MatchedItem {
|
|||
item,
|
||||
rank,
|
||||
matched_range,
|
||||
sort_key: rank.sort_key(rank_builder.criteria()),
|
||||
sort_key: rank_builder.sort_key(&rank),
|
||||
}
|
||||
}
|
||||
/// Merge two sorted `Vec<MatchedItem>` lists into one, preserving sort order by rank.
|
||||
|
|
@ -359,9 +397,7 @@ impl PartialOrd for MatchedItem {
|
|||
|
||||
impl Ord for MatchedItem {
|
||||
fn cmp(&self, other: &Self) -> CmpOrd {
|
||||
self.sort_key
|
||||
.cmp(&other.sort_key)
|
||||
.then_with(|| self.rank.index.cmp(&other.rank.index))
|
||||
self.sort_key.cmp(&other.sort_key)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,33 @@ fn build_rank_records_offsets_and_pathname() {
|
|||
assert_eq!(rank.path_name_offset, i32::try_from("src/lib/".len()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_name_offset_counts_chars_not_bytes() {
|
||||
// `path_name_offset` is subtracted from `Rank::begin`, which is a char index,
|
||||
// so a multi-byte directory component must not inflate it.
|
||||
let rb = RankBuilder::default();
|
||||
// "ééé/" is 4 chars but 7 bytes; the filename starts at char index 4.
|
||||
let rank = rb.build_rank(0, 4, 5, "ééé/a");
|
||||
assert_eq!(rank.path_name_offset, 4);
|
||||
|
||||
// With the match sitting on the filename, PathName must score it as 0 (best).
|
||||
// A byte-based offset would give 7 - 4 = 3 and rank it below a plain match.
|
||||
assert_eq!(rank.sort_key(&[RankCriteria::PathName])[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathname_tiebreak_prefers_filename_match_with_non_ascii_dir() {
|
||||
// "ééééé/a" matches in the filename (best), "a/xxxxx" matches in the dir part.
|
||||
let rb = RankBuilder::new(vec![RankCriteria::PathName, RankCriteria::Index]);
|
||||
let in_filename = MatchedItem::new(item("ééééé/a"), rb.build_rank(0, 6, 7, "ééééé/a"), None, &rb);
|
||||
let in_dir = MatchedItem::new(item("a/xxxxx"), rb.build_rank(0, 0, 1, "a/xxxxx"), None, &rb);
|
||||
|
||||
assert!(
|
||||
in_filename < in_dir,
|
||||
"a filename match must outrank a directory match even when the directory is non-ASCII"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_key_flips_score_sign() {
|
||||
let rank = Rank {
|
||||
|
|
@ -59,6 +86,57 @@ fn matched_item_ordering_prefers_higher_score() {
|
|||
assert!(high < low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matched_item_ordering_reverses_stable_input_index_for_tac() {
|
||||
let old_rank = Rank {
|
||||
index: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let new_rank = Rank {
|
||||
index: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let normal_builder = RankBuilder::new(vec![RankCriteria::Index]);
|
||||
let old = MatchedItem::new(item("old"), old_rank, None, &normal_builder);
|
||||
let new = MatchedItem::new(item("new"), new_rank, None, &normal_builder);
|
||||
assert!(old < new);
|
||||
|
||||
let tac_builder = RankBuilder::new(vec![RankCriteria::Index]).tac(true);
|
||||
let old = MatchedItem::new(item("old"), old_rank, None, &tac_builder);
|
||||
let new = MatchedItem::new(item("new"), new_rank, None, &tac_builder);
|
||||
assert!(new < old);
|
||||
assert_eq!(new.rank.index, 1);
|
||||
assert_eq!(MatchedItem::sorted_merge(vec![old], vec![new])[0].text(), "new");
|
||||
|
||||
let tac_builder = RankBuilder::default().tac(true);
|
||||
let old = MatchedItem::new(item("old"), old_rank, None, &tac_builder);
|
||||
let new = MatchedItem::new(item("new"), new_rank, None, &tac_builder);
|
||||
assert!(new < old);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_merge_tac_places_newer_incremental_batch_first() {
|
||||
let rank_builder = RankBuilder::default().tac(true);
|
||||
let make = |text: &str, index| {
|
||||
MatchedItem::new(
|
||||
item(text),
|
||||
Rank {
|
||||
index,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
&rank_builder,
|
||||
)
|
||||
};
|
||||
let existing = vec![make("c", 2), make("b", 1), make("a", 0)];
|
||||
let incoming = vec![make("e", 4), make("d", 3)];
|
||||
|
||||
let merged = MatchedItem::sorted_merge(existing, incoming);
|
||||
let indexes: Vec<_> = merged.iter().map(|item| item.rank.index).collect();
|
||||
assert_eq!(indexes, [4, 3, 2, 1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_merge_handles_empty_inputs() {
|
||||
let a = vec![matched("a", 0, 10)];
|
||||
|
|
|
|||
227
src/matcher.rs
227
src/matcher.rs
|
|
@ -2,14 +2,13 @@
|
|||
use crate::thread_pool::{self, ThreadPool};
|
||||
use crate::tui::item_list::{MergeStrategy, ProcessedItems};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::engine::normalized::NormalizedEngineFactory;
|
||||
use crate::engine::split::SplitMatchEngineFactory;
|
||||
use crate::item::{ItemPool, MatchedItem, RankBuilder};
|
||||
use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory};
|
||||
use crate::spinlock::SpinLock;
|
||||
use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
|
||||
|
||||
/// Merges per-worker match results and writes them into `processed_items`.
|
||||
|
|
@ -25,13 +24,28 @@ use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
|
|||
/// flattened without sorting.
|
||||
///
|
||||
/// Signals `needs_render` after writing so the UI picks up the new data.
|
||||
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
|
||||
debug_assert!(batch_index < batch_len);
|
||||
if tac {
|
||||
start + batch_len - 1 - batch_index
|
||||
} else {
|
||||
start + batch_index
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_worker_results(
|
||||
worker_results: Vec<Vec<MatchedItem>>,
|
||||
no_sort: bool,
|
||||
processed_items: &SpinLock<Option<ProcessedItems>>,
|
||||
processed_items: &Mutex<Option<ProcessedItems>>,
|
||||
merge_strategy: MergeStrategy,
|
||||
generation: usize,
|
||||
current_generation: &AtomicUsize,
|
||||
needs_render: &AtomicBool,
|
||||
) {
|
||||
if current_generation.load(Ordering::Acquire) != generation {
|
||||
return;
|
||||
}
|
||||
|
||||
let total_len: usize = worker_results.iter().map(Vec::len).sum();
|
||||
let mut items = Vec::with_capacity(total_len);
|
||||
for chunk in worker_results {
|
||||
|
|
@ -46,21 +60,40 @@ fn merge_worker_results(
|
|||
|
||||
trace!("matcher stop, total matched: {}", items.len());
|
||||
|
||||
// Single lock, single write into processed_items.
|
||||
let mut guard = processed_items.lock();
|
||||
// Validate while holding the result lock so an old matcher cannot overwrite
|
||||
// results that belong to a newer query generation.
|
||||
let mut guard = processed_items
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if current_generation.load(Ordering::Acquire) != generation {
|
||||
return;
|
||||
}
|
||||
if matches!(merge_strategy, MergeStrategy::Replace) {
|
||||
*guard = Some(ProcessedItems {
|
||||
items,
|
||||
merge: MergeStrategy::Replace,
|
||||
generation,
|
||||
});
|
||||
drop(guard);
|
||||
needs_render.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
match &mut *guard {
|
||||
Some(existing) if existing.generation != generation => {
|
||||
*guard = Some(ProcessedItems {
|
||||
items,
|
||||
merge: MergeStrategy::Replace,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
Some(existing) => {
|
||||
if no_sort {
|
||||
existing.items.extend(items);
|
||||
if matches!(merge_strategy, MergeStrategy::Prepend) {
|
||||
items.append(&mut existing.items);
|
||||
existing.items = items;
|
||||
} else {
|
||||
existing.items.extend(items);
|
||||
}
|
||||
} else {
|
||||
// Both sides are fully sorted — one O(n+m) merge.
|
||||
MatchedItem::merge_into_sorted(&mut existing.items, items);
|
||||
|
|
@ -70,6 +103,7 @@ fn merge_worker_results(
|
|||
*guard = Some(ProcessedItems {
|
||||
items,
|
||||
merge: merge_strategy,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -186,15 +220,16 @@ impl Matcher {
|
|||
#[must_use]
|
||||
pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
|
||||
if options.regex {
|
||||
let regex_factory = RegexEngineFactory::builder();
|
||||
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
|
||||
let regex_factory = RegexEngineFactory::builder().rank_builder(rank_builder.clone());
|
||||
let factory: Rc<dyn MatchEngineFactory> = if options.normalize {
|
||||
Rc::new(NormalizedEngineFactory::new(regex_factory))
|
||||
} else {
|
||||
Rc::new(regex_factory)
|
||||
};
|
||||
(factory, Arc::new(RankBuilder::default()))
|
||||
(factory, rank_builder)
|
||||
} else {
|
||||
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()));
|
||||
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
|
||||
log::debug!("Creating matcher for algo {:?}", options.algorithm);
|
||||
let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
|
||||
.fuzzy_algorithm(options.algorithm)
|
||||
|
|
@ -261,9 +296,12 @@ impl Matcher {
|
|||
query: &str,
|
||||
item_pool: &Arc<ItemPool>,
|
||||
thread_pool: &Arc<ThreadPool>,
|
||||
processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
|
||||
processed_items: Arc<Mutex<Option<ProcessedItems>>>,
|
||||
merge_strategy: MergeStrategy,
|
||||
no_sort: bool,
|
||||
tac: bool,
|
||||
generation: usize,
|
||||
current_generation: Arc<AtomicUsize>,
|
||||
needs_render: Arc<AtomicBool>,
|
||||
) -> MatcherControl {
|
||||
let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
|
||||
|
|
@ -336,7 +374,10 @@ impl Matcher {
|
|||
if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
|
||||
chunk_matched += 1;
|
||||
let mut rank = match_result.rank;
|
||||
let index = chunk_start + i + start;
|
||||
let batch_index = chunk_start + i;
|
||||
// `take()` reverses each tac batch, so recover the
|
||||
// item's stable ordinal in the original input stream.
|
||||
let index = input_index(tac, start, total, batch_index);
|
||||
rank.index = i32::try_from(index).unwrap_or(i32::MAX);
|
||||
local_matches.push(MatchedItem::new(
|
||||
Arc::clone(item),
|
||||
|
|
@ -383,7 +424,15 @@ impl Matcher {
|
|||
return;
|
||||
}
|
||||
|
||||
merge_worker_results(worker_results, no_sort, &processed_items, merge_strategy, &needs_render);
|
||||
merge_worker_results(
|
||||
worker_results,
|
||||
no_sort,
|
||||
&processed_items,
|
||||
merge_strategy,
|
||||
generation,
|
||||
¤t_generation,
|
||||
&needs_render,
|
||||
);
|
||||
},
|
||||
);
|
||||
stopped.store(true, Ordering::Relaxed);
|
||||
|
|
@ -451,31 +500,123 @@ mod tests {
|
|||
assert!(engine.match_item(&"foobar".to_string()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_factory_uses_configured_tiebreak() {
|
||||
let options = SkimOptionsBuilder::default()
|
||||
.regex(true)
|
||||
.tiebreak(vec![crate::RankCriteria::Length])
|
||||
.build()
|
||||
.unwrap();
|
||||
let (factory, rank_builder) = Matcher::create_engine_factory_with_builder(&options);
|
||||
let engine = factory.create_engine("a");
|
||||
let mut matches: Vec<_> = ["aaaa", "a", "aaa"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, text)| {
|
||||
let item: Arc<dyn SkimItem> = Arc::new(text.to_string());
|
||||
let mut result = engine.match_item(item.as_ref()).unwrap();
|
||||
result.rank.index = i32::try_from(index).unwrap();
|
||||
MatchedItem::new(item, result.rank, Some(result.matched_range), &rank_builder)
|
||||
})
|
||||
.collect();
|
||||
matches.sort();
|
||||
|
||||
let output: Vec<_> = matches.iter().map(|item| item.text().into_owned()).collect();
|
||||
assert_eq!(output, ["a", "aaa", "aaaa"]);
|
||||
}
|
||||
|
||||
fn merge_test_results(
|
||||
worker_results: Vec<Vec<MatchedItem>>,
|
||||
no_sort: bool,
|
||||
processed_items: &Mutex<Option<ProcessedItems>>,
|
||||
merge_strategy: MergeStrategy,
|
||||
needs_render: &AtomicBool,
|
||||
) {
|
||||
let generation = AtomicUsize::new(0);
|
||||
merge_worker_results(
|
||||
worker_results,
|
||||
no_sort,
|
||||
processed_items,
|
||||
merge_strategy,
|
||||
0,
|
||||
&generation,
|
||||
needs_render,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_worker_results_replace_sorts() {
|
||||
let processed = SpinLock::new(None);
|
||||
let processed = Mutex::new(None);
|
||||
let needs_render = AtomicBool::new(false);
|
||||
let workers = vec![vec![matched("b", 1)], vec![matched("a", 0)]];
|
||||
merge_worker_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
|
||||
merge_test_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
|
||||
|
||||
assert!(needs_render.load(Ordering::Relaxed));
|
||||
let guard = processed.lock();
|
||||
let guard = processed.lock().unwrap();
|
||||
let items = &guard.as_ref().unwrap().items;
|
||||
assert_eq!(items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_generation_cannot_publish_results() {
|
||||
let processed = Mutex::new(None);
|
||||
let needs_render = AtomicBool::new(false);
|
||||
let generation = AtomicUsize::new(2);
|
||||
|
||||
merge_worker_results(
|
||||
vec![vec![matched("stale", 0)]],
|
||||
false,
|
||||
&processed,
|
||||
MergeStrategy::Replace,
|
||||
1,
|
||||
&generation,
|
||||
&needs_render,
|
||||
);
|
||||
|
||||
assert!(processed.lock().unwrap().is_none());
|
||||
assert!(!needs_render.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_results_replace_pending_stale_generation() {
|
||||
let processed = Mutex::new(Some(ProcessedItems {
|
||||
items: vec![matched("stale", 0)],
|
||||
merge: MergeStrategy::SortedMerge,
|
||||
generation: 0,
|
||||
}));
|
||||
let needs_render = AtomicBool::new(false);
|
||||
let generation = AtomicUsize::new(1);
|
||||
|
||||
merge_worker_results(
|
||||
vec![vec![matched("current", 1)]],
|
||||
false,
|
||||
&processed,
|
||||
MergeStrategy::SortedMerge,
|
||||
1,
|
||||
&generation,
|
||||
&needs_render,
|
||||
);
|
||||
|
||||
let guard = processed.lock().unwrap();
|
||||
let result = guard.as_ref().unwrap();
|
||||
assert_eq!(result.generation, 1);
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert_eq!(result.items[0].text(), "current");
|
||||
assert!(matches!(result.merge, MergeStrategy::Replace));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_worker_results_no_sort_preserves_chunk_order() {
|
||||
let processed = SpinLock::new(None);
|
||||
let processed = Mutex::new(None);
|
||||
let needs_render = AtomicBool::new(false);
|
||||
let workers = vec![
|
||||
vec![matched("a", 0), matched("b", 1)],
|
||||
vec![matched("c", 2), matched("d", 3)],
|
||||
vec![matched("e", 4), matched("f", 5)],
|
||||
];
|
||||
merge_worker_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
|
||||
merge_test_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
|
||||
|
||||
let guard = processed.lock();
|
||||
let guard = processed.lock().unwrap();
|
||||
let items = &guard.as_ref().unwrap().items;
|
||||
let indexes: Vec<i32> = items.iter().map(|item| item.rank.index).collect();
|
||||
assert_eq!(indexes, vec![0, 1, 2, 3, 4, 5]);
|
||||
|
|
@ -483,11 +624,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn merge_worker_results_append_no_sort_extends_existing() {
|
||||
let processed = SpinLock::new(None);
|
||||
let processed = Mutex::new(None);
|
||||
let needs_render = AtomicBool::new(false);
|
||||
|
||||
// First append establishes the existing list.
|
||||
merge_worker_results(
|
||||
merge_test_results(
|
||||
vec![vec![matched("a", 0)]],
|
||||
true,
|
||||
&processed,
|
||||
|
|
@ -495,7 +636,7 @@ mod tests {
|
|||
&needs_render,
|
||||
);
|
||||
// Second append with no_sort extends the existing list in place.
|
||||
merge_worker_results(
|
||||
merge_test_results(
|
||||
vec![vec![matched("b", 1)]],
|
||||
true,
|
||||
&processed,
|
||||
|
|
@ -503,7 +644,49 @@ mod tests {
|
|||
&needs_render,
|
||||
);
|
||||
|
||||
let guard = processed.lock();
|
||||
let guard = processed.lock().unwrap();
|
||||
assert_eq!(guard.as_ref().unwrap().items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tac_input_index_spans_incremental_batches() {
|
||||
let first_batch: Vec<_> = (0..3).map(|index| input_index(true, 0, 3, index)).collect();
|
||||
let second_batch: Vec<_> = (0..2).map(|index| input_index(true, 3, 2, index)).collect();
|
||||
|
||||
assert_eq!(first_batch, [2, 1, 0]);
|
||||
assert_eq!(second_batch, [4, 3]);
|
||||
assert_eq!(input_index(false, 3, 2, 0), 3);
|
||||
assert_eq!(input_index(false, 3, 2, 1), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_worker_results_prepend_no_sort_places_new_batch_first() {
|
||||
let processed = Mutex::new(None);
|
||||
let needs_render = AtomicBool::new(false);
|
||||
|
||||
merge_test_results(
|
||||
vec![vec![matched("c", 2), matched("b", 1), matched("a", 0)]],
|
||||
true,
|
||||
&processed,
|
||||
MergeStrategy::Prepend,
|
||||
&needs_render,
|
||||
);
|
||||
merge_test_results(
|
||||
vec![vec![matched("e", 4), matched("d", 3)]],
|
||||
true,
|
||||
&processed,
|
||||
MergeStrategy::Prepend,
|
||||
&needs_render,
|
||||
);
|
||||
|
||||
let guard = processed.lock().unwrap();
|
||||
let indexes: Vec<i32> = guard
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.rank.index)
|
||||
.collect();
|
||||
assert_eq!(indexes, [4, 3, 2, 1, 0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,17 @@ use crate::tui::{BorderType, PreviewCallback};
|
|||
use crate::util::read_file_lines;
|
||||
use crate::{CaseMatching, FuzzyAlgorithm, Selector, Typos};
|
||||
|
||||
const MIN_HEIGHT_PARSE_ERROR: &str = "min-height needs to be a non-negative integer";
|
||||
|
||||
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
|
||||
s.parse().map_err(|_| MIN_HEIGHT_PARSE_ERROR.to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
fn parse_min_height_value(s: &str) -> Result<String, String> {
|
||||
parse_min_height(s).map(|_| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
/// Custom value parser for delimiter that handles escape sequences
|
||||
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
|
||||
|
|
@ -196,7 +207,13 @@ pub struct SkimOptions {
|
|||
/// See **nth** for the details
|
||||
#[cfg_attr(
|
||||
feature = "cli",
|
||||
arg(long, default_value = "", help_heading = "Search", value_delimiter = ',')
|
||||
arg(
|
||||
long,
|
||||
default_value = "",
|
||||
help_heading = "Search",
|
||||
value_delimiter = ',',
|
||||
allow_hyphen_values = true,
|
||||
)
|
||||
)]
|
||||
pub with_nth: Vec<String>,
|
||||
|
||||
|
|
@ -496,13 +513,21 @@ pub struct SkimOptions {
|
|||
#[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))]
|
||||
pub no_height: bool,
|
||||
|
||||
/// Minimum height of skim's window
|
||||
/// Minimum height of skim's window as a non-negative row count
|
||||
///
|
||||
/// Useful when the height is set as a percentage
|
||||
/// Ignored when --height is not specified
|
||||
/// Must be a non-negative row count, not a percentage.
|
||||
/// Useful when the height is set as a percentage.
|
||||
/// Ignored when --height is not specified.
|
||||
#[cfg_attr(
|
||||
feature = "cli",
|
||||
arg(long, default_value = "10", help_heading = "Layout", verbatim_doc_comment)
|
||||
arg(
|
||||
long,
|
||||
default_value = "10",
|
||||
help_heading = "Layout",
|
||||
allow_hyphen_values = true,
|
||||
value_parser = parse_min_height_value,
|
||||
verbatim_doc_comment
|
||||
)
|
||||
)]
|
||||
pub min_height: String,
|
||||
|
||||
|
|
@ -702,9 +727,9 @@ pub struct SkimOptions {
|
|||
// --- Preview ---
|
||||
/// Preview command
|
||||
///
|
||||
/// Execute the given command for the current line and display the result on the preview window. {} in the command
|
||||
/// is the placeholder that is replaced to the single-quoted string of the current line. To transform the
|
||||
/// replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
|
||||
/// Execute the given command with `sh -c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
|
||||
/// `{}` in the command is the placeholder that is replaced to the single-quoted string of the current line.
|
||||
/// To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
|
||||
///
|
||||
/// **Examples**:
|
||||
///
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//! and history initialization, which apply defaults and cross-option rules.
|
||||
|
||||
use super::*;
|
||||
use crate::field::FieldRange;
|
||||
use crate::item::RankCriteria;
|
||||
use crate::tui::statusline::InfoDisplay;
|
||||
|
||||
|
|
@ -21,6 +22,50 @@ fn merge(
|
|||
.expect("options should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_height_accepts_a_non_negative_row_count() {
|
||||
assert_eq!(super::parse_min_height("60"), Ok(60));
|
||||
assert_eq!(
|
||||
SkimOptionsBuilder::default()
|
||||
.min_height("60")
|
||||
.build()
|
||||
.unwrap()
|
||||
.min_height,
|
||||
"60"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_height_rejects_non_integers() {
|
||||
for value in ["-1", "30%", "many"] {
|
||||
assert_eq!(
|
||||
super::parse_min_height(value),
|
||||
Err("min-height needs to be a non-negative integer".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
#[test]
|
||||
fn cli_min_height_uses_custom_error() {
|
||||
for value in ["-1", "30%"] {
|
||||
let error = SkimOptions::merge_args_and_parse(
|
||||
"sk".to_string(),
|
||||
None,
|
||||
None,
|
||||
["--min-height".to_string(), value.to_string()],
|
||||
None,
|
||||
)
|
||||
.expect_err("invalid min-height must fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("min-height needs to be a non-negative integer")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_uses_skim_default_command_when_no_cmd_flag() {
|
||||
// SKIM_DEFAULT_COMMAND fills `cmd` when neither --cmd nor a pipe is given.
|
||||
|
|
@ -287,3 +332,38 @@ fn build_history_file_adds_history_keybindings() {
|
|||
|
||||
let _ = std::fs::remove_file(&qpath);
|
||||
}
|
||||
|
||||
/// Helper: parse real CLI args with no env influence.
|
||||
fn parse_args(args: &[&str]) -> Result<SkimOptions, clap::Error> {
|
||||
SkimOptions::merge_args_and_parse(
|
||||
"sk".to_string(),
|
||||
None,
|
||||
None,
|
||||
args.iter().map(|s| (*s).to_string()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_field_indices_parse_for_every_nth_flag() {
|
||||
// All three flags document the same `nth` syntax, which includes `-1` for the
|
||||
// last field; a space-separated negative value must not be read as a flag.
|
||||
for flag in ["--nth", "--with-nth", "--hide-nth"] {
|
||||
let opts = parse_args(&[flag, "-1"]).unwrap_or_else(|e| panic!("{flag} -1 failed to parse: {e}"));
|
||||
let got = match flag {
|
||||
"--nth" => &opts.nth,
|
||||
"--with-nth" => &opts.with_nth,
|
||||
_ => &opts.hide_nth,
|
||||
};
|
||||
assert_eq!(got, &vec!["-1".to_string()], "{flag}");
|
||||
assert_eq!(
|
||||
FieldRange::from_str(&got[0]),
|
||||
Some(FieldRange::Single(-1)),
|
||||
"{flag} should resolve to the last field"
|
||||
);
|
||||
}
|
||||
|
||||
// ...and a negative index inside a comma-separated list.
|
||||
let opts = parse_args(&["--with-nth", "2,-1"]).expect("--with-nth 2,-1 should parse");
|
||||
assert_eq!(opts.with_nth, vec!["2".to_string(), "-1".to_string()]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ use crate::{SkimItem, SkimItemReceiver};
|
|||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Trait for collecting items from command output
|
||||
pub trait CommandCollector {
|
||||
|
|
@ -39,6 +41,7 @@ pub struct ReaderControl {
|
|||
tx_interrupt: Sender<i32>,
|
||||
tx_interrupt_cmd: Option<Sender<i32>>,
|
||||
components_to_stop: Arc<AtomicUsize>,
|
||||
collector_handle: Option<JoinHandle<()>>,
|
||||
items: Arc<SpinLock<Vec<Arc<dyn SkimItem>>>>,
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +55,14 @@ impl ReaderControl {
|
|||
|
||||
let _ = self.tx_interrupt_cmd.clone().map(|tx| tx.send(1));
|
||||
let _ = self.tx_interrupt.send(1);
|
||||
while self.components_to_stop.load(Ordering::SeqCst) != 0 {}
|
||||
if let Some(handle) = self.collector_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
// Command collectors can own additional components outside the reader's
|
||||
// join handle. Wait without consuming a CPU while they process the signal.
|
||||
while self.components_to_stop.load(Ordering::Acquire) != 0 {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes all items collected so far
|
||||
|
|
@ -123,12 +133,14 @@ impl Reader {
|
|||
);
|
||||
|
||||
let components_to_stop_clone = components_to_stop.clone();
|
||||
let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
|
||||
let (tx_interrupt, collector_handle) =
|
||||
collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
|
||||
|
||||
ReaderControl {
|
||||
tx_interrupt,
|
||||
tx_interrupt_cmd,
|
||||
components_to_stop,
|
||||
collector_handle: Some(collector_handle),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
|
@ -149,7 +161,7 @@ impl Reader {
|
|||
);
|
||||
|
||||
let components_to_stop_clone = components_to_stop.clone();
|
||||
let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| {
|
||||
let (tx_interrupt, collector_handle) = collect_items(components_to_stop_clone, rx_item, move |items| {
|
||||
item_pool.append(items);
|
||||
});
|
||||
debug!("collect: started ({components_to_stop:?} components)");
|
||||
|
|
@ -158,6 +170,7 @@ impl Reader {
|
|||
tx_interrupt,
|
||||
tx_interrupt_cmd,
|
||||
components_to_stop,
|
||||
collector_handle: Some(collector_handle),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
|
@ -172,18 +185,19 @@ impl Default for Reader {
|
|||
}
|
||||
}
|
||||
|
||||
fn collect_items<F>(components_to_stop: Arc<AtomicUsize>, rx_item: SkimItemReceiver, callback: F) -> Sender<i32>
|
||||
fn collect_items<F>(
|
||||
components_to_stop: Arc<AtomicUsize>,
|
||||
rx_item: SkimItemReceiver,
|
||||
callback: F,
|
||||
) -> (Sender<i32>, JoinHandle<()>)
|
||||
where
|
||||
F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
|
||||
{
|
||||
let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
|
||||
|
||||
let started = Arc::new(AtomicBool::new(false));
|
||||
let started_clone = started.clone();
|
||||
std::thread::spawn(move || {
|
||||
components_to_stop.fetch_add(1, Ordering::AcqRel);
|
||||
let handle = std::thread::spawn(move || {
|
||||
debug!("collect_item start");
|
||||
components_to_stop.fetch_add(1, Ordering::SeqCst);
|
||||
started_clone.store(true, Ordering::SeqCst); // notify parent that it is started
|
||||
|
||||
loop {
|
||||
if let Ok(Some(msg)) = rx_interrupt.try_recv() {
|
||||
|
|
@ -205,15 +219,11 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
components_to_stop.fetch_sub(1, Ordering::SeqCst);
|
||||
components_to_stop.fetch_sub(1, Ordering::AcqRel);
|
||||
debug!("collect_item stop");
|
||||
});
|
||||
|
||||
while !started.load(Ordering::SeqCst) {
|
||||
// busy waiting for the thread to start. (components_to_stop is added)
|
||||
}
|
||||
|
||||
tx_interrupt
|
||||
(tx_interrupt, handle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
28
src/skim.rs
28
src/skim.rs
|
|
@ -14,6 +14,8 @@ use tokio::task::block_in_place;
|
|||
use crate::binds::SkimEvent;
|
||||
use crate::reader::{Reader, ReaderControl};
|
||||
use crate::tui::actions::Action;
|
||||
#[cfg(feature = "image")]
|
||||
use crate::tui::util::detect_image_picker;
|
||||
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
|
||||
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
|
||||
|
||||
|
|
@ -138,6 +140,8 @@ impl Skim {
|
|||
if self.app.options.no_mouse {
|
||||
tui.disable_mouse();
|
||||
}
|
||||
let min_height = crate::options::parse_min_height(&self.app.options.min_height).map_err(eyre::Report::msg)?;
|
||||
tui.min_height(min_height)?;
|
||||
self.tui = Some(tui);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -400,7 +404,7 @@ where
|
|||
if !tui.is_fullscreen {
|
||||
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;
|
||||
}
|
||||
let picker = Picker::from_query_stdio().unwrap_or_else(|err| {
|
||||
let picker = detect_image_picker().unwrap_or_else(|err| {
|
||||
warn!("failed to query terminal image protocol: {err:?}");
|
||||
Picker::halfblocks()
|
||||
});
|
||||
|
|
@ -442,6 +446,18 @@ where
|
|||
if app.options.filter.is_some() {
|
||||
trace!("filter mode: waiting for all items to be processed");
|
||||
loop {
|
||||
// `--min-query-length` short-circuits `restart_matcher`, so the pool would
|
||||
// never be drained and this loop would spin forever. There is nothing to
|
||||
// match in that case: stop as soon as the reader is done.
|
||||
if app.query_below_min_length() {
|
||||
if reader_control.is_done() {
|
||||
debug!("filter mode: query shorter than --min-query-length, no results");
|
||||
app.item_list.items.clear();
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
continue;
|
||||
}
|
||||
let matcher_stopped = app.matcher_control.stopped();
|
||||
let reader_done = reader_control.is_done();
|
||||
if matcher_stopped && reader_done && app.item_pool.num_not_taken() == 0 {
|
||||
|
|
@ -454,6 +470,7 @@ where
|
|||
.item_list
|
||||
.processed_items
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.items
|
||||
|
|
@ -505,7 +522,14 @@ where
|
|||
app.matcher_control.get_num_matched()
|
||||
);
|
||||
if app.matcher_control.get_num_matched() == min_items_before_enter - 1 {
|
||||
app.item_list.items = app.item_list.processed_items.lock().take().unwrap_or_default().items;
|
||||
app.item_list.items = app
|
||||
.item_list
|
||||
.processed_items
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.items;
|
||||
debug!("early exit, result: {:?}", app.results());
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,42 @@ fn should_enter_is_false_in_filter_mode() {
|
|||
assert_eq!(skim.app().item_list.items.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mode_terminates_when_query_is_below_min_query_length() {
|
||||
// `--min-query-length` makes `restart_matcher` a no-op, so the filter loop must
|
||||
// not wait for the item pool to drain — it used to spin forever here. Run it on a
|
||||
// worker thread so the regression surfaces as a failed assertion, not a hung test.
|
||||
let (done_tx, done_rx) = crate::prelude::unbounded::<(bool, usize)>();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut options = SkimOptions::default();
|
||||
options.filter = Some("a".to_string());
|
||||
options.min_query_length = Some(3);
|
||||
let options = options.build();
|
||||
let mut skim = started_skim_with(options, &["a", "b", "c"]);
|
||||
let entered = skim.should_enter();
|
||||
let _ = done_tx.send((entered, skim.app().item_list.items.len()));
|
||||
});
|
||||
|
||||
let (entered, matched) = done_rx
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("filter mode did not terminate with a query below --min-query-length");
|
||||
worker.join().unwrap();
|
||||
assert!(!entered);
|
||||
// The query is too short, so nothing is reported as matched.
|
||||
assert_eq!(matched, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mode_matches_when_query_meets_min_query_length() {
|
||||
let mut options = SkimOptions::default();
|
||||
options.filter = Some("abc".to_string());
|
||||
options.min_query_length = Some(3);
|
||||
let options = options.build();
|
||||
let mut skim = started_skim_with(options, &["abc", "xyz"]);
|
||||
assert!(!skim.should_enter());
|
||||
assert_eq!(skim.app().item_list.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_enter_is_false_for_select_1_single_match() {
|
||||
let mut options = SkimOptions::default();
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ pub struct SpinLockGuard<'a, T: ?Sized + 'a> {
|
|||
}
|
||||
|
||||
impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> {
|
||||
/// Creates a new guard for the given lock
|
||||
pub fn new(pool: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
|
||||
Self { __lock: pool }
|
||||
/// Creates a guard after its lock has been acquired.
|
||||
fn new(lock: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
|
||||
Self { __lock: lock }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -582,7 +582,7 @@ impl App {
|
|||
u16::try_from(u32::from(self.preview.rows) * u32::from(p) / 100).unwrap_or(u16::MAX)
|
||||
}
|
||||
};
|
||||
self.preview.scroll_y = v_scroll;
|
||||
self.preview.scroll_y = usize::from(v_scroll);
|
||||
self.preview.scroll_down(v_offset);
|
||||
|
||||
let h_scroll = match preview_position.h_scroll {
|
||||
|
|
@ -599,7 +599,7 @@ impl App {
|
|||
u16::try_from(u32::from(self.preview.cols) * u32::from(p) / 100).unwrap_or(u16::MAX)
|
||||
}
|
||||
};
|
||||
self.preview.scroll_x = h_scroll.saturating_add(h_offset);
|
||||
self.preview.scroll_x = usize::from(h_scroll).saturating_add(usize::from(h_offset));
|
||||
}
|
||||
ItemPreview::TextWithPos(t, preview_position) | ItemPreview::AnsiWithPos(t, preview_position) => self
|
||||
.preview
|
||||
|
|
@ -1348,7 +1348,7 @@ impl App {
|
|||
pub fn results(&mut self) -> Vec<MatchedItem> {
|
||||
if self.options.filter.is_some() {
|
||||
// In filter mode, drain items to avoid cloning
|
||||
self.item_list.items.drain(..).collect()
|
||||
std::mem::take(&mut self.item_list.items)
|
||||
} else if self.options.multi && !self.item_list.selection.is_empty() {
|
||||
self.item_list.selection.clone().into_iter().collect()
|
||||
} else if let Some(sel) = self.item_list.selected() {
|
||||
|
|
@ -1358,6 +1358,18 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the current query is shorter than `--min-query-length`, meaning no
|
||||
/// results should be produced yet.
|
||||
///
|
||||
/// Always false when `--min-query-length` is unset, or under `--disabled`, where
|
||||
/// the input is not used as a query.
|
||||
#[must_use]
|
||||
pub fn query_below_min_length(&self) -> bool {
|
||||
self.options
|
||||
.min_query_length
|
||||
.is_some_and(|min| !self.options.disabled && self.input.value.chars().count() < min)
|
||||
}
|
||||
|
||||
/// Restart the matcher to process items in the item pool.
|
||||
///
|
||||
/// If `force` is true, the matcher will be restarted even if it's currently running.
|
||||
|
|
@ -1366,19 +1378,19 @@ impl App {
|
|||
pub fn restart_matcher(&mut self, force: bool) {
|
||||
use crate::tui::item_list::MergeStrategy;
|
||||
// Check if query meets minimum length requirement
|
||||
if let Some(min_length) = self.options.min_query_length
|
||||
&& !self.options.disabled
|
||||
{
|
||||
let query_to_check = &self.input.value;
|
||||
|
||||
if query_to_check.chars().count() < min_length {
|
||||
// Query is too short, clear items and don't run matcher
|
||||
self.matcher_control.kill();
|
||||
self.item_list.items.clear();
|
||||
self.item_list.current = 0;
|
||||
self.item_list.offset = 0;
|
||||
return;
|
||||
}
|
||||
if self.query_below_min_length() {
|
||||
// Query is too short, clear items and don't run matcher
|
||||
self.matcher_control.kill();
|
||||
self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel);
|
||||
self.item_list
|
||||
.processed_items
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
self.item_list.items.clear();
|
||||
self.item_list.current = 0;
|
||||
self.item_list.offset = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let matcher_stopped = self.matcher_control.stopped();
|
||||
|
|
@ -1407,8 +1419,16 @@ impl App {
|
|||
self.item_pool.reset();
|
||||
}
|
||||
|
||||
let generation = if force {
|
||||
self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel) + 1
|
||||
} else {
|
||||
self.item_list.matcher_generation.load(Ordering::Acquire)
|
||||
};
|
||||
|
||||
let merge_strategy = if force {
|
||||
MergeStrategy::Replace
|
||||
} else if no_sort && self.options.tac {
|
||||
MergeStrategy::Prepend
|
||||
} else if no_sort {
|
||||
MergeStrategy::Append
|
||||
} else {
|
||||
|
|
@ -1422,6 +1442,9 @@ impl App {
|
|||
self.item_list.processed_items.clone(),
|
||||
merge_strategy,
|
||||
no_sort,
|
||||
self.options.tac,
|
||||
generation,
|
||||
self.item_list.matcher_generation.clone(),
|
||||
self.needs_render.clone(),
|
||||
);
|
||||
// A new search is in flight; arm the `result`/`zero`/`one` events to
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ where
|
|||
pub cancellation_token: CancellationToken,
|
||||
/// Whether running in fullscreen mode
|
||||
pub is_fullscreen: bool,
|
||||
/// The terminal's rect (drawing) area, set if the layout is inline
|
||||
rect: Option<Rect>,
|
||||
enable_mouse: bool,
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +89,7 @@ where
|
|||
Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
|
||||
};
|
||||
|
||||
let rect: Option<Rect>;
|
||||
let viewport = if let Some(mut height) = lines {
|
||||
// Until https://github.com/crossterm-rs/crossterm/issues/919 is fixed, we need to do it ourselves
|
||||
let cursor_pos = cursor_pos_from_tty()?;
|
||||
|
|
@ -97,13 +100,15 @@ where
|
|||
crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
|
||||
y = y.saturating_sub(to_scroll);
|
||||
}
|
||||
Viewport::Fixed(Rect::new(
|
||||
rect = Some(Rect::new(
|
||||
0,
|
||||
y,
|
||||
backend.size().expect("Failed to get terminal width").width - 1,
|
||||
height,
|
||||
))
|
||||
));
|
||||
Viewport::Fixed(rect.unwrap())
|
||||
} else {
|
||||
rect = None;
|
||||
Viewport::Fullscreen
|
||||
};
|
||||
|
||||
|
|
@ -111,6 +116,7 @@ where
|
|||
Ok(Self {
|
||||
terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })?,
|
||||
task: None,
|
||||
rect,
|
||||
event_rx: event_channel.1,
|
||||
event_tx: event_channel.0,
|
||||
tick_rate: f64::from(TICK_RATE),
|
||||
|
|
@ -381,6 +387,46 @@ where
|
|||
self.start();
|
||||
restore_result
|
||||
}
|
||||
|
||||
/// Set the minimum height of an inline viewport.
|
||||
///
|
||||
/// Scrolls the terminal when there are not enough rows below the viewport's
|
||||
/// current origin.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the terminal size cannot be read, the terminal cannot
|
||||
/// be scrolled, or the viewport cannot be resized.
|
||||
pub fn min_height(&mut self, min_height: u16) -> Result<()> {
|
||||
if self.is_fullscreen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(current_rect) = self.rect else {
|
||||
return Ok(());
|
||||
};
|
||||
let terminal_height = self.backend().size()?.height;
|
||||
let (rect, to_scroll) = rect_with_min_height(current_rect, min_height, terminal_height);
|
||||
if rect == current_rect {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if to_scroll > 0 {
|
||||
crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
|
||||
}
|
||||
debug!("min_height: resizing TUI to {rect:?}");
|
||||
self.resize(rect)?;
|
||||
self.rect = Some(rect);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_with_min_height(mut rect: Rect, min_height: u16, terminal_height: u16) -> (Rect, u16) {
|
||||
rect.height = rect.height.max(min_height).min(terminal_height);
|
||||
let lowest_origin = terminal_height.saturating_sub(rect.height);
|
||||
let to_scroll = rect.y.saturating_sub(lowest_origin);
|
||||
rect.y = rect.y.saturating_sub(to_scroll);
|
||||
(rect, to_scroll)
|
||||
}
|
||||
|
||||
impl<B: Backend> Deref for Tui<B>
|
||||
|
|
@ -476,6 +522,14 @@ mod tests {
|
|||
.expect("failed to build test TUI")
|
||||
}
|
||||
|
||||
fn inline_tui(rect: Rect) -> Tui<TestBackend> {
|
||||
let mut tui = fullscreen_tui();
|
||||
tui.is_fullscreen = false;
|
||||
tui.rect = Some(rect);
|
||||
tui.resize(rect).expect("failed to set initial viewport");
|
||||
tui
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_full_height_is_fullscreen() {
|
||||
let tui = fullscreen_tui();
|
||||
|
|
@ -507,4 +561,50 @@ mod tests {
|
|||
assert_eq!(area.width, 80);
|
||||
assert_eq!(area.height, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_height_resizes_and_scrolls_inline_terminal() {
|
||||
let mut tui = inline_tui(Rect::new(0, 20, 79, 4));
|
||||
|
||||
tui.min_height(10).expect("failed to apply minimum height");
|
||||
|
||||
assert_eq!(tui.get_frame().area(), Rect::new(0, 14, 79, 10));
|
||||
assert_eq!(tui.rect, Some(Rect::new(0, 14, 79, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_height_keeps_origin_when_rows_are_available() {
|
||||
let rect = Rect::new(0, 5, 79, 4);
|
||||
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
|
||||
|
||||
assert_eq!(rect, Rect::new(0, 5, 79, 10));
|
||||
assert_eq!(to_scroll, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_height_scrolls_to_make_room() {
|
||||
let rect = Rect::new(0, 20, 79, 4);
|
||||
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
|
||||
|
||||
assert_eq!(rect, Rect::new(0, 14, 79, 10));
|
||||
assert_eq!(to_scroll, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_height_is_limited_to_terminal_height() {
|
||||
let rect = Rect::new(0, 20, 79, 4);
|
||||
let (rect, to_scroll) = rect_with_min_height(rect, 30, 24);
|
||||
|
||||
assert_eq!(rect, Rect::new(0, 0, 79, 24));
|
||||
assert_eq!(to_scroll, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_height_is_limited_to_terminal_height() {
|
||||
let rect = Rect::new(0, 0, 79, 30);
|
||||
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
|
||||
|
||||
assert_eq!(rect, Rect::new(0, 0, 79, 24));
|
||||
assert_eq!(to_scroll, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,12 +165,9 @@ impl Input {
|
|||
}
|
||||
pub fn insert_str(&mut self, s: &str) {
|
||||
self.value.insert_str(self.cursor_pos as usize, s);
|
||||
self.move_cursor(
|
||||
s.chars()
|
||||
.count()
|
||||
.try_into()
|
||||
.expect("Failed to fit inserted str len into an i32"),
|
||||
);
|
||||
// `cursor_pos` is a byte offset (see `move_cursor_to`), so advance by the
|
||||
// inserted byte length, not the char count.
|
||||
self.move_cursor(s.len().try_into().expect("Failed to fit inserted str len into an i32"));
|
||||
}
|
||||
fn nchars(&self) -> usize {
|
||||
self.value.chars().count()
|
||||
|
|
|
|||
|
|
@ -236,3 +236,36 @@ fn input_render_writes_prompt_and_value() {
|
|||
}
|
||||
assert!(text.contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_str_leaves_cursor_at_end_for_multibyte() {
|
||||
// `cursor_pos` is a byte offset, so advancing it by char count left the cursor
|
||||
// inside the inserted text whenever a character was wider than one byte.
|
||||
let mut input = Input::default();
|
||||
input.insert_str("中文");
|
||||
assert_eq!(input.cursor_pos as usize, "中文".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_insert_str_preserves_order_for_multibyte() {
|
||||
// Bracketed paste and IMEs deliver whole strings, so a wrong cursor position
|
||||
// made the next chunk land in the middle of the previous one.
|
||||
let mut input = Input::default();
|
||||
input.insert_str("中文");
|
||||
input.insert_str("测试");
|
||||
assert_eq!(input.value, "中文测试");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_str_matches_repeated_insert_for_multibyte() {
|
||||
let mut by_str = Input::default();
|
||||
by_str.insert_str("中文");
|
||||
|
||||
let mut by_char = Input::default();
|
||||
for c in "中文".chars() {
|
||||
by_char.insert(c);
|
||||
}
|
||||
|
||||
assert_eq!(by_str.value, by_char.value);
|
||||
assert_eq!(by_str.cursor_pos, by_char.cursor_pos);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use indexmap::IndexSet;
|
||||
use ratatui::widgets::{
|
||||
|
|
@ -10,7 +11,6 @@ use regex::Regex;
|
|||
|
||||
use crate::item::MatchedItem;
|
||||
use crate::options::feature_flag;
|
||||
use crate::spinlock::SpinLock;
|
||||
use crate::theme::ColorTheme;
|
||||
use crate::tui::BorderType;
|
||||
use crate::tui::item_renderer::ItemRenderer;
|
||||
|
|
@ -28,12 +28,15 @@ pub(crate) enum MergeStrategy {
|
|||
SortedMerge,
|
||||
/// Append to existing list without sorting (for --no-sort)
|
||||
Append,
|
||||
/// Prepend to existing list without sorting (for --tac --no-sort)
|
||||
Prepend,
|
||||
}
|
||||
|
||||
/// Processed items ready for rendering
|
||||
pub(crate) struct ProcessedItems {
|
||||
pub(crate) items: Vec<MatchedItem>,
|
||||
pub(crate) merge: MergeStrategy,
|
||||
pub(crate) generation: usize,
|
||||
}
|
||||
|
||||
impl Default for ProcessedItems {
|
||||
|
|
@ -41,6 +44,7 @@ impl Default for ProcessedItems {
|
|||
Self {
|
||||
items: Vec::new(),
|
||||
merge: MergeStrategy::Replace,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +54,8 @@ impl Default for ProcessedItems {
|
|||
pub struct ItemList {
|
||||
pub(crate) items: Vec<MatchedItem>,
|
||||
pub(crate) selection: IndexSet<MatchedItem>,
|
||||
pub(crate) processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
|
||||
pub(crate) processed_items: Arc<Mutex<Option<ProcessedItems>>>,
|
||||
pub(crate) matcher_generation: Arc<AtomicUsize>,
|
||||
pub(crate) direction: ListDirection,
|
||||
pub(crate) offset: usize,
|
||||
/// How many leading sub-lines of items[offset] have been scrolled off the top.
|
||||
|
|
@ -125,6 +130,27 @@ impl ItemList {
|
|||
self.showing_stale_items = false;
|
||||
}
|
||||
|
||||
/// Prepends a batch while preserving either the head-following behavior or
|
||||
/// the item currently focused by a user who has moved away from the head.
|
||||
fn prepend(&mut self, mut items: Vec<MatchedItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let added = items.len();
|
||||
let follows_head = self.current == 0;
|
||||
items.append(&mut self.items);
|
||||
self.items = items;
|
||||
|
||||
if follows_head {
|
||||
self.offset = 0;
|
||||
self.sub_offset = 0;
|
||||
} else {
|
||||
self.current = self.current.saturating_add(added);
|
||||
self.offset = self.offset.saturating_add(added);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles the selection state of the item at the given index
|
||||
pub fn toggle_at(&mut self, index: usize) {
|
||||
if self.items.is_empty() {
|
||||
|
|
@ -397,7 +423,8 @@ impl SkimWidget for ItemList {
|
|||
(None, 0)
|
||||
};
|
||||
|
||||
let processed_items = Arc::new(SpinLock::new(None));
|
||||
let processed_items = Arc::new(Mutex::new(None));
|
||||
let matcher_generation = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let interactive = options.interactive;
|
||||
let no_clear_if_empty = options.no_clear_if_empty;
|
||||
|
|
@ -406,6 +433,7 @@ impl SkimWidget for ItemList {
|
|||
// Spawn background processing thread with the appropriate configuration
|
||||
Self {
|
||||
processed_items,
|
||||
matcher_generation,
|
||||
reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
|
||||
direction: match options.layout {
|
||||
TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
|
||||
|
|
@ -485,8 +513,17 @@ impl SkimWidget for ItemList {
|
|||
}
|
||||
let initial_current = this.selected();
|
||||
|
||||
// Check for pre-processed items from background thread (non-blocking)
|
||||
let items_updated = if let Some(processed) = this.processed_items.lock().take() {
|
||||
// Check for pre-processed items from background thread (non-blocking).
|
||||
// Bind the result separately so the lock guard is dropped before a merge
|
||||
// mutates the item list.
|
||||
let processed = this
|
||||
.processed_items
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
let current_generation = this.matcher_generation.load(Ordering::Acquire);
|
||||
let processed = processed.filter(|result| result.generation == current_generation);
|
||||
let items_updated = if let Some(processed) = processed {
|
||||
debug!("Render: Got {} processed items", processed.items.len());
|
||||
|
||||
// Check if items are empty or blank for no_clear_if_empty handling
|
||||
|
|
@ -513,6 +550,9 @@ impl SkimWidget for ItemList {
|
|||
MergeStrategy::Append => {
|
||||
this.items.extend(processed.items);
|
||||
}
|
||||
MergeStrategy::Prepend => {
|
||||
this.prepend(processed.items);
|
||||
}
|
||||
}
|
||||
this.showing_stale_items = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -240,7 +240,11 @@ fn render_list(il: &mut ItemList, w: u16, h: u16) {
|
|||
}
|
||||
|
||||
fn set_processed(il: &ItemList, items: Vec<MatchedItem>, merge: MergeStrategy) {
|
||||
*il.processed_items.lock() = Some(ProcessedItems { items, merge });
|
||||
*il.processed_items.lock().unwrap() = Some(ProcessedItems {
|
||||
items,
|
||||
merge,
|
||||
generation: il.matcher_generation.load(std::sync::atomic::Ordering::Acquire),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -253,6 +257,21 @@ fn render_applies_replace_strategy() {
|
|||
assert_eq!(il.items[0].text(), "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_discards_results_from_stale_generation() {
|
||||
let mut il = list(2);
|
||||
*il.processed_items.lock().unwrap() = Some(ProcessedItems {
|
||||
items: vec![matched("stale", 0)],
|
||||
merge: MergeStrategy::Replace,
|
||||
generation: 0,
|
||||
});
|
||||
il.matcher_generation.store(1, std::sync::atomic::Ordering::Release);
|
||||
|
||||
render_list(&mut il, 20, 5);
|
||||
assert_eq!(il.items.len(), 2);
|
||||
assert!(il.items.iter().all(|item| item.text() != "stale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_applies_append_strategy() {
|
||||
let mut il = list(2);
|
||||
|
|
@ -273,6 +292,43 @@ fn render_applies_sorted_merge_strategy() {
|
|||
assert_eq!(il.items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prepends_tac_batch_and_follows_head() {
|
||||
let mut il = ItemList::default();
|
||||
let mut base = vec![matched("c", 2), matched("b", 1), matched("a", 0)];
|
||||
il.append(&mut base);
|
||||
set_processed(&il, vec![matched("e", 4), matched("d", 3)], MergeStrategy::Prepend);
|
||||
|
||||
render_list(&mut il, 20, 5);
|
||||
|
||||
let texts: Vec<_> = il.items.iter().map(|item| item.item.text().into_owned()).collect();
|
||||
assert_eq!(texts, ["e", "d", "c", "b", "a"]);
|
||||
assert_eq!(il.current, 0);
|
||||
assert_eq!(
|
||||
il.selected().as_ref().map(|item| item.item.text().into_owned()),
|
||||
Some("e".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prepend_preserves_focus_away_from_head() {
|
||||
let mut il = ItemList::default();
|
||||
let mut base = vec![matched("c", 2), matched("b", 1), matched("a", 0)];
|
||||
il.append(&mut base);
|
||||
il.current = 1;
|
||||
il.offset = 1;
|
||||
set_processed(&il, vec![matched("e", 4), matched("d", 3)], MergeStrategy::Prepend);
|
||||
|
||||
render_list(&mut il, 20, 5);
|
||||
|
||||
assert_eq!(il.current, 3);
|
||||
assert_eq!(il.offset, 3);
|
||||
assert_eq!(
|
||||
il.selected().as_ref().map(|item| item.item.text().into_owned()),
|
||||
Some("b".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_empty_list_does_not_panic() {
|
||||
let mut il = ItemList::default();
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ use tui_term::widget::PseudoTerminal;
|
|||
|
||||
use std::env;
|
||||
use std::io::Read;
|
||||
use std::sync::{Arc, RwLock, mpsc};
|
||||
use std::process::{Child, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock, mpsc};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::statusline::spinner_char;
|
||||
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
|
||||
|
|
@ -31,6 +33,79 @@ use crate::{SkimItem, SkimOptions};
|
|||
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
|
||||
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
|
||||
const VT_SCROLLBACK: usize = 100_000;
|
||||
type PlainChild = Arc<Mutex<Option<Child>>>;
|
||||
|
||||
fn read_bounded(mut reader: impl Read) -> Vec<u8> {
|
||||
read_bounded_with_updates(&mut reader, |_| {})
|
||||
}
|
||||
|
||||
fn read_bounded_with_updates(mut reader: impl Read, mut update: impl FnMut(&[u8])) -> Vec<u8> {
|
||||
const UPDATE_INTERVAL: Duration = Duration::from_millis(16);
|
||||
|
||||
let mut output = Vec::with_capacity(PREVIEW_MAX_BYTES);
|
||||
let mut buffer = [0; 8192];
|
||||
let mut last_update = None;
|
||||
let mut published_len = 0;
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(read) => {
|
||||
let retained = PREVIEW_MAX_BYTES.saturating_sub(output.len()).min(read);
|
||||
output.extend_from_slice(&buffer[..retained]);
|
||||
|
||||
let update_due = last_update.is_none_or(|last: Instant| last.elapsed() >= UPDATE_INTERVAL);
|
||||
if retained > 0 && (update_due || output.len() == PREVIEW_MAX_BYTES) {
|
||||
update(&output);
|
||||
published_len = output.len();
|
||||
last_update = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if output.len() != published_len {
|
||||
update(&output);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn update_plain_content(content: &RwLock<PreviewContent>, cancelled: &AtomicBool, output: &[u8]) {
|
||||
let Ok(text) = output.to_vec().into_text() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut content) = content.write()
|
||||
&& !cancelled.load(Ordering::Acquire)
|
||||
{
|
||||
*content = PreviewContent::Text(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_plain_child(child: &PlainChild) {
|
||||
let Ok(mut guard) = child.lock() else {
|
||||
return;
|
||||
};
|
||||
let Some(child) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Ok(process_group) = i32::try_from(child.id()) {
|
||||
use nix::sys::signal::{Signal, killpg};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
let _ = killpg(Pid::from_raw(process_group), Signal::SIGKILL);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/PID", &child.id().to_string(), "/T", "/F"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
/// Preview content options
|
||||
pub(crate) enum PreviewContent {
|
||||
|
|
@ -81,11 +156,13 @@ pub struct Preview {
|
|||
pub cmd: String,
|
||||
pub rows: u16,
|
||||
pub cols: u16,
|
||||
pub scroll_y: u16,
|
||||
pub scroll_x: u16,
|
||||
pub scroll_y: usize,
|
||||
pub scroll_x: usize,
|
||||
pub thread_handle: Option<JoinHandle<()>>,
|
||||
/// Channel to signal thread interruption
|
||||
interrupt_tx: Option<mpsc::Sender<()>>,
|
||||
plain_child: Option<PlainChild>,
|
||||
plain_cancelled: Option<Arc<AtomicBool>>,
|
||||
pub theme: Arc<ColorTheme>,
|
||||
/// Border type
|
||||
pub border: BorderType,
|
||||
|
|
@ -97,7 +174,7 @@ pub struct Preview {
|
|||
image: bool,
|
||||
#[cfg(feature = "image")]
|
||||
image_picker: Option<Picker>,
|
||||
pub total_lines: u16,
|
||||
pub total_lines: usize,
|
||||
loading: bool,
|
||||
spinner_start: Instant,
|
||||
}
|
||||
|
|
@ -150,18 +227,12 @@ impl Preview {
|
|||
}
|
||||
|
||||
/// Convert a Size value to an actual offset based on preview dimensions
|
||||
fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> u16 {
|
||||
fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> usize {
|
||||
let dimension = if is_vertical { self.rows } else { self.cols };
|
||||
match size {
|
||||
super::Size::Fixed(n) => n,
|
||||
super::Size::Percent(p) => {
|
||||
let dimension = if is_vertical { self.rows } else { self.cols };
|
||||
// Result is at most dimension (a u16), so truncation cannot occur.
|
||||
u16::try_from(u32::from(dimension) * u32::from(p) / 100).unwrap_or(u16::MAX)
|
||||
}
|
||||
super::Size::Neg(n) => {
|
||||
let dimension = if is_vertical { self.rows } else { self.cols };
|
||||
dimension.saturating_sub(n)
|
||||
}
|
||||
super::Size::Fixed(n) => usize::from(n),
|
||||
super::Size::Percent(p) => usize::from(dimension) * usize::from(p) / 100,
|
||||
super::Size::Neg(n) => usize::from(dimension.saturating_sub(n)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +297,7 @@ impl Preview {
|
|||
let Ok(mut content) = self.content.write() else {
|
||||
return Err(eyre::eyre!("Failed to acquire content for writing"));
|
||||
};
|
||||
self.total_lines = text.lines.len().try_into().unwrap();
|
||||
self.total_lines = text.lines.len();
|
||||
*content = PreviewContent::Text(text);
|
||||
self.scroll_y = 0;
|
||||
self.scroll_x = 0;
|
||||
|
|
@ -256,7 +327,7 @@ impl Preview {
|
|||
}
|
||||
|
||||
pub fn scroll_up(&mut self, lines: u16) {
|
||||
self.scroll_y = self.scroll_y.saturating_sub(lines);
|
||||
self.scroll_y = self.scroll_y.saturating_sub(usize::from(lines));
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self, lines: u16) {
|
||||
|
|
@ -265,26 +336,26 @@ impl Preview {
|
|||
self.total_lines, self.rows
|
||||
);
|
||||
if self.total_lines > 0 {
|
||||
self.scroll_y = self
|
||||
.scroll_y
|
||||
.saturating_add(lines)
|
||||
.min(self.total_lines.saturating_sub(self.rows.saturating_sub(1)));
|
||||
self.scroll_y = self.scroll_y.saturating_add(usize::from(lines)).min(
|
||||
self.total_lines
|
||||
.saturating_sub(usize::from(self.rows.saturating_sub(1))),
|
||||
);
|
||||
} else {
|
||||
// We might not have the actual total_lines value
|
||||
self.scroll_y = self.scroll_y.saturating_add(lines);
|
||||
self.scroll_y = self.scroll_y.saturating_add(usize::from(lines));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_left(&mut self, cols: u16) {
|
||||
self.scroll_x = self.scroll_x.saturating_sub(cols);
|
||||
self.scroll_x = self.scroll_x.saturating_sub(usize::from(cols));
|
||||
}
|
||||
|
||||
pub fn scroll_right(&mut self, cols: u16) {
|
||||
self.scroll_x = self.scroll_x.saturating_add(cols);
|
||||
self.scroll_x = self.scroll_x.saturating_add(usize::from(cols));
|
||||
}
|
||||
|
||||
pub fn set_offset(&mut self, offset: u16) {
|
||||
self.scroll_y = offset.saturating_sub(1); // -1 because line numbers are 1-indexed
|
||||
self.scroll_y = usize::from(offset.saturating_sub(1)); // -1 because line numbers are 1-indexed
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
|
|
@ -298,10 +369,19 @@ impl Preview {
|
|||
}
|
||||
/// Kill the preview child process and interrupt the reader thread.
|
||||
pub fn kill(&mut self) {
|
||||
if let Some(cancelled) = self.plain_cancelled.take() {
|
||||
cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
if let Some(tx) = self.interrupt_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
if let Some(child) = self.plain_child.take() {
|
||||
trace!("killing plain preview child process group");
|
||||
terminate_plain_child(&child);
|
||||
}
|
||||
|
||||
if let Some(mut child) = self.pty_child.take() {
|
||||
trace!("killing pty child process");
|
||||
match child.try_wait() {
|
||||
|
|
@ -490,43 +570,102 @@ impl Preview {
|
|||
shell_cmd
|
||||
.env("ROWS", self.rows.to_string())
|
||||
.env("COLUMNS", self.cols.to_string())
|
||||
.env("PAGER", "");
|
||||
.env("PAGER", "")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Ok(cwd) = env::current_dir() {
|
||||
shell_cmd.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
shell_cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
shell_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
||||
}
|
||||
|
||||
let (interrupt_tx, interrupt_rx) = mpsc::channel();
|
||||
self.interrupt_tx = Some(interrupt_tx);
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
self.plain_cancelled = Some(cancelled.clone());
|
||||
|
||||
let mut child = match shell_cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
log::info!("Shell cmd in error: {error:?}");
|
||||
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let stdout = child.stdout.take().expect("stdout was configured as piped");
|
||||
let stderr = child.stderr.take().expect("stderr was configured as piped");
|
||||
let child = Arc::new(Mutex::new(Some(child)));
|
||||
self.plain_child = Some(child.clone());
|
||||
|
||||
self.thread_handle = Some(std::thread::spawn(move || {
|
||||
if interrupt_rx.try_recv().is_ok() {
|
||||
return;
|
||||
}
|
||||
let streaming_content = content.clone();
|
||||
let streaming_cancelled = cancelled.clone();
|
||||
let stdout_reader = std::thread::spawn(move || {
|
||||
read_bounded_with_updates(stdout, |output| {
|
||||
update_plain_content(&streaming_content, &streaming_cancelled, output);
|
||||
})
|
||||
});
|
||||
let stderr_reader = std::thread::spawn(move || read_bounded(stderr));
|
||||
|
||||
let try_out = shell_cmd.output();
|
||||
if try_out.is_err() {
|
||||
log::info!("Shell cmd in error: {try_out:?}");
|
||||
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut out = try_out.unwrap();
|
||||
|
||||
if interrupt_rx.try_recv().is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(mut c) = content.write() {
|
||||
if out.status.success() {
|
||||
out.stdout.resize(PREVIEW_MAX_BYTES.min(out.stdout.len()), 0);
|
||||
*c = PreviewContent::Text(out.stdout.into_text().unwrap_or_default());
|
||||
} else {
|
||||
*c = PreviewContent::Text(out.stderr.clone().into_text().unwrap_or_default());
|
||||
let status = loop {
|
||||
match interrupt_rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
terminate_plain_child(&child);
|
||||
break None;
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
|
||||
let wait_result = match child.lock() {
|
||||
Ok(mut guard) => guard.as_mut().map(Child::try_wait),
|
||||
Err(_) => break None,
|
||||
};
|
||||
match wait_result {
|
||||
Some(Ok(Some(status))) => break Some(status),
|
||||
Some(Ok(None)) => {}
|
||||
Some(Err(error)) => {
|
||||
log::info!("Failed to wait for preview command: {error:?}");
|
||||
break None;
|
||||
}
|
||||
None => break None,
|
||||
}
|
||||
};
|
||||
|
||||
// A shell can exit while a background descendant still owns the pipes.
|
||||
// Terminate the whole process group before joining the drain threads.
|
||||
terminate_plain_child(&child);
|
||||
let stdout = stdout_reader.join().unwrap_or_default();
|
||||
let stderr = stderr_reader.join().unwrap_or_default();
|
||||
if let Ok(mut guard) = child.lock()
|
||||
&& let Some(mut child) = guard.take()
|
||||
&& status.is_none()
|
||||
{
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
trace!("sending ready ping");
|
||||
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
|
||||
let Some(status) = status else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut c) = content.write()
|
||||
&& !cancelled.load(Ordering::Acquire)
|
||||
{
|
||||
let output = if status.success() { stdout } else { stderr };
|
||||
*c = PreviewContent::Text(output.into_text().unwrap_or_default());
|
||||
}
|
||||
|
||||
if !cancelled.load(Ordering::Acquire) {
|
||||
trace!("sending ready ping");
|
||||
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
|
||||
}
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -538,12 +677,14 @@ impl Preview {
|
|||
area: ratatui::layout::Rect,
|
||||
buf: &mut ratatui::prelude::Buffer,
|
||||
text: &Text,
|
||||
) -> u16 {
|
||||
) -> usize {
|
||||
// Calculate total lines in content
|
||||
let total_lines: u16 = text.lines.len().try_into().unwrap();
|
||||
let total_lines = text.lines.len();
|
||||
|
||||
// Create paragraph with optional block
|
||||
let mut paragraph = Paragraph::new(text.clone()).scroll((self.scroll_y, self.scroll_x));
|
||||
// Ratatui terminal coordinates are u16. Saturate previews that exceed that range.
|
||||
let scroll_y = u16::try_from(self.scroll_y).unwrap_or(u16::MAX);
|
||||
let scroll_x = u16::try_from(self.scroll_x).unwrap_or(u16::MAX);
|
||||
let mut paragraph = Paragraph::new(text.clone()).scroll((scroll_y, scroll_x));
|
||||
|
||||
// Enable wrapping if wrap is true
|
||||
if self.wrap {
|
||||
|
|
@ -552,7 +693,7 @@ impl Preview {
|
|||
|
||||
// Add scroll position indicator at top-right if scrolled
|
||||
if self.scroll_y > 0 && total_lines > 0 {
|
||||
let current_line = (self.scroll_y + 1) as usize; // +1 because scroll_y is 0-indexed but we want 1-indexed display
|
||||
let current_line = self.scroll_y.saturating_add(1); // Display line numbers are 1-indexed.
|
||||
let title = format!("{current_line}/{total_lines}");
|
||||
|
||||
outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
|
||||
|
|
@ -569,23 +710,21 @@ impl Preview {
|
|||
area: ratatui::layout::Rect,
|
||||
buf: &mut ratatui::prelude::Buffer,
|
||||
parser: &std::sync::RwLock<tui_term::vt100::Parser>,
|
||||
) -> u16 {
|
||||
let mut total_lines = 0u16;
|
||||
) -> usize {
|
||||
let mut total_lines = 0usize;
|
||||
// For terminal content, manipulate scrollback to implement scrolling
|
||||
if let Ok(mut parser_guard) = parser.try_write() {
|
||||
let scrollback_len = parser_guard.screen().scrollback();
|
||||
// Reset scrollback to its full size first
|
||||
parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
|
||||
// If the scrollback is not empty, we seem to be off by one
|
||||
total_lines = (scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count())
|
||||
.try_into()
|
||||
.unwrap();
|
||||
total_lines = scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count();
|
||||
if self.scroll_y > 0 {
|
||||
trace!("scrolling in vt buffer: {}/{}", self.scroll_y, total_lines);
|
||||
// Reduce scrollback by scroll_y to show earlier content
|
||||
parser_guard
|
||||
.screen_mut()
|
||||
.set_scrollback(scrollback_len.saturating_sub(self.scroll_y.into()));
|
||||
.set_scrollback(scrollback_len.saturating_sub(self.scroll_y));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -674,6 +813,8 @@ impl SkimWidget for Preview {
|
|||
scroll_x: 0,
|
||||
thread_handle: None,
|
||||
interrupt_tx: None,
|
||||
plain_child: None,
|
||||
plain_cancelled: None,
|
||||
pty: None,
|
||||
pty_child: None,
|
||||
#[cfg(feature = "image")]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use ratatui::layout::Size;
|
|||
#[cfg(feature = "image")]
|
||||
use ratatui_image::picker::Picker;
|
||||
|
||||
use super::Preview;
|
||||
use super::{PREVIEW_MAX_BYTES, Preview, PreviewContent, read_bounded, update_plain_content};
|
||||
|
||||
#[cfg(feature = "image")]
|
||||
fn image(width: u32, height: u32) -> DynamicImage {
|
||||
|
|
@ -60,6 +60,130 @@ fn content_loads_text_and_resets_scroll() {
|
|||
assert!(!p.is_loading());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_text_content_does_not_overflow_line_count() {
|
||||
let input = "x\n".repeat(70_000);
|
||||
let mut preview = Preview::default();
|
||||
preview.content(input.as_bytes()).unwrap();
|
||||
assert_eq!(preview.total_lines, 70_000);
|
||||
|
||||
let content = preview.content.read().unwrap();
|
||||
let PreviewContent::Text(text) = &*content else {
|
||||
panic!("expected text preview");
|
||||
};
|
||||
let area = ratatui::layout::Rect::new(0, 0, 20, 5);
|
||||
let mut buffer = ratatui::buffer::Buffer::empty(area);
|
||||
assert_eq!(
|
||||
preview.render_text(ratatui::widgets::Block::new(), area, &mut buffer, text),
|
||||
70_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_reader_discards_output_after_limit() {
|
||||
let input = vec![b'x'; PREVIEW_MAX_BYTES + 4096];
|
||||
let output = read_bounded(std::io::Cursor::new(input));
|
||||
assert_eq!(output.len(), PREVIEW_MAX_BYTES);
|
||||
}
|
||||
|
||||
fn preview_contains(preview: &Preview, expected: &str) -> bool {
|
||||
preview.content.read().is_ok_and(|content| match &*content {
|
||||
PreviewContent::Text(text) => text
|
||||
.lines
|
||||
.iter()
|
||||
.any(|line| line.spans.iter().any(|span| span.content.as_ref().contains(expected))),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn plain_preview_streams_before_command_exits() {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let mut preview = Preview::default();
|
||||
preview.pty = None;
|
||||
let mut tui =
|
||||
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
|
||||
.unwrap();
|
||||
preview.spawn(&mut tui, "printf streamed; sleep 30").unwrap();
|
||||
|
||||
let started = Instant::now();
|
||||
let streamed_in_time = loop {
|
||||
if preview_contains(&preview, "streamed") {
|
||||
break true;
|
||||
}
|
||||
if started.elapsed() >= Duration::from_secs(2) {
|
||||
break false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
preview.kill();
|
||||
preview.thread_handle.take().unwrap().join().unwrap();
|
||||
assert!(streamed_in_time, "preview output did not stream");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stale_plain_preview_cannot_replace_newer_streamed_output() {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let mut preview = Preview::default();
|
||||
preview.pty = None;
|
||||
let mut tui =
|
||||
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
|
||||
.unwrap();
|
||||
|
||||
preview.spawn(&mut tui, "printf stale; sleep 30").unwrap();
|
||||
let stale_cancelled = preview.plain_cancelled.as_ref().unwrap().clone();
|
||||
let stale_thread = preview.thread_handle.take().unwrap();
|
||||
preview.spawn(&mut tui, "printf current; sleep 30").unwrap();
|
||||
|
||||
let started = Instant::now();
|
||||
let current_streamed = loop {
|
||||
if preview_contains(&preview, "current") {
|
||||
break true;
|
||||
}
|
||||
if started.elapsed() >= Duration::from_secs(2) {
|
||||
break false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
update_plain_content(&preview.content, &stale_cancelled, b"stale");
|
||||
let stale_write_was_ignored = preview_contains(&preview, "current") && !preview_contains(&preview, "stale");
|
||||
|
||||
preview.kill();
|
||||
preview.thread_handle.take().unwrap().join().unwrap();
|
||||
stale_thread.join().unwrap();
|
||||
assert!(current_streamed, "new preview output did not stream");
|
||||
assert!(stale_write_was_ignored, "stale preview replaced newer output");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn plain_preview_can_be_cancelled() {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let mut preview = Preview::default();
|
||||
preview.pty = None;
|
||||
let mut tui =
|
||||
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
|
||||
.unwrap();
|
||||
preview.spawn(&mut tui, "sleep 30").unwrap();
|
||||
|
||||
let started = Instant::now();
|
||||
preview.kill();
|
||||
preview.thread_handle.take().unwrap().join().unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_scroll_clamps_to_content() {
|
||||
let mut p = Preview::default();
|
||||
|
|
|
|||
117
src/tui/util.rs
117
src/tui/util.rs
|
|
@ -251,6 +251,123 @@ impl Drop for RawMode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Detect the terminal's image protocol and cell size through its controlling TTY.
|
||||
///
|
||||
/// Unlike `Picker::from_query_stdio`, this does not read from standard input, which may be the
|
||||
/// item stream when skim is used in a pipeline. The caller must have put the terminal in raw mode.
|
||||
#[cfg(all(feature = "image", unix))]
|
||||
pub(crate) fn detect_image_picker() -> eyre::Result<ratatui_image::picker::Picker> {
|
||||
use std::env;
|
||||
use std::os::fd::AsRawFd as _;
|
||||
use std::time::Instant;
|
||||
|
||||
use eyre::eyre;
|
||||
use nix::sys::time::{suseconds_t, time_t};
|
||||
use ratatui_image::FontSize;
|
||||
use ratatui_image::picker::cap_parser::{Parser, QueryStdioOptions, Response};
|
||||
use ratatui_image::picker::{Picker, ProtocolType};
|
||||
|
||||
let mut tty = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits())
|
||||
.open("/dev/tty")?;
|
||||
|
||||
let mut options = QueryStdioOptions::default();
|
||||
let is_wezterm = env::var("WEZTERM_EXECUTABLE").is_ok_and(|value| !value.is_empty());
|
||||
let is_konsole = env::var("KONSOLE_VERSION").is_ok_and(|value| !value.is_empty());
|
||||
if is_wezterm || is_konsole {
|
||||
options.blacklist_protocols = vec![ProtocolType::Kitty, ProtocolType::Sixel];
|
||||
}
|
||||
|
||||
let timeout = options.timeout;
|
||||
let is_tmux = env::var("TMUX").is_ok_and(|value| !value.is_empty());
|
||||
tty.write_all(Parser::query(is_tmux, options).as_bytes())?;
|
||||
tty.flush()?;
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut parser = Parser::new();
|
||||
let mut responses = Vec::new();
|
||||
|
||||
'query: loop {
|
||||
let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
|
||||
return Err(eyre!("terminal image protocol detection timed out"));
|
||||
};
|
||||
let micros = i32::try_from(remaining.as_micros()).unwrap_or(i32::MAX);
|
||||
let mut select_timeout =
|
||||
nix::sys::time::TimeVal::new(time_t::from(micros / 1_000_000), suseconds_t::from(micros % 1_000_000));
|
||||
let mut rfds = nix::sys::select::FdSet::new();
|
||||
rfds.insert(tty.as_fd());
|
||||
|
||||
match nix::sys::select::select(
|
||||
rfds.highest().unwrap().as_raw_fd() + 1,
|
||||
Some(&mut rfds),
|
||||
None,
|
||||
None,
|
||||
Some(&mut select_timeout),
|
||||
) {
|
||||
Ok(0) => return Err(eyre!("terminal image protocol detection timed out")),
|
||||
Ok(_) => {
|
||||
let mut buf = [0; 128];
|
||||
match tty.read(&mut buf) {
|
||||
Ok(0) => return Err(eyre!("controlling terminal closed during image protocol detection")),
|
||||
Ok(read) => {
|
||||
for byte in &buf[..read] {
|
||||
for response in parser.push(char::from(*byte)) {
|
||||
if response == Response::Status {
|
||||
break 'query;
|
||||
}
|
||||
responses.push(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
Err(nix::errno::Errno::EINTR) => {}
|
||||
Err(err) => return Err(io::Error::from_raw_os_error(err as i32).into()),
|
||||
}
|
||||
}
|
||||
|
||||
let mut protocol = None;
|
||||
let mut font_size = None;
|
||||
for response in responses {
|
||||
match response {
|
||||
Response::Kitty => protocol = Some(ProtocolType::Kitty),
|
||||
Response::Sixel if protocol.is_none() => protocol = Some(ProtocolType::Sixel),
|
||||
Response::CellSize(Some((width, height))) => font_size = Some(FontSize::new(width, height)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let font_size = font_size.or_else(|| {
|
||||
let size = crossterm::terminal::window_size().ok()?;
|
||||
if size.width == 0 || size.height == 0 || size.columns == 0 || size.rows == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(FontSize::new(size.width / size.columns, size.height / size.rows))
|
||||
});
|
||||
let Some(font_size) = font_size else {
|
||||
return Ok(Picker::halfblocks());
|
||||
};
|
||||
|
||||
// This deprecated constructor is currently the only public way to set the font size while also
|
||||
// initializing ratatui-image's private tmux state.
|
||||
#[allow(deprecated)]
|
||||
let mut picker = Picker::from_fontsize(font_size);
|
||||
if let Some(protocol) = protocol {
|
||||
picker.set_protocol_type(protocol);
|
||||
}
|
||||
Ok(picker)
|
||||
}
|
||||
|
||||
/// Detect the image protocol through standard I/O on Windows.
|
||||
#[cfg(all(feature = "image", windows))]
|
||||
pub(crate) fn detect_image_picker() -> eyre::Result<ratatui_image::picker::Picker> {
|
||||
Ok(ratatui_image::picker::Picker::from_query_stdio()?)
|
||||
}
|
||||
|
||||
/// Get cursor position, 1-based
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn cursor_pos_from_tty() -> io::Result<(u16, u16)> {
|
||||
|
|
|
|||
73
tests/cli.rs
73
tests/cli.rs
|
|
@ -110,6 +110,28 @@ fn filter_mode_with_print0() {
|
|||
assert!(stdout.contains('\0'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mode_inverse_query_checks_every_nth_field() {
|
||||
// `--nth 1,2` gives each item two matching ranges. An inverse query must
|
||||
// reject an item when ANY of them contains the term, not just the first one.
|
||||
let (code, stdout, _) = run_sk_argv("foo bar\nqux bar\n", &["-f", "!foo", "--nth", "1,2"], &[]);
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(
|
||||
!stdout.contains("foo bar"),
|
||||
"!foo must exclude 'foo bar' (got {stdout:?})"
|
||||
);
|
||||
assert!(stdout.contains("qux bar"), "!foo must keep 'qux bar' (got {stdout:?})");
|
||||
|
||||
// The term sitting in the second field must be caught too.
|
||||
let (code, stdout, _) = run_sk_argv("bar foo\nbar qux\n", &["-f", "!foo", "--nth", "1,2"], &[]);
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(
|
||||
!stdout.contains("bar foo"),
|
||||
"!foo must exclude 'bar foo' (got {stdout:?})"
|
||||
);
|
||||
assert!(stdout.contains("bar qux"), "!foo must keep 'bar qux' (got {stdout:?})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mode_no_sort_preserves_input_order() {
|
||||
// Workers grab 4096-item chunks from a shared queue, so with enough items
|
||||
|
|
@ -128,6 +150,57 @@ fn filter_mode_no_sort_preserves_input_order() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_nth_accepts_space_separated_negative_index() {
|
||||
// `--with-nth -1` (space form) used to be parsed as a missing value, while
|
||||
// `--nth -1` and `--with-nth=-1` both worked.
|
||||
let (code, stdout, stderr) = run_sk_argv("a b c", &["-f", "c", "--with-nth", "-1"], &[]);
|
||||
assert_eq!(code, Some(0), "stderr: {stderr}");
|
||||
assert_eq!(stdout.trim_end(), "a b c");
|
||||
|
||||
// The space form and the `=` form must agree.
|
||||
let (code_eq, stdout_eq, _) = run_sk_argv("a b c", &["-f", "c", "--with-nth=-1"], &[]);
|
||||
assert_eq!((code, stdout), (code_eq, stdout_eq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nth_index_past_i32_does_not_fall_back_to_field_1() {
|
||||
// An index too large for i32 used to fail to parse and silently become field 1,
|
||||
// so `--nth <huge>` matched the first field instead of matching nothing.
|
||||
let (code_huge, out_huge, _) = run_sk_argv("a b c", &["-f", "a", "--nth", "2147483648"], &[]);
|
||||
let (code_oob, out_oob, _) = run_sk_argv("a b c", &["-f", "a", "--nth", "5"], &[]);
|
||||
assert_eq!((code_huge, out_huge.as_str()), (code_oob, out_oob.as_str()));
|
||||
assert_eq!(code_huge, Some(1), "an out-of-range field must match nothing");
|
||||
assert!(out_huge.is_empty());
|
||||
|
||||
// Same for the `{N}` field syntax in --output-format. Assert the exit status and
|
||||
// stderr too, so an empty stdout can't pass by way of the placeholder erroring out.
|
||||
for placeholder in ["{2147483648}", "{-2147483649}"] {
|
||||
let (code, out, err) = run_sk_argv("a b c", &["-1", "-q", "a", "--output-format", placeholder], &[]);
|
||||
assert_eq!(code, Some(0), "{placeholder}: stderr: {err}");
|
||||
assert_eq!(err, "", "{placeholder} should not error");
|
||||
assert_eq!(out.trim_end(), "", "{placeholder} should render an empty field");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathname_tiebreak_is_not_broken_by_a_non_ascii_directory() {
|
||||
// `path_name_offset` used to be a byte offset while `Rank::begin` is a char
|
||||
// index, so a multi-byte directory component inflated the PathName score and
|
||||
// pushed the filename match below the directory match.
|
||||
let (code, stdout, stderr) = run_sk_argv("ééééé/a\na/xxxxx\n", &["-f", "a", "--scheme", "path"], &[]);
|
||||
assert_eq!(code, Some(0), "stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout.lines().next(),
|
||||
Some("ééééé/a"),
|
||||
"the filename match must rank first, got: {stdout:?}"
|
||||
);
|
||||
|
||||
// The all-ASCII shape of the same input already ranked correctly; both must agree.
|
||||
let (_, ascii_stdout, _) = run_sk_argv("eeeee/a\na/xxxxx\n", &["-f", "a", "--scheme", "path"], &[]);
|
||||
assert_eq!(ascii_stdout.lines().next(), Some("eeeee/a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_1_with_output_format() {
|
||||
// --output-format renders the selected item through the printf branch.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
#[allow(dead_code)]
|
||||
#[macro_use]
|
||||
mod common;
|
||||
use std::io::Cursor;
|
||||
|
||||
use skim::prelude::*;
|
||||
|
||||
use common::zellij::Keys::*;
|
||||
|
||||
sk_test!(sk_version_long, "", &["--version"], {
|
||||
|
|
@ -38,3 +42,69 @@ sk_test!(issue_1120_height_mode_clears_on_exit, @cmd "seq 1 10", &["--height=50%
|
|||
@keys Key('\x1b');
|
||||
@lines |l| (!l.iter().any(|line| line.starts_with(">")));
|
||||
});
|
||||
|
||||
sk_test!(min_height_grows_inline_viewport, @cmd "for i in {1..20}; do echo min-height-item-$i; done", &["--height=20%", "--min-height=10"], {
|
||||
@lines |l| (l.iter().map(|line| line.matches("min-height-item-").count()).sum::<usize>() >= 7);
|
||||
@keys Escape;
|
||||
});
|
||||
|
||||
#[test]
|
||||
fn library_builder_min_height_child() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if std::env::var_os("SKIM_MIN_HEIGHT_BUILDER_CHILD").is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let options = SkimOptionsBuilder::default().height("20%").min_height("10").build()?;
|
||||
let items = SkimItemReader::default().of_bufread(Cursor::new(
|
||||
(1..=20)
|
||||
.map(|i| format!("builder-min-height-item-{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
));
|
||||
Skim::run_with(options, Some(items))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_builder_min_height_resizes_and_scrolls() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let zellij = common::zellij::ZellijController::new_named("builderminheight")?;
|
||||
zellij.send_keys(&[Str("printf '\\n%.0s' {1..22}"), Enter])?;
|
||||
zellij.until(|lines| lines.first().is_some_and(|line| line.starts_with("skim$")))?;
|
||||
|
||||
let test_binary = std::env::current_exe()?.to_string_lossy().replace('\\', "/");
|
||||
let test_binary = format!("'{}'", test_binary.replace('\'', "'\\''"));
|
||||
let command =
|
||||
format!("SKIM_MIN_HEIGHT_BUILDER_CHILD=1 {test_binary} --exact library_builder_min_height_child --nocapture");
|
||||
zellij.send_keys(&[Str(&command), Enter])?;
|
||||
zellij.until(|lines| {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| line.matches("builder-min-height-item-").count())
|
||||
.sum::<usize>()
|
||||
>= 7
|
||||
})?;
|
||||
zellij.send_keys(&[Escape])?;
|
||||
zellij.until(|lines| lines.iter().any(|line| line.contains("test result: ok")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_height_scrolls_when_cursor_is_near_terminal_bottom() -> std::io::Result<()> {
|
||||
let mut zellij = common::zellij::ZellijController::new_named("minheightscroll")?;
|
||||
zellij.send_keys(&[Str("printf '\\n%.0s' {1..22}"), Enter])?;
|
||||
zellij.until(|lines| lines.first().is_some_and(|line| line.starts_with("skim$")))?;
|
||||
|
||||
zellij.start_sk(
|
||||
Some("for i in {1..20}; do echo min-height-scroll-item-$i; done"),
|
||||
&["--height=20%", "--min-height=10"],
|
||||
)?;
|
||||
zellij.until(|lines| {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| line.matches("min-height-scroll-item-").count())
|
||||
.sum::<usize>()
|
||||
>= 7
|
||||
})?;
|
||||
zellij.send_keys(&[Escape])?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -848,7 +848,7 @@ fn opt_multiple_flags_parse() {
|
|||
"-I {} -I XX",
|
||||
"--color base --color light",
|
||||
"--margin 30% --margin 0",
|
||||
"--min-height 30% --min-height 10",
|
||||
"--min-height 30 --min-height 10",
|
||||
"--preview 'ls {}' --preview 'cat {}'",
|
||||
"--preview-window up --preview-window down",
|
||||
"--multi -m",
|
||||
|
|
|
|||
|
|
@ -139,9 +139,10 @@ fn tmux_quote() -> Result<()> {
|
|||
tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?;
|
||||
tmux.until(|_| Path::new(&outfile).exists())?;
|
||||
let cmd = get_tmux_cmd(&outfile)?;
|
||||
println!("tmux cmd: {cmd}");
|
||||
assert!(cmd.starts_with("display-popup"));
|
||||
assert!(cmd.contains("-E"));
|
||||
assert!(cmd.contains("--bind ctrl-a':reload(ls /foo*)'"));
|
||||
assert!(cmd.contains("--bind ctrl-a:reload'(ls /foo*)'"));
|
||||
assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;"));
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
Loading…
Reference in a new issue