mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
feat: use a separate thread pool for Matcher runs (#961)
* Initial commit * Disable Matcher when query is empty * Revert * Cleanup * Use par_chunks for faster search * Cleanup * Skip updating atomic on every iter * Item index unnecessary? * Cleanup * Build thread pool at App level * Initial commit * Disable Matcher when query is empty * Revert * Cleanup * Use par_chunks for faster search * Cleanup * Skip updating atomic on every iter * Item index unnecessary? * Cleanup * Build thread pool at App level * Make suggested improvements * chore: cleanup after rebase * refactor: rewrite insta test harness to use fine-grained Skim:: methods Split `impl Skim` into a generic `impl<Backend> Skim<Backend>` block so that `Skim::init()`, `Skim::start()`, `Skim::tick()`, etc. work with any backend, not just the default CrosstermBackend. New public API on Skim<B>: - `init_tui_with(tui)` – inject a caller-provided TUI (e.g. TestBackend) - `app()` / `app_mut()` – access the application state - `tui_ref()` / `tui_mut()` – access the TUI - `app_and_tui()` – simultaneous mutable access to both (avoids borrow conflicts in render and handle_event calls) - `final_event()` – inspect the quit event TestHarness now wraps `Skim<TestBackend>` and initializes via `Skim::init()` + `Skim::init_tui_with()`, sharing the production init path (theme, reader, command expansion) instead of duplicating it. https://claude.ai/code/session_016PtHKc9YVEpHftDxG5Nger * chore: make insta harness more realistic * Cleanup merge errors * Remove duplicate check * Cleanup * No need to clone twice * fix: fix thread pool race condition --------- Co-authored-by: Loric ANDRE <loric.andre@pm.me> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
19bfd34aaf
commit
ad91558749
135
src/lib.rs
135
src/lib.rs
|
|
@ -393,6 +393,18 @@ impl Skim {
|
|||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Initialize the TUI with the default crossterm backend, but do not enter it yet
|
||||
pub fn init_tui(&mut self) -> Result<()> {
|
||||
self.tui = Some(tui::Tui::new_with_height(self.height)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<Backend: ratatui::backend::Backend + 'static> Skim<Backend>
|
||||
where
|
||||
Backend::Error: Send + Sync + 'static,
|
||||
{
|
||||
/// Initialize skim, without starting anything yet
|
||||
pub fn init(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<Self> {
|
||||
let height = Size::try_from(options.height.as_str())?;
|
||||
|
|
@ -446,10 +458,104 @@ impl Skim {
|
|||
self.app.restart_matcher(true);
|
||||
}
|
||||
|
||||
/// Initialize the TUI, but do not enter it yet
|
||||
pub fn init_tui(&mut self) -> Result<()> {
|
||||
self.tui = Some(tui::Tui::new_with_height(self.height)?);
|
||||
Ok(())
|
||||
/// Handle a reload event by killing the current reader, clearing items, and starting a new reader.
|
||||
///
|
||||
/// This encapsulates the reload logic from the main event loop so it can
|
||||
/// be reused by test harnesses without reimplementing it.
|
||||
pub fn handle_reload(&mut self, new_cmd: &str) {
|
||||
debug!("reloading with cmd {new_cmd}");
|
||||
// Kill the current reader
|
||||
if let Some(rc) = self.reader_control.as_mut() {
|
||||
rc.kill()
|
||||
}
|
||||
// Clear items
|
||||
self.app.item_pool.clear();
|
||||
// Clear displayed items unless no_clear_if_empty is set
|
||||
if !self.app.options.no_clear_if_empty {
|
||||
self.app.item_list.clear();
|
||||
}
|
||||
self.app.restart_matcher(true);
|
||||
// Start a new reader with the new command
|
||||
self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
|
||||
self.reader_done = false;
|
||||
}
|
||||
|
||||
/// Check if the reader has finished and restart the matcher if needed.
|
||||
///
|
||||
/// This encapsulates the reader-status check from the main event loop
|
||||
/// so it can be reused by test harnesses.
|
||||
///
|
||||
/// Returns `true` if the reader has completed.
|
||||
pub fn check_reader(&mut self) -> bool {
|
||||
if self.reader_control.as_ref().is_some_and(|rc| rc.is_done()) && !self.reader_done {
|
||||
self.reader_done = true;
|
||||
self.app.restart_matcher(false);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the reader is done (has finished producing items).
|
||||
pub fn reader_done(&self) -> bool {
|
||||
self.reader_done && self.reader_control.as_ref().is_none_or(|rc| rc.is_done())
|
||||
}
|
||||
|
||||
/// Initialize the TUI with a caller-provided instance.
|
||||
///
|
||||
/// Use this instead of [`init_tui()`](Skim::init_tui) when you need a
|
||||
/// non-default backend (e.g. `TestBackend` for snapshot tests).
|
||||
pub fn init_tui_with(&mut self, tui: Tui<Backend>) {
|
||||
self.tui = Some(tui);
|
||||
}
|
||||
|
||||
/// Returns a shared reference to the application state.
|
||||
pub fn app(&self) -> &App {
|
||||
&self.app
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the application state.
|
||||
pub fn app_mut(&mut self) -> &mut App {
|
||||
&mut self.app
|
||||
}
|
||||
|
||||
/// Returns a shared reference to the TUI.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the TUI has not been initialized yet.
|
||||
pub fn tui_ref(&self) -> &Tui<Backend> {
|
||||
self.tui.as_ref().expect("TUI needs to be initialized before access")
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the TUI.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the TUI has not been initialized yet.
|
||||
pub fn tui_mut(&mut self) -> &mut Tui<Backend> {
|
||||
self.tui.as_mut().expect("TUI needs to be initialized before access")
|
||||
}
|
||||
|
||||
/// Returns mutable references to both the app and the TUI simultaneously.
|
||||
///
|
||||
/// This is useful when you need to call `app.handle_event(tui, ...)` or
|
||||
/// `tui.draw(|frame| frame.render_widget(app, ...))`, which require
|
||||
/// disjoint mutable borrows of both fields.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the TUI has not been initialized yet.
|
||||
pub fn app_and_tui(&mut self) -> (&mut App, &mut Tui<Backend>) {
|
||||
(
|
||||
&mut self.app,
|
||||
self.tui.as_mut().expect("TUI needs to be initialized before access"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a shared reference to the final event that caused skim to quit.
|
||||
pub fn final_event(&self) -> &Event {
|
||||
&self.final_event
|
||||
}
|
||||
|
||||
/// Returns a clone of the TUI event sender.
|
||||
|
|
@ -458,7 +564,7 @@ impl Skim {
|
|||
/// to the running skim instance from outside the event loop. The sender
|
||||
/// is cheap to clone and can be moved into async blocks or other tasks.
|
||||
///
|
||||
/// Must be called after [`init_tui()`](Self::init_tui).
|
||||
/// Must be called after [`init_tui()`](Skim::init_tui).
|
||||
pub fn event_sender(&self) -> tokio::sync::mpsc::Sender<Event> {
|
||||
self.tui
|
||||
.as_ref()
|
||||
|
|
@ -613,28 +719,13 @@ impl Skim {
|
|||
|
||||
// Handle reload event separately
|
||||
if let Event::Reload(new_cmd) = &evt {
|
||||
debug!("reloading with cmd {new_cmd}");
|
||||
// Kill the current reader
|
||||
if let Some(rc) = self.reader_control.as_mut() { rc.kill() }
|
||||
// Clear items
|
||||
self.app.item_pool.clear();
|
||||
// Clear displayed items unless no_clear_if_empty is set
|
||||
// (in which case the item_list will handle keeping stale items)
|
||||
if !self.app.options.no_clear_if_empty {
|
||||
self.app.item_list.clear();
|
||||
}
|
||||
self.app.restart_matcher(true);
|
||||
// Start a new reader with the new command (no source, using cmd)
|
||||
self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
|
||||
self.reader_done = false;
|
||||
self.handle_reload(&new_cmd.clone());
|
||||
} else {
|
||||
self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)?;
|
||||
}
|
||||
|
||||
// Check reader status and update
|
||||
if self.reader_control.as_ref().is_some_and(|rc| rc.is_done()) && !self.reader_done {
|
||||
self.app.restart_matcher(false);
|
||||
}
|
||||
self.check_reader();
|
||||
}
|
||||
_ = async {
|
||||
match matcher_interval {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! This module contains the matching coordinator
|
||||
use rayon::ThreadPool;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
|
|
@ -148,7 +148,7 @@ impl Matcher {
|
|||
///
|
||||
/// The callback is invoked when matching is complete with the matched items.
|
||||
/// Returns a MatcherControl that can be used to monitor progress or stop the matcher.
|
||||
pub fn run<C>(&self, query: &str, item_pool: Arc<ItemPool>, callback: C) -> MatcherControl
|
||||
pub fn run<C>(&self, query: &str, item_pool: Arc<ItemPool>, thread_pool: &ThreadPool, callback: C) -> MatcherControl
|
||||
where
|
||||
C: Fn(Vec<MatchedItem>) + Send + 'static,
|
||||
{
|
||||
|
|
@ -162,39 +162,51 @@ impl Matcher {
|
|||
let processed_clone = processed.clone();
|
||||
let matched = Arc::new(AtomicUsize::new(0));
|
||||
let matched_clone = matched.clone();
|
||||
let mut matched_items = Vec::new();
|
||||
|
||||
thread::spawn(move || {
|
||||
let items = item_pool.take();
|
||||
trace!("matcher start, total: {}", items.len());
|
||||
let result: Result<Vec<_>, _> = items
|
||||
// Take items synchronously before spawning to avoid a race condition:
|
||||
// if we took items inside the spawned closure, a subsequent restart_matcher()
|
||||
// could call kill() + reset() before the old closure runs, causing the old
|
||||
// closure to re-take items that should belong to the new matcher.
|
||||
let items = item_pool.take();
|
||||
trace!("matcher start, total: {}", items.len());
|
||||
|
||||
thread_pool.spawn(move || {
|
||||
let matched_items: Vec<MatchedItem> = items
|
||||
.into_par_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(_, item)| {
|
||||
processed.fetch_add(1, Ordering::Relaxed);
|
||||
.chunks(8196)
|
||||
.take_any_while(|_chunk| {
|
||||
if interrupt.load(Ordering::Relaxed) {
|
||||
stopped.store(true, Ordering::Relaxed);
|
||||
Some(Err("matcher killed"))
|
||||
} else if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
|
||||
matched.fetch_add(1, Ordering::Relaxed);
|
||||
// item is Arc but we get &Arc from iterator, so one clone is needed
|
||||
Some(Ok(MatchedItem {
|
||||
item: item.clone(),
|
||||
rank: match_result.rank,
|
||||
matched_range: Some(match_result.matched_range),
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.map(|chunk| {
|
||||
processed.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||
|
||||
let matched_chunk: Vec<MatchedItem> = chunk
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
matcher_engine.match_item(item.as_ref()).map(|match_result| {
|
||||
// item is Arc but we get &Arc from iterator, so one clone is needed
|
||||
MatchedItem {
|
||||
item,
|
||||
rank: match_result.rank,
|
||||
matched_range: Some(match_result.matched_range),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
matched.fetch_add(matched_chunk.len(), Ordering::Relaxed);
|
||||
|
||||
matched_chunk
|
||||
})
|
||||
.flatten_iter()
|
||||
.collect();
|
||||
|
||||
if let Ok(items) = result {
|
||||
matched_items = items;
|
||||
if !interrupt.load(Ordering::SeqCst) {
|
||||
trace!("matcher stop, total matched: {}", matched_items.len());
|
||||
}
|
||||
|
||||
if !interrupt.load(Ordering::Relaxed) {
|
||||
callback(matched_items);
|
||||
}
|
||||
stopped.store(true, Ordering::Relaxed);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,15 @@ use ratatui::crossterm::event::KeyCode::Char;
|
|||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::widgets::Widget;
|
||||
use rayon::ThreadPool;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
|
||||
std::thread::available_parallelism()
|
||||
.ok()
|
||||
.map(|inner| inner.get())
|
||||
.unwrap_or_else(|| 0)
|
||||
});
|
||||
|
||||
use super::{input, preview};
|
||||
|
||||
|
|
@ -37,6 +46,8 @@ const HIDE_GRACE_MS: u128 = 500;
|
|||
pub struct App {
|
||||
/// Pool of items to be filtered
|
||||
pub item_pool: Arc<ItemPool>,
|
||||
/// Separate thread pool for use by skim
|
||||
pub thread_pool: Arc<ThreadPool>,
|
||||
/// Whether the application should quit
|
||||
pub should_quit: bool,
|
||||
|
||||
|
|
@ -284,6 +295,12 @@ impl Default for App {
|
|||
preview: Preview::from_options(&opts, theme.clone()),
|
||||
header: Header::from_options(&opts, theme.clone()),
|
||||
item_list: ItemList::from_options(&opts, theme.clone()),
|
||||
thread_pool: Arc::new(
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(*NUM_THREADS)
|
||||
.build()
|
||||
.unwrap(),
|
||||
),
|
||||
item_pool: Arc::default(),
|
||||
theme,
|
||||
should_quit: false,
|
||||
|
|
@ -324,6 +341,12 @@ impl App {
|
|||
input: Input::from_options(&options, theme.clone()),
|
||||
preview: Preview::from_options(&options, theme.clone()),
|
||||
header: Header::from_options(&options, theme.clone()),
|
||||
thread_pool: Arc::new(
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(*NUM_THREADS)
|
||||
.build()
|
||||
.unwrap(),
|
||||
),
|
||||
item_pool: Arc::new(ItemPool::from_options(&options)),
|
||||
item_list: ItemList::from_options(&options, theme.clone()),
|
||||
theme,
|
||||
|
|
@ -1180,11 +1203,12 @@ impl App {
|
|||
&self.input
|
||||
};
|
||||
let item_pool = self.item_pool.clone();
|
||||
let thread_pool = &self.thread_pool;
|
||||
let processed_items = self.item_list.processed_items.clone();
|
||||
let no_sort = self.options.no_sort;
|
||||
|
||||
self.item_pool.reset();
|
||||
self.matcher_control = self.matcher.run(query, item_pool.clone(), move |mut matches| {
|
||||
self.matcher_control = self.matcher.run(query, item_pool, thread_pool, move |mut matches| {
|
||||
debug!("Got {} results from matcher, sending to item list...", matches.len());
|
||||
|
||||
// Send matched items directly (header_lines are now handled by the Header widget)
|
||||
|
|
|
|||
|
|
@ -79,13 +79,13 @@ insta_test!(bind_set_query_to_itself, ["a", "b", "c"], &["--bind", "ctrl-a:set-q
|
|||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(bind_toggle_interactive, @interactive, &["--bind", "ctrl-a:toggle-interactive", "-i"], {
|
||||
insta_test!(bind_toggle_interactive, @interactive, &["--bind", "ctrl-a:toggle-interactive", "-i", "--cmd", "true"], {
|
||||
@snap;
|
||||
@ctrl 'a';
|
||||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(bind_toggle_interactive_queries, @interactive, &["--bind", "ctrl-a:toggle-interactive", "-i", "--query", "normal", "--cmd-query", "interactive"], {
|
||||
insta_test!(bind_toggle_interactive_queries, @interactive, &["--bind", "ctrl-a:toggle-interactive", "-i", "--cmd", "true", "--query", "normal", "--cmd-query", "interactive"], {
|
||||
@snap;
|
||||
@ctrl 'a';
|
||||
@snap;
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::io::Cursor;
|
||||
|
||||
use clap::Parser;
|
||||
use color_eyre::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::backend::TestBackend;
|
||||
use skim::{
|
||||
field::FieldRange,
|
||||
helper::item::DefaultSkimItem,
|
||||
Skim, SkimItemReceiver,
|
||||
prelude::*,
|
||||
theme::ColorTheme,
|
||||
tui::{App, Event, Tui, event::Action},
|
||||
tui::{Event, Tui, event::Action},
|
||||
};
|
||||
|
||||
/// A test harness for running skim TUI tests with insta snapshots.
|
||||
///
|
||||
/// This struct wraps the TUI and App, providing an event-driven interface
|
||||
/// that mirrors how the real application works. Events are sent via the
|
||||
/// event channel and processed through the app's event loop.
|
||||
/// This struct wraps a [`Skim<TestBackend>`] instance, providing a synchronous,
|
||||
/// event-driven interface that mirrors how the real application works. Events are
|
||||
/// sent via the event channel and processed through the app's event loop.
|
||||
///
|
||||
/// The harness reuses as much of the production code path as possible:
|
||||
/// - Items are loaded through the same `Reader` + `SkimItemReader` pipeline as
|
||||
/// the real application
|
||||
/// - Reload events use `Skim::handle_reload()`, the same logic as the production
|
||||
/// event loop
|
||||
/// - Reader completion is checked via `Skim::check_reader()`, identical to
|
||||
/// production
|
||||
pub struct TestHarness {
|
||||
/// The TUI instance with TestBackend
|
||||
pub tui: Tui<TestBackend>,
|
||||
/// The application state
|
||||
pub app: App,
|
||||
/// The Skim instance backed by a TestBackend for snapshot testing.
|
||||
pub skim: Skim<TestBackend>,
|
||||
/// Tokio runtime for async operations (preview commands, etc.)
|
||||
pub runtime: tokio::runtime::Runtime,
|
||||
/// The exit status of the last command executed (for @cmd tests)
|
||||
pub exit_status: Option<i32>,
|
||||
/// The final event that caused the app to quit (for determining exit code)
|
||||
pub final_event: Option<Event>,
|
||||
}
|
||||
|
|
@ -39,40 +39,48 @@ impl TestHarness {
|
|||
/// in `lib.rs`. It drains the event_rx channel and calls `app.handle_event()`
|
||||
/// for each event, mimicking the actual application behavior.
|
||||
///
|
||||
/// For `Event::Reload`, it executes the command and restarts the reader,
|
||||
/// just like the main event loop does.
|
||||
/// For `Event::Reload`, it delegates to `Skim::handle_reload()` — the same
|
||||
/// method used by the production event loop.
|
||||
pub fn tick(&mut self) -> Result<()> {
|
||||
// Process all pending events
|
||||
while let Ok(event) = self.tui.event_rx.try_recv() {
|
||||
self.process_event(event)?;
|
||||
// Drain-and-process in a loop so events queued during processing
|
||||
// are picked up on the next iteration.
|
||||
loop {
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = self.skim.tui_mut().event_rx.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
if events.is_empty() {
|
||||
break;
|
||||
}
|
||||
for event in events {
|
||||
self.process_event(event)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process a single event through the app's event handler.
|
||||
/// Process a single event through the same logic as the production event loop.
|
||||
///
|
||||
/// This handles special events like Reload that need extra processing
|
||||
/// beyond what `app.handle_event()` does.
|
||||
/// `Event::Reload` is handled via `Skim::handle_reload()` — the exact same
|
||||
/// method used in production. All other events are forwarded to
|
||||
/// `app.handle_event()`.
|
||||
fn process_event(&mut self, event: Event) -> Result<()> {
|
||||
// Handle reload event specially - this is what the main loop does
|
||||
if let Event::Reload(ref new_cmd) = event {
|
||||
// Clear items
|
||||
self.app.item_pool.clear();
|
||||
if !self.app.options.no_clear_if_empty {
|
||||
self.app.item_list.clear();
|
||||
}
|
||||
// Run the command and add items
|
||||
self.run_command_internal(new_cmd)?;
|
||||
self.app.restart_matcher(true);
|
||||
let new_cmd = new_cmd.clone();
|
||||
self.skim.handle_reload(&new_cmd);
|
||||
} else {
|
||||
// Let the app handle the event (this may queue more events)
|
||||
// Enter the runtime context so that tokio::spawn() calls work
|
||||
let _guard = self.runtime.enter();
|
||||
self.app.handle_event(&mut self.tui, &event)?;
|
||||
let (app, tui) = self.skim.app_and_tui();
|
||||
app.handle_event(tui, &event)?;
|
||||
}
|
||||
|
||||
// Check reader status, just like the production event loop
|
||||
self.skim.check_reader();
|
||||
|
||||
// Track if app should quit and what the final event was
|
||||
if self.app.should_quit && self.final_event.is_none() {
|
||||
if self.skim.app().should_quit && self.final_event.is_none() {
|
||||
self.final_event = Some(event);
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +91,7 @@ impl TestHarness {
|
|||
///
|
||||
/// This queues an event for processing. Call `tick()` to process queued events.
|
||||
pub fn send(&mut self, event: Event) -> Result<()> {
|
||||
self.tui.event_tx.try_send(event)?;
|
||||
self.skim.tui_mut().event_tx.try_send(event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -92,14 +100,11 @@ impl TestHarness {
|
|||
/// This is the primary way to simulate user input. It:
|
||||
/// 1. Sends the key event to the queue
|
||||
/// 2. Processes all pending events (including any triggered by the key)
|
||||
/// 3. For interactive mode, handles any reload commands
|
||||
/// 3. Waits for reader (if running) and matcher to complete
|
||||
pub fn key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
self.send(Event::Key(key))?;
|
||||
self.tick()?;
|
||||
// Wait for matcher if items changed
|
||||
if self.app.pending_matcher_restart || !self.app.matcher_control.stopped() {
|
||||
self.wait_for_matcher()?;
|
||||
}
|
||||
self.wait_for_completion()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -120,8 +125,33 @@ impl TestHarness {
|
|||
pub fn action(&mut self, action: Action) -> Result<()> {
|
||||
self.send(Event::Action(action))?;
|
||||
self.tick()?;
|
||||
// Wait for matcher if items changed
|
||||
if self.app.pending_matcher_restart || !self.app.matcher_control.stopped() {
|
||||
self.wait_for_completion()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for any in-flight reader and matcher to complete.
|
||||
///
|
||||
/// If the reader is still running (e.g., after a reload in interactive mode),
|
||||
/// waits for it to finish first. Then waits for the matcher if it needs to run.
|
||||
///
|
||||
/// If a debounced matcher restart is pending (e.g., because the query changed
|
||||
/// within the 50ms debounce window), this forces the restart immediately so
|
||||
/// tests don't see stale results.
|
||||
fn wait_for_completion(&mut self) -> Result<()> {
|
||||
// If a debounced matcher restart is pending, force it now.
|
||||
// In production, the Heartbeat event would trigger this, but in tests
|
||||
// we don't have a continuous heartbeat timer.
|
||||
if self.skim.app().pending_matcher_restart {
|
||||
self.skim.app_mut().restart_matcher(true);
|
||||
}
|
||||
|
||||
// If the reader is running (not done), wait for it + matcher
|
||||
if !self.skim.reader_done() {
|
||||
self.wait_for_reader_and_matcher()?;
|
||||
} else {
|
||||
// Always wait for the matcher to ensure results are consumed.
|
||||
// Even if stopped() is already true, wait_for_matcher will
|
||||
// render and process heartbeat to consume processed_items.
|
||||
self.wait_for_matcher()?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -129,15 +159,16 @@ impl TestHarness {
|
|||
|
||||
/// Render the current app state to the terminal buffer.
|
||||
pub fn render(&mut self) -> Result<()> {
|
||||
self.tui.draw(|frame| {
|
||||
frame.render_widget(&mut self.app, frame.area());
|
||||
let (app, tui) = self.skim.app_and_tui();
|
||||
tui.draw(|frame| {
|
||||
frame.render_widget(&mut *app, frame.area());
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a string representation of the current buffer for snapshot testing.
|
||||
pub fn buffer_view(&self) -> String {
|
||||
self.tui.backend().to_string()
|
||||
self.skim.tui_ref().backend().to_string()
|
||||
}
|
||||
|
||||
/// Prepare for taking a snapshot by waiting for preview and processing heartbeat.
|
||||
|
|
@ -154,7 +185,7 @@ impl TestHarness {
|
|||
self.tick()?;
|
||||
|
||||
// Now wait for preview if configured
|
||||
if self.app.options.preview.is_some() {
|
||||
if self.skim.app().options.preview.is_some() {
|
||||
self.wait_for_preview()?;
|
||||
}
|
||||
|
||||
|
|
@ -170,116 +201,38 @@ impl TestHarness {
|
|||
pub fn snap(&mut self) -> Result<()> {
|
||||
self.prepare_snap()?;
|
||||
let buf = self.buffer_view();
|
||||
let cursor_pos = format!("cursor: {}x{}", self.app.cursor_pos.0, self.app.cursor_pos.1);
|
||||
let cursor_pos = format!(
|
||||
"cursor: {}x{}",
|
||||
self.skim.app().cursor_pos.0,
|
||||
self.skim.app().cursor_pos.1
|
||||
);
|
||||
insta::assert_snapshot!(buf + &cursor_pos);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add items to the item pool and run the matcher.
|
||||
pub fn add_items<I, S>(&mut self, items: I) -> Result<()>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
// Parse field ranges from options
|
||||
let transform_fields: Vec<FieldRange> = self
|
||||
.app
|
||||
.options
|
||||
.with_nth
|
||||
.iter()
|
||||
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
|
||||
.collect();
|
||||
/// Wait for the reader to finish producing items and the matcher to complete.
|
||||
///
|
||||
/// This polls `Skim::reader_done()` and `Skim::check_reader()` — the same
|
||||
/// methods used by the production event loop — until the reader has finished,
|
||||
/// then waits for the matcher to process all items.
|
||||
pub fn wait_for_reader_and_matcher(&mut self) -> Result<()> {
|
||||
let timeout = std::time::Duration::from_secs(5);
|
||||
let start = std::time::Instant::now();
|
||||
let poll_interval = std::time::Duration::from_millis(10);
|
||||
|
||||
let matching_fields: Vec<FieldRange> = self
|
||||
.app
|
||||
.options
|
||||
.nth
|
||||
.iter()
|
||||
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
|
||||
.collect();
|
||||
// Wait for reader to finish
|
||||
while !self.skim.reader_done() {
|
||||
if start.elapsed() > timeout {
|
||||
return Err(color_eyre::eyre::eyre!("Timeout waiting for reader to finish"));
|
||||
}
|
||||
// Check reader status (may restart matcher)
|
||||
self.skim.check_reader();
|
||||
std::thread::sleep(poll_interval);
|
||||
}
|
||||
|
||||
let items: Vec<Arc<dyn SkimItem>> = items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, s)| {
|
||||
Arc::new(DefaultSkimItem::new(
|
||||
s.as_ref(),
|
||||
self.app.options.ansi,
|
||||
&transform_fields,
|
||||
&matching_fields,
|
||||
&self.app.options.delimiter,
|
||||
idx,
|
||||
)) as Arc<dyn SkimItem>
|
||||
})
|
||||
.collect();
|
||||
self.app.handle_items(items);
|
||||
self.app.restart_matcher(true);
|
||||
self.wait_for_matcher()?;
|
||||
Ok(())
|
||||
}
|
||||
// Final check to restart matcher with remaining items
|
||||
self.skim.check_reader();
|
||||
|
||||
/// Execute a shell command and add its output lines as items.
|
||||
/// This is an internal method that doesn't restart the matcher.
|
||||
fn run_command_internal(&mut self, cmd: &str) -> Result<()> {
|
||||
let mut child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("Failed to capture stdout"))?;
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
// Parse field ranges from options
|
||||
let transform_fields: Vec<FieldRange> = self
|
||||
.app
|
||||
.options
|
||||
.with_nth
|
||||
.iter()
|
||||
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
|
||||
.collect();
|
||||
|
||||
let matching_fields: Vec<FieldRange> = self
|
||||
.app
|
||||
.options
|
||||
.nth
|
||||
.iter()
|
||||
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
|
||||
.collect();
|
||||
|
||||
let items: Vec<Arc<dyn SkimItem>> = reader
|
||||
.lines()
|
||||
.map_while(Result::ok)
|
||||
.enumerate()
|
||||
.map(|(idx, s)| {
|
||||
Arc::new(DefaultSkimItem::new(
|
||||
&s,
|
||||
self.app.options.ansi,
|
||||
&transform_fields,
|
||||
&matching_fields,
|
||||
&self.app.options.delimiter,
|
||||
idx,
|
||||
)) as Arc<dyn SkimItem>
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.app.handle_items(items);
|
||||
|
||||
// Wait for the command to complete and capture its exit status
|
||||
let status = child.wait()?;
|
||||
self.exit_status = status.code();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a shell command and add its output lines as items.
|
||||
pub fn run_command(&mut self, cmd: &str) -> Result<()> {
|
||||
self.run_command_internal(cmd)?;
|
||||
self.app.restart_matcher(true);
|
||||
self.wait_for_matcher()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -291,7 +244,7 @@ impl TestHarness {
|
|||
let poll_interval = std::time::Duration::from_millis(10);
|
||||
|
||||
// Wait for matcher to complete
|
||||
while !self.app.matcher_control.stopped() {
|
||||
while !self.skim.app().matcher_control.stopped() {
|
||||
if start.elapsed() > timeout {
|
||||
return Err(color_eyre::eyre::eyre!("Timeout waiting for matcher to stop"));
|
||||
}
|
||||
|
|
@ -311,7 +264,8 @@ impl TestHarness {
|
|||
// Manually trigger preview if configured and an item is selected
|
||||
// Note: on_item_changed won't trigger automatically because the item was
|
||||
// already selected during render, so prev_item == new_item
|
||||
if self.app.options.preview.is_some() && self.app.item_list.selected().is_some() {
|
||||
let needs_preview = self.skim.app().options.preview.is_some() && self.skim.app().item_list.selected().is_some();
|
||||
if needs_preview {
|
||||
self.send(Event::RunPreview)?;
|
||||
self.wait_for_preview()?;
|
||||
}
|
||||
|
|
@ -326,7 +280,7 @@ impl TestHarness {
|
|||
|
||||
// Now check if there's a pending preview task
|
||||
// If not, there's nothing to wait for
|
||||
if let Some(ref handle) = self.app.preview.thread_handle {
|
||||
if let Some(ref handle) = self.skim.app().preview.thread_handle {
|
||||
if handle.is_finished() {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -343,8 +297,12 @@ impl TestHarness {
|
|||
// Sleep to give background tasks time to execute
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// Try to process any pending events (including PreviewReady)
|
||||
while let Ok(event) = self.tui.event_rx.try_recv() {
|
||||
// Drain events then process, so we can check for PreviewReady
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = self.skim.tui_mut().event_rx.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
for event in events {
|
||||
let is_preview_ready = matches!(event, Event::PreviewReady);
|
||||
self.process_event(event)?;
|
||||
// If we got PreviewReady, render and return
|
||||
|
|
@ -375,7 +333,7 @@ impl TestHarness {
|
|||
/// - 0 if the app accepted (Enter)
|
||||
/// - None if the app hasn't quit yet
|
||||
pub fn app_exit_code(&self) -> Option<i32> {
|
||||
if !self.app.should_quit {
|
||||
if !self.skim.app().should_quit {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -395,25 +353,44 @@ impl TestHarness {
|
|||
// Factory functions
|
||||
// ============================================================================
|
||||
|
||||
/// Initialize a test harness with the given options and dimensions.
|
||||
pub fn enter_sized(options: SkimOptions, width: u16, height: u16) -> Result<TestHarness> {
|
||||
/// Initialize a test harness with the given options, dimensions, and optional item source.
|
||||
///
|
||||
/// Uses [`Skim::init`] for the core initialization (App, Reader, theme, etc.)
|
||||
/// and [`Skim::init_tui_with`] to inject a [`TestBackend`].
|
||||
/// Then calls [`Skim::start`] to begin the reader and matcher — the same
|
||||
/// production code path used by the real application.
|
||||
fn enter_sized_with_source(
|
||||
options: SkimOptions,
|
||||
width: u16,
|
||||
height: u16,
|
||||
source: Option<SkimItemReceiver>,
|
||||
) -> Result<TestHarness> {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let tui = Tui::new_for_test(backend)?;
|
||||
let theme = Arc::new(ColorTheme::init_from_options(&options));
|
||||
let cmd = options.cmd.clone().unwrap_or_default();
|
||||
let app = App::from_options(options, theme, cmd);
|
||||
let mut skim = Skim::<TestBackend>::init(options, source)?;
|
||||
skim.init_tui_with(tui);
|
||||
|
||||
// Start the reader and matcher — the same call the production binary makes
|
||||
skim.start();
|
||||
|
||||
// Create a multi-threaded tokio runtime for async operations (preview commands, etc.)
|
||||
// We use multi-threaded so spawned tasks can execute on background threads
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
|
||||
|
||||
Ok(TestHarness {
|
||||
tui,
|
||||
app,
|
||||
let mut harness = TestHarness {
|
||||
skim,
|
||||
runtime,
|
||||
exit_status: None,
|
||||
final_event: None,
|
||||
})
|
||||
};
|
||||
|
||||
// Wait for the reader to finish and matcher to complete
|
||||
harness.wait_for_reader_and_matcher()?;
|
||||
|
||||
Ok(harness)
|
||||
}
|
||||
|
||||
/// Initialize a test harness with the given options and dimensions.
|
||||
pub fn enter_sized(options: SkimOptions, width: u16, height: u16) -> Result<TestHarness> {
|
||||
enter_sized_with_source(options, width, height, None)
|
||||
}
|
||||
|
||||
/// Initialize a test harness with default dimensions (80x24).
|
||||
|
|
@ -427,36 +404,65 @@ pub fn enter_default() -> Result<TestHarness> {
|
|||
}
|
||||
|
||||
/// Initialize a test harness with pre-loaded items.
|
||||
///
|
||||
/// Items are fed through the production `Reader` + `SkimItemReader` pipeline
|
||||
/// by converting them to a newline-separated byte stream and using
|
||||
/// `SkimItemReader::of_bufread()`.
|
||||
pub fn enter_items<I, S>(items: I, options: SkimOptions) -> Result<TestHarness>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut harness = enter(options)?;
|
||||
harness.add_items(items)?;
|
||||
Ok(harness)
|
||||
// Build a newline-terminated string from items and feed through SkimItemReader.
|
||||
// Each item must end with '\n' so the reader produces one item per entry,
|
||||
// including empty-string items like "".
|
||||
let text: String = items
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let mut line = s.as_ref().to_owned();
|
||||
line.push('\n');
|
||||
line
|
||||
})
|
||||
.collect();
|
||||
|
||||
let reader_opts = SkimItemReaderOption::from_options(&options);
|
||||
let item_reader = SkimItemReader::new(reader_opts);
|
||||
let rx = item_reader.of_bufread(Cursor::new(text));
|
||||
|
||||
enter_sized_with_source(options, 80, 24, Some(rx))
|
||||
}
|
||||
|
||||
/// Initialize a test harness with command output as items.
|
||||
///
|
||||
/// The command is executed through the production `Reader` + `SkimItemReader`
|
||||
/// pipeline, identical to how the real application works.
|
||||
pub fn enter_cmd(cmd: &str, options: SkimOptions) -> Result<TestHarness> {
|
||||
let mut harness = enter(options)?;
|
||||
harness.run_command(cmd)?;
|
||||
Ok(harness)
|
||||
let reader_opts = SkimItemReaderOption::from_options(&options);
|
||||
let item_reader = SkimItemReader::new(reader_opts);
|
||||
let rx = item_reader.of_bufread(std::io::BufReader::new(
|
||||
std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?
|
||||
.stdout
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("Failed to capture stdout"))?,
|
||||
));
|
||||
|
||||
enter_sized_with_source(options, 80, 24, Some(rx))
|
||||
}
|
||||
|
||||
/// Initialize a test harness for interactive mode.
|
||||
///
|
||||
/// This runs the initial command (with empty query) and sets up the harness.
|
||||
/// Uses [`Skim::start`] which already handles interactive mode correctly:
|
||||
/// it expands the command template with the initial query and starts the
|
||||
/// reader pipeline.
|
||||
pub fn enter_interactive(options: SkimOptions) -> Result<TestHarness> {
|
||||
let mut harness = enter(options)?;
|
||||
|
||||
// Run initial command with current (empty) query
|
||||
if let Some(ref cmd_template) = harness.app.options.cmd.clone() {
|
||||
let expanded_cmd = harness.app.expand_cmd(cmd_template, true);
|
||||
harness.run_command(&expanded_cmd)?;
|
||||
}
|
||||
|
||||
Ok(harness)
|
||||
// Skim::init() computes initial_cmd for interactive mode,
|
||||
// and Skim::start() kicks off the reader with it.
|
||||
// No special-casing needed here.
|
||||
enter(options)
|
||||
}
|
||||
|
||||
/// Parse SkimOptions from CLI-style arguments.
|
||||
|
|
@ -479,8 +485,8 @@ macro_rules! snap {
|
|||
let buf = $harness.buffer_view();
|
||||
let cursor_pos = format!(
|
||||
"cursor: ({}, {})",
|
||||
$harness.app.cursor_pos.1 + 1,
|
||||
$harness.app.cursor_pos.0 + 1
|
||||
$harness.skim.app().cursor_pos.1 + 1,
|
||||
$harness.skim.app().cursor_pos.0 + 1
|
||||
);
|
||||
insta::assert_snapshot!(buf + &cursor_pos);
|
||||
};
|
||||
|
|
@ -701,8 +707,8 @@ macro_rules! insta_test {
|
|||
|
||||
// @assert - run an assertion closure
|
||||
// Pass a closure that takes the harness as parameter
|
||||
// Usage: @assert(|h| h.app.should_quit);
|
||||
// @assert(|h| h.app.item_list.selected().unwrap().text() == "1");
|
||||
// Usage: @assert(|h| h.skim.app().should_quit);
|
||||
// @assert(|h| h.skim.app().item_list.selected().unwrap().text() == "1");
|
||||
(@expand $h:ident; @assert ( $assertion:expr ) ; $($rest:tt)*) => {
|
||||
assert!(($assertion)(&$h));
|
||||
insta_test!(@expand $h; $($rest)*);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ insta_test!(keys_interactive_basic, ["1", "2", "3", "4"], &["-i"], {
|
|||
|
||||
// Input navigation keys
|
||||
|
||||
insta_test!(keys_interactive_arrows, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_arrows, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@key Left;
|
||||
@char '|';
|
||||
|
|
@ -21,7 +21,7 @@ insta_test!(keys_interactive_arrows, @interactive, &["-i", "--cmd-query", "foo b
|
|||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_arrows, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_arrows, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl Left;
|
||||
@char '|';
|
||||
|
|
@ -34,14 +34,14 @@ insta_test!(keys_interactive_ctrl_arrows, @interactive, &["-i", "--cmd-query", "
|
|||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_a, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_a, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'a';
|
||||
@char '|';
|
||||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_b, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_b, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'a';
|
||||
@char '|';
|
||||
|
|
@ -51,7 +51,7 @@ insta_test!(keys_interactive_ctrl_b, @interactive, &["-i", "--cmd-query", "foo b
|
|||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_e, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_e, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'a';
|
||||
@char '|';
|
||||
|
|
@ -61,7 +61,7 @@ insta_test!(keys_interactive_ctrl_e, @interactive, &["-i", "--cmd-query", "foo b
|
|||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_f, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_f, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'a';
|
||||
@char '|';
|
||||
|
|
@ -71,21 +71,21 @@ insta_test!(keys_interactive_ctrl_f, @interactive, &["-i", "--cmd-query", "foo b
|
|||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_h, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_h, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'h';
|
||||
@char '|';
|
||||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_alt_b, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_alt_b, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@alt 'b';
|
||||
@char '|';
|
||||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_alt_f, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_alt_f, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'a';
|
||||
@char '|';
|
||||
|
|
@ -97,39 +97,39 @@ insta_test!(keys_interactive_alt_f, @interactive, &["-i", "--cmd-query", "foo ba
|
|||
|
||||
// Input manipulation keys
|
||||
|
||||
insta_test!(keys_interactive_bspace, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_bspace, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@key Backspace;
|
||||
@char '|';
|
||||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_c, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_c, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'c';
|
||||
@exited 130;
|
||||
});
|
||||
insta_test!(keys_interactive_ctrl_d, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_d, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'd';
|
||||
@exited 130;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_u, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_u, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'u';
|
||||
@char '|';
|
||||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_w, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_w, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl 'w';
|
||||
@char '|';
|
||||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_ctrl_y, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_ctrl_y, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@alt Backspace;
|
||||
@char '|';
|
||||
|
|
@ -139,7 +139,7 @@ insta_test!(keys_interactive_ctrl_y, @interactive, &["-i", "--cmd-query", "foo b
|
|||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_alt_d, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_alt_d, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@ctrl Left;
|
||||
@char '|';
|
||||
|
|
@ -152,7 +152,7 @@ insta_test!(keys_interactive_alt_d, @interactive, &["-i", "--cmd-query", "foo ba
|
|||
@snap;
|
||||
});
|
||||
|
||||
insta_test!(keys_interactive_alt_bspace, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
|
||||
insta_test!(keys_interactive_alt_bspace, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
|
||||
@snap;
|
||||
@alt Backspace;
|
||||
@char '|';
|
||||
|
|
@ -186,6 +186,6 @@ insta_test!(keys_interactive_btab, ["1", "2", "3", "4"], &["-i"], {
|
|||
insta_test!(keys_interactive_enter, ["1", "2", "3", "4"], &["-i"], {
|
||||
@snap;
|
||||
@key Enter;
|
||||
@assert(|h: &common::insta::TestHarness| h.app.should_quit);
|
||||
@assert(|h: &common::insta::TestHarness| h.app.item_list.selected().unwrap().text() == "1");
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().should_quit);
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "1");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue