fix: refresh preview after mouse selection (#1095)

* fix: refresh preview after mouse selection

* Address mouse preview review feedback

* feat(ci): upload coverage report to pages for easier browsing (#1096)

* feat(ci): upload coverage report to pages for easier browsing

* fix: remove anchors

* fix: release report

* fix: coverage percent

* docs: update README

* chore: only on master

* test: add unit tests for the shell & manpage generators (#1098)

* test: add unit tests for the shell & manpage generators

* chore: propagate key bindings generation errors

* tests: improve coverage to 90% (#1099)

* tests: improve coverage to 90%

* feat: improve coverage

* remove most unix-only tests

* Update src/skim_tests.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fixes

* chore: misc

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: cleanup

---------

Co-authored-by: LoricAndre <57358788+LoricAndre@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Loric ANDRE <loric.andre@pm.me>
This commit is contained in:
Jason Wang 2026-06-26 06:36:07 +08:00 committed by GitHub
parent 7e2cdf3c8e
commit 7d4502201f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 195 additions and 23 deletions

View file

@ -633,7 +633,10 @@ impl App {
}
}
Event::Mouse(mouse_event) => {
self.handle_mouse(*mouse_event, tui)?;
let events = self.handle_mouse(*mouse_event)?;
for evt in events {
tui.event_tx.try_send(evt)?;
}
}
Event::InvalidInput => {
warn!("Received invalid input");
@ -1332,16 +1335,15 @@ impl App {
}
/// Handle mouse events
fn handle_mouse<B: Backend>(&mut self, mouse_event: MouseEvent, tui: &mut Tui<B>) -> Result<()>
where
B::Error: Send + Sync + 'static,
{
fn handle_mouse(&mut self, mouse_event: MouseEvent) -> Result<Vec<Event>> {
let mouse_pos = ratatui::layout::Position {
x: mouse_event.column,
y: mouse_event.row,
};
trace!("Got mouse event {mouse_event:?}");
let old_current = self.item_list.current;
match mouse_event.kind {
MouseEventKind::ScrollUp => {
// Check if mouse is over preview area
@ -1349,15 +1351,10 @@ impl App {
&& preview_area.contains(mouse_pos)
{
// Scroll preview up
for evt in self.handle_action(&Action::PreviewUp(3))? {
tui.event_tx.try_send(evt)?;
}
return Ok(());
return self.handle_action(&Action::PreviewUp(3));
}
// Otherwise scroll item list up
for evt in self.handle_action(&Action::Up(1))? {
tui.event_tx.try_send(evt)?;
}
return self.handle_action(&Action::Up(1));
}
MouseEventKind::ScrollDown => {
// Check if mouse is over preview area
@ -1365,15 +1362,10 @@ impl App {
&& preview_area.contains(mouse_pos)
{
// Scroll preview down
for evt in self.handle_action(&Action::PreviewDown(3))? {
tui.event_tx.try_send(evt)?;
}
return Ok(());
return self.handle_action(&Action::PreviewDown(3));
}
// Otherwise scroll item list down
for evt in self.handle_action(&Action::Down(1))? {
tui.event_tx.try_send(evt)?;
}
return self.handle_action(&Action::Down(1));
}
MouseEventKind::Down(MouseButton::Left) => {
if let Some((inner, scrollbar_col)) = self.scrollbar_column()
@ -1409,8 +1401,13 @@ impl App {
// Ignore other mouse events for now
}
}
tui.event_tx.try_send(Event::Render)?;
Ok(())
self.needs_render();
if self.item_list.current != old_current {
return Ok(Self::on_selection_changed());
}
Ok(vec![])
}
fn toggle_spinner(&mut self) {
self.show_spinner = !self.show_spinner;

View file

@ -1133,7 +1133,7 @@ fn mouse_scroll_up_and_down_move_selection() {
app.handle_event(&mut tui, &Event::Mouse(mouse(MouseEventKind::ScrollUp, 1, 1)))
.unwrap();
// A Render event is queued after handling a mouse event.
assert!(drain_events(&mut tui).iter().any(|e| matches!(e, Event::Render)));
assert!(app.needs_render.load(Ordering::Relaxed));
}
#[test]
@ -1200,6 +1200,46 @@ fn mouse_scroll_over_preview_area_scrolls_preview() {
}
}
/// A left mouse-button press at the given cell.
fn mouse_down(col: u16, row: u16) -> MouseEvent {
mouse(MouseEventKind::Down(MouseButton::Left), col, row)
}
#[test]
fn mouse_selection_change_requests_preview() -> Result<()> {
let mut app = app_with_items(&["a", "b", "c", "d", "e", "f"]);
let _ = render(&mut app, 40, 6);
let inner = app.list_inner_area();
// The default layout is bottom-to-top, so the current item (0) sits on the
// bottom row; item 1 occupies the row just above it.
let item_one_row = inner.y + inner.height - 2;
// Clicking a different row moves the cursor and asks for a fresh preview.
let events = app.handle_mouse(mouse_down(inner.x, item_one_row))?;
assert_eq!(app.item_list.current, 1);
assert!(events.iter().any(|event| matches!(event, Event::RunPreview)));
assert!(app.needs_render.load(Ordering::Relaxed));
Ok(())
}
#[test]
fn mouse_selection_same_item_only_requests_render() -> Result<()> {
let mut app = app_with_items(&["a", "b", "c", "d", "e", "f"]);
let _ = render(&mut app, 40, 6);
let inner = app.list_inner_area();
// Bottom-to-top layout: the current item (0) renders on the bottom row.
let current_row = inner.y + inner.height - 1;
// Clicking the already-current row only redraws; no preview is requested.
let events = app.handle_mouse(mouse_down(inner.x, current_row))?;
assert_eq!(app.item_list.current, 0);
assert!(!events.iter().any(|event| matches!(event, Event::RunPreview)));
assert!(app.needs_render.load(Ordering::Relaxed));
Ok(())
}
#[test]
fn mouse_other_event_is_ignored() {
let mut app = app_with_items(&["a"]);

View file

@ -2,7 +2,7 @@ use std::io::Cursor;
use clap::Parser;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use ratatui::backend::TestBackend;
use skim::prelude::*;
use skim::tui::event::Action;
@ -128,6 +128,12 @@ impl TestHarness {
Ok(())
}
/// Send a mouse event and process any resulting events immediately.
pub fn mouse(&mut self, mouse: MouseEvent) -> Result<()> {
self.send(Event::Mouse(mouse))?;
self.handle_remaining_events()
}
/// Wait for any in-flight reader and matcher to complete.
///
/// If the reader is still running (e.g., after a reload in interactive mode),
@ -322,6 +328,18 @@ impl TestHarness {
// Process any queued events first (including RunPreview)
self.tick()?;
let debounce_timeout = std::time::Duration::from_secs(2);
let debounce_start = std::time::Instant::now();
while self.skim.app().pending_preview_run {
if debounce_start.elapsed() > debounce_timeout {
return Err(color_eyre::eyre::eyre!("Timeout waiting for debounced preview to run"));
}
std::thread::sleep(std::time::Duration::from_millis(10));
self.send(Event::Heartbeat)?;
self.tick()?;
}
// If there's no preview task running, nothing to wait for
let has_pending = match self.skim.app().preview.thread_handle {
Some(ref handle) => !handle.is_finished(),
@ -633,6 +651,7 @@ macro_rules! snap_color {
/// @snap; // Take snapshot
/// @char 'f'; // Send single character
/// @type "foo"; // Type string
/// @mouse(|h| mouse_down(h, 1)); // Send mouse event
/// @action Down(1); // Send action
/// @key Enter; // Send special key
/// @exited 0; // Assert command exited with status code 0
@ -856,6 +875,14 @@ macro_rules! insta_test {
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
// @mouse - build and send a mouse event from the current harness state
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @mouse ( $mouse:expr ) ; $($rest:tt)*) => {
$cmds.push(concat!("@mouse(", stringify!($mouse), ")"));
let __mouse = ($mouse)(&$h);
$h.mouse(__mouse)?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
// @key - send a special key (Enter, Escape, Tab, etc.)
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @key $key:ident ; $($rest:tt)*) => {
$cmds.push(concat!("@key ", stringify!($key)));

50
tests/mouse.rs Normal file
View file

@ -0,0 +1,50 @@
#![allow(missing_docs, clippy::pedantic)]
#[allow(dead_code)]
#[macro_use]
mod common;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use skim::tui::BorderType;
fn mouse_down(column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
}
}
fn list_inner_area(h: &common::insta::TestHarness) -> Rect {
let app = h.skim.app();
let list_area = app.layout.list_area;
if !matches!(app.options.border, BorderType::None | BorderType::ForceOff) {
return Rect {
x: list_area.x + 1,
y: list_area.y + 1,
width: list_area.width.saturating_sub(2),
height: list_area.height.saturating_sub(2),
};
}
list_area
}
fn mouse_down_list_row(h: &common::insta::TestHarness, row: u16) -> MouseEvent {
let inner = list_inner_area(h);
mouse_down(inner.x, inner.y + row)
}
insta_test!(
mouse_selection_refreshes_preview,
["first", "second", "third"],
&["--layout", "reverse", "--preview", "echo preview:{}"],
{
@snap;
@mouse(|h| mouse_down_list_row(h, 1));
@snap;
}
);

View file

@ -0,0 +1,29 @@
---
source: tests/mouse.rs
description: "input: items [\"first\", \"second\", \"third\"]\noptions: --layout reverse --preview echo preview:{}"
---
"> │preview:first "
" 3/3 0/0│ "
"> first │ "
" second │ "
" third │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
cursor: (1, 3)

View file

@ -0,0 +1,29 @@
---
source: tests/mouse.rs
description: "input: items [\"first\", \"second\", \"third\"]\noptions: --layout reverse --preview echo preview:{}\nafter:\n @mouse(|h| mouse_down_list_row(h, 1))"
---
"> │preview:second "
" 3/3 1/0│ "
" first │ "
"> second │ "
" third │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
cursor: (1, 3)