diff --git a/src/lib.rs b/src/lib.rs index cc45064f..7808a852 100644 --- a/src/lib.rs +++ b/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 Skim +where + Backend::Error: Send + Sync + 'static, +{ /// Initialize skim, without starting anything yet pub fn init(options: SkimOptions, source: Option) -> Result { 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) { + 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 { + 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 { + 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) { + ( + &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 { 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 { diff --git a/src/matcher.rs b/src/matcher.rs index 7cbaa875..3afff024 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -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(&self, query: &str, item_pool: Arc, callback: C) -> MatcherControl + pub fn run(&self, query: &str, item_pool: Arc, thread_pool: &ThreadPool, callback: C) -> MatcherControl where C: Fn(Vec) + 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, _> = 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 = 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 = 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); diff --git a/src/tui/app.rs b/src/tui/app.rs index dd26ad33..159ca7b3 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -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 = 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, + /// Separate thread pool for use by skim + pub thread_pool: Arc, /// 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) diff --git a/tests/binds.rs b/tests/binds.rs index 7882d007..ca840ce3 100644 --- a/tests/binds.rs +++ b/tests/binds.rs @@ -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; diff --git a/tests/common/insta.rs b/tests/common/insta.rs index 17a8ca8e..297881a4 100644 --- a/tests/common/insta.rs +++ b/tests/common/insta.rs @@ -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`] 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, - /// The application state - pub app: App, + /// The Skim instance backed by a TestBackend for snapshot testing. + pub skim: Skim, /// 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, /// The final event that caused the app to quit (for determining exit code) pub final_event: Option, } @@ -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(&mut self, items: I) -> Result<()> - where - I: IntoIterator, - S: AsRef, - { - // Parse field ranges from options - let transform_fields: Vec = 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 = 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> = 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 - }) - .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 = self - .app - .options - .with_nth - .iter() - .filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None }) - .collect(); - - let matching_fields: Vec = self - .app - .options - .nth - .iter() - .filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None }) - .collect(); - - let items: Vec> = 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 - }) - .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 { - 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 { +/// 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, +) -> Result { 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::::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 { + 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 { } /// 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(items: I, options: SkimOptions) -> Result where I: IntoIterator, S: AsRef, { - 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 { - 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 { - 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)*); diff --git a/tests/keys_interactive.rs b/tests/keys_interactive.rs index 148de5fd..22f5105e 100644 --- a/tests/keys_interactive.rs +++ b/tests/keys_interactive.rs @@ -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"); });