From aefd9959761dfcec99683d64dbf984c151c83499 Mon Sep 17 00:00:00 2001 From: beeb <703631+beeb@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:59:43 +0200 Subject: [PATCH 1/2] feat: mouse support (click + scroll) --- README.md | 2 +- src/app.rs | 38 ++++++++++--- src/app/input.rs | 117 +++++++++++++++++++++++++++++++++++--- src/types.rs | 44 ++++++++++++++ src/ui.rs | 23 ++++++-- src/ui/preview.rs | 48 ++++++++++++++-- src/ui/preview/builder.rs | 29 +++++++--- 7 files changed, 263 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 7e7e8b3..5d3d2a0 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ directory applies to that directory and its descendants. - [x] Toggle hidden files - [x] Toggle gitignored files - [x] Custom `.swpignore` files -- [ ] Focus pane with mouse +- [x] Mouse support - [ ] Glob to include/exclude files ## Credits diff --git a/src/app.rs b/src/app.rs index f6a9cfd..3040916 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use std::{ + io::stdout, path::PathBuf, sync::{Arc, RwLock, atomic::AtomicBool, mpsc}, thread, @@ -8,7 +9,10 @@ use std::{ use rat_widget::{list::ListState, text_input::TextInputState}; use ratatui::{ DefaultTerminal, Frame, - crossterm::event::{self, Event, KeyEventKind}, + crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}, + execute, + }, }; use crate::{ @@ -16,7 +20,7 @@ use crate::{ preview::{PreviewCommand, PreviewResult, PreviewWorker, WantedSet}, search::{FileMatches, SearchResult, SearchWorker, WorkerCommand}, spinner::SpinnerState, - types::Pane, + types::{Pane, PaneAreas}, ui::{self, preview::PreviewState}, }; @@ -27,6 +31,22 @@ pub mod search; const POLL_TIMEOUT: Duration = Duration::from_millis(16); +/// RAII guard that enables mouse capture for its lifetime and disables it on drop. +struct MouseCapture; + +impl MouseCapture { + fn enable() -> anyhow::Result { + execute!(stdout(), EnableMouseCapture)?; + Ok(Self) + } +} + +impl Drop for MouseCapture { + fn drop(&mut self) { + let _ = execute!(stdout(), DisableMouseCapture); + } +} + #[expect(clippy::struct_excessive_bools)] pub struct App { pub root: PathBuf, @@ -37,6 +57,7 @@ pub struct App { pub preview: PreviewState, pub spinner: SpinnerState, pub focused_pane: Pane, + pub pane_areas: PaneAreas, pub status_message: Option, pub searching: bool, pub truncated: bool, @@ -83,6 +104,7 @@ impl App { options, results: Vec::new(), focused_pane: Pane::default(), + pane_areas: PaneAreas::default(), file_list: ListState::default(), status_message: warning, searching: false, @@ -106,6 +128,7 @@ impl App { } pub fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> { + let _mouse = MouseCapture::enable()?; while !self.exit { terminal.draw(|frame| self.draw(frame))?; self.poll_events()?; @@ -141,11 +164,12 @@ impl App { } fn poll_events(&mut self) -> anyhow::Result<()> { - if event::poll(POLL_TIMEOUT)? - && let Event::Key(key) = event::read()? - && key.kind == KeyEventKind::Press - { - self.handle_key(key); + if event::poll(POLL_TIMEOUT)? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key(key), + Event::Mouse(mouse) => self.handle_mouse(mouse), + _ => {} + } } Ok(()) } diff --git a/src/app/input.rs b/src/app/input.rs index 11ebaa5..f8f4c53 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1,5 +1,10 @@ use rat_widget::{event::TextOutcome, text_input}; -use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + crossterm::event::{ + Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + }, + layout::Position, +}; use crate::{app::App, types::Pane, ui::preview}; @@ -84,6 +89,20 @@ impl App { } } + pub fn handle_mouse(&mut self, mouse: MouseEvent) { + // modals swallow mouse input, mirroring key handling + if self.confirm_apply_all || self.options_open { + return; + } + let pos = Position::new(mouse.column, mouse.row); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => self.handle_click(pos, mouse), + MouseEventKind::ScrollDown => self.handle_scroll(pos, ScrollDir::Down), + MouseEventKind::ScrollUp => self.handle_scroll(pos, ScrollDir::Up), + _ => {} + } + } + fn handle_options_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -117,6 +136,86 @@ impl App { } } + fn select_next_file(&mut self) { + if self.results.is_empty() { + return; + } + let next = (self.selected_file() + 1).min(self.results.len() - 1); + self.file_list.select(Some(next)); + self.preview.reset_position(); + self.dispatch_preview(); + } + + fn select_prev_file(&mut self) { + let prev = self.selected_file().saturating_sub(1); + self.file_list.select(Some(prev)); + self.preview.reset_position(); + self.dispatch_preview(); + } + + fn handle_click(&mut self, pos: Position, mouse: MouseEvent) { + let Some(pane) = self.pane_areas.pane_at(pos) else { + return; + }; + // during search, only input panes are focusable (mirrors Tab) + if self.searching && !pane.is_input() { + return; + } + self.focused_pane = pane; + match pane { + Pane::SearchInput => { + text_input::handle_events(&mut self.search_input, true, &Event::Mouse(mouse)); + } + Pane::ReplaceInput => { + text_input::handle_events(&mut self.replace_input, true, &Event::Mouse(mouse)); + } + Pane::FileList => { + if let Some(idx) = self.file_list.row_at_clicked((pos.x, pos.y)) + && Some(idx) != self.file_list.selected() + { + self.file_list.select(Some(idx)); + self.preview.reset_position(); + self.dispatch_preview(); + } + } + Pane::Preview => { + if let Some(idx) = self.preview.match_at(pos) { + self.preview.select_match(idx); + } + } + } + } + + fn handle_scroll(&mut self, pos: Position, dir: ScrollDir) { + let Some(pane) = self.pane_areas.pane_at(pos) else { + return; + }; + if self.searching && !pane.is_input() { + return; + } + match pane { + Pane::FileList => { + if matches!(dir, ScrollDir::Down) { + self.select_next_file(); + } else { + self.select_prev_file(); + } + } + Pane::Preview => { + let count = self + .results + .get(self.selected_file()) + .map_or(0, |fm| fm.matches.len()); + if matches!(dir, ScrollDir::Down) { + self.preview.move_down(count); + } else { + self.preview.move_up(); + } + } + Pane::SearchInput | Pane::ReplaceInput => {} + } + } + fn handle_non_input_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('q') => self.exit = true, @@ -139,17 +238,11 @@ impl App { return; } KeyCode::Char('j') | KeyCode::Down if !self.results.is_empty() => { - let next = (self.selected_file() + 1).min(self.results.len() - 1); - self.file_list.select(Some(next)); - self.preview.reset_position(); - self.dispatch_preview(); + self.select_next_file(); return; } KeyCode::Char('k') | KeyCode::Up => { - let prev = self.selected_file().saturating_sub(1); - self.file_list.select(Some(prev)); - self.preview.reset_position(); - self.dispatch_preview(); + self.select_prev_file(); return; } KeyCode::Char('l') | KeyCode::Enter | KeyCode::Right if !self.results.is_empty() => { @@ -184,3 +277,9 @@ impl App { } } } + +#[derive(Clone, Copy)] +enum ScrollDir { + Up, + Down, +} diff --git a/src/types.rs b/src/types.rs index 60af2b3..fcd84d7 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,3 +1,5 @@ +use ratatui::layout::{Position, Rect}; + /// A half-open `[start, end)` byte range within a file's content. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ByteRange { @@ -104,10 +106,52 @@ impl Pane { } } +#[derive(Debug, Clone, Copy, Default)] +pub struct PaneAreas { + pub search_input: Rect, + pub replace_input: Rect, + pub file_list: Rect, + pub preview: Rect, +} + +impl PaneAreas { + /// Return the pane whose rectangle contains `pos`, if any. + #[must_use] + pub fn pane_at(&self, pos: Position) -> Option { + if self.search_input.contains(pos) { + Some(Pane::SearchInput) + } else if self.replace_input.contains(pos) { + Some(Pane::ReplaceInput) + } else if self.file_list.contains(pos) { + Some(Pane::FileList) + } else if self.preview.contains(pos) { + Some(Pane::Preview) + } else { + None + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn pane_at_hits_correct_pane() { + use ratatui::layout::{Position, Rect}; + let areas = PaneAreas { + search_input: Rect::new(0, 0, 10, 3), + replace_input: Rect::new(0, 3, 10, 3), + file_list: Rect::new(0, 6, 10, 10), + preview: Rect::new(10, 0, 20, 16), + }; + assert_eq!(areas.pane_at(Position::new(5, 1)), Some(Pane::SearchInput)); + assert_eq!(areas.pane_at(Position::new(5, 4)), Some(Pane::ReplaceInput)); + assert_eq!(areas.pane_at(Position::new(5, 10)), Some(Pane::FileList)); + assert_eq!(areas.pane_at(Position::new(15, 5)), Some(Pane::Preview)); + assert_eq!(areas.pane_at(Position::new(50, 50)), None); + } + #[test] fn pane_cycle_forward() { let mut pane = Pane::SearchInput; diff --git a/src/ui.rs b/src/ui.rs index b29906c..459f367 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,7 +8,12 @@ use ratatui::{ widgets::{Block, Clear, Paragraph, StatefulWidget as _}, }; -use crate::{app::App, replace::effective_replacement, types::Pane, ui::preview::Preview}; +use crate::{ + app::App, + replace::effective_replacement, + types::{Pane, PaneAreas}, + ui::preview::Preview, +}; mod file_list; pub mod preview; @@ -39,8 +44,17 @@ pub fn render(app: &mut App, frame: &mut Frame) { // left column: input area + file list let [input_area, file_area] = Layout::vertical([Constraint::Length(6), Constraint::Fill(1)]).areas(left); + let [search_area, replace_area] = + Layout::vertical([Constraint::Length(3), Constraint::Length(3)]).areas(input_area); + + app.pane_areas = PaneAreas { + search_input: search_area, + replace_input: replace_area, + file_list: file_area, + preview: right, + }; - render_input_area(app, frame, input_area); + render_input_area(app, frame, search_area, replace_area); file_list::render(app, frame, file_area); render_preview(app, frame, right); render_status_bar(app, frame, status_area, hints_area); @@ -75,10 +89,7 @@ fn focused_border_style(pane: Pane, current: Pane) -> Style { } } -fn render_input_area(app: &mut App, frame: &mut Frame, area: Rect) { - let [search_area, replace_area] = - Layout::vertical([Constraint::Length(3), Constraint::Length(3)]).areas(area); - +fn render_input_area(app: &mut App, frame: &mut Frame, search_area: Rect, replace_area: Rect) { let mode_label = format!( "\u{2500}[{}]\u{2500}Search ({})", Pane::SearchInput.digit(), diff --git a/src/ui/preview.rs b/src/ui/preview.rs index 5245b41..d35b953 100644 --- a/src/ui/preview.rs +++ b/src/ui/preview.rs @@ -2,6 +2,7 @@ mod builder; use std::{ collections::HashMap, + ops::Range, path::{Path, PathBuf}, sync::Arc, }; @@ -10,15 +11,19 @@ use rat_widget::scrolled::{Scroll, ScrollArea, ScrollAreaState, ScrollState}; use ratatui::{ buffer::{Buffer, CellWidth as _}, crossterm::event::{KeyCode, KeyEvent, KeyEventKind}, - layout::{Alignment, Rect}, + layout::{Alignment, Position, Rect}, style::{Color, Style}, symbols::border, widgets::{Block, Paragraph, StatefulWidget, Widget as _}, }; use crate::{ - config::MatchMode, preview::data::PreviewData, search::FileMatches, types::Pane, - ui::preview::builder::PreviewBuilder, utils::trim_start_to_width, + config::MatchMode, + preview::data::PreviewData, + search::FileMatches, + types::Pane, + ui::preview::builder::{Layout, PreviewBuilder}, + utils::trim_start_to_width, }; /// Per-frame mutable state for the [`Preview`] widget. @@ -33,6 +38,10 @@ pub struct PreviewState { /// Max value for `line_offset`, recomputed during each render. line_offset_max: usize, selected_match: usize, + /// Inner content area of the preview (excludes the border), set during render. + inner: Rect, + /// Line range covered by each match, indexed by match, set during render. + match_ranges: Vec>, } impl PreviewState { @@ -46,6 +55,24 @@ impl PreviewState { self.selected_match } + /// Map a screen position to the index of the match rendered there, if any. + /// + /// Returns `None` for positions outside the content area or on a separator line between + /// matches. + pub fn match_at(&self, pos: Position) -> Option { + if !self.inner.contains(pos) { + return None; + } + let line = self.scroll.offset + usize::from(pos.y - self.inner.y); + self.match_ranges.iter().position(|r| r.contains(&line)) + } + + /// Select the match at `idx` and reset the intra-match line offset. + pub fn select_match(&mut self, idx: usize) { + self.selected_match = idx; + self.line_offset = 0; + } + /// Reset selection and scroll position. /// /// Called after navigating to a different file or match. @@ -132,7 +159,7 @@ impl PreviewState { /// Move the selection down by one row: scroll within the current match if room remains, otherwise advance /// to the next match (clamped to `match_count`). - fn move_down(&mut self, match_count: usize) { + pub fn move_down(&mut self, match_count: usize) { if match_count == 0 { return; } @@ -149,7 +176,7 @@ impl PreviewState { /// Move the selection up by one row: scroll within the current match if scrolled, otherwise step back /// to the previous match (and scroll to its bottom). - fn move_up(&mut self) { + pub fn move_up(&mut self) { if self.line_offset > 0 { self.line_offset -= 1; } else { @@ -254,7 +281,16 @@ impl StatefulWidget for Preview<'_> { inner.width, ); - let (total_lines, selected_range) = builder.layout(); + let Layout { + total_lines, + match_ranges, + } = builder.layout(); + let selected_range = match_ranges + .get(state.selected_match) + .cloned() + .unwrap_or(0..0); + state.match_ranges = match_ranges; + state.inner = inner; // compute how far we can scroll within the selected match let selected_height = selected_range.end - selected_range.start; diff --git a/src/ui/preview/builder.rs b/src/ui/preview/builder.rs index 5b49359..d09fc7c 100644 --- a/src/ui/preview/builder.rs +++ b/src/ui/preview/builder.rs @@ -137,6 +137,15 @@ impl From for Line<'static> { } } +/// Line layout of the preview +pub struct Layout { + /// Total height of all matches + pub total_lines: usize, + + /// Line range covered by each match + pub match_ranges: Vec>, +} + pub struct PreviewBuilder<'a> { matches: &'a [MatchInfo], data: &'a PreviewData, @@ -170,12 +179,13 @@ impl<'a> PreviewBuilder<'a> { } } - /// Total preview line count and the [start, end) range covered by the selected match. + /// Total preview line count and the [start, end) line range of every match, indexed by match. /// - /// The selected range is `0..0` when the preview is unfocused. - pub fn layout(&self) -> (usize, Range) { + /// Ranges are computed for all matches regardless of focus so the viewport can follow the + /// selected match even when the preview is not focused. + pub fn layout(&self) -> Layout { let mut total_lines = 0; - let mut selected_range: Range = 0..0; + let mut match_ranges = Vec::with_capacity(self.matches.len()); for (match_idx, (info, preview)) in self .matches .iter() @@ -185,13 +195,14 @@ impl<'a> PreviewBuilder<'a> { if match_idx > 0 { total_lines += 1; // separator } - let match_start = total_lines; + let start = total_lines; total_lines += preview.line_count(info, self.replacement); - if self.selected == Some(match_idx) { - selected_range = match_start..total_lines; - } + match_ranges.push(start..total_lines); + } + Layout { + total_lines, + match_ranges, } - (total_lines, selected_range) } /// Generate the set of lines for the preview. From b701e90f7a9ae5b17fe79b7e58d6ca39ce3acf36 Mon Sep 17 00:00:00 2001 From: beeb <703631+beeb@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:16:01 +0200 Subject: [PATCH 2/2] feat: show select match in preview when unfocused --- src/ui/preview/builder.rs | 58 +++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/src/ui/preview/builder.rs b/src/ui/preview/builder.rs index d09fc7c..6c03ab4 100644 --- a/src/ui/preview/builder.rs +++ b/src/ui/preview/builder.rs @@ -63,6 +63,7 @@ impl Gutter { line_number: Option, is_selected: bool, is_skipped: bool, + focused: bool, ) -> Vec> { let line_nb_style = if is_skipped { Style::default().dim() @@ -74,12 +75,13 @@ impl Gutter { }) }; let (ind, ind_style) = if is_selected { - ( - ">", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) + let style = Style::default().fg(Color::Yellow); + let style = if focused { + style.add_modifier(Modifier::BOLD) + } else { + style + }; + (">", style) } else { (" ", Style::default()) }; @@ -105,7 +107,7 @@ impl Gutter { /// One rendered preview line that belongs to a match (matched, removed, added, or skipped). struct MatchLine { spans: Vec>, - is_selected: bool, + bold: bool, } impl MatchLine { @@ -114,10 +116,11 @@ impl MatchLine { line_number: Option, is_selected: bool, is_skipped: bool, + focused: bool, ) -> Self { Self { - spans: gutter.match_spans(line_number, is_selected, is_skipped), - is_selected, + spans: gutter.match_spans(line_number, is_selected, is_skipped, focused), + bold: is_selected && focused, } } @@ -128,7 +131,7 @@ impl MatchLine { impl From for Line<'static> { fn from(mut ml: MatchLine) -> Self { - if ml.is_selected { + if ml.bold { for span in &mut ml.spans { span.style = span.style.add_modifier(Modifier::BOLD); } @@ -152,6 +155,7 @@ pub struct PreviewBuilder<'a> { replacement: &'a str, mode: MatchMode, selected: Option, + focused: bool, inner_width: u16, gutter: Gutter, } @@ -167,13 +171,13 @@ impl<'a> PreviewBuilder<'a> { inner_width: u16, ) -> Self { let gutter = Gutter::new(data); - let selected = is_preview_focused.then_some(selected_match); Self { matches, data, replacement, mode, - selected, + selected: Some(selected_match), + focused: is_preview_focused, inner_width, gutter, } @@ -293,7 +297,13 @@ impl<'a> PreviewBuilder<'a> { let dim = Style::default().dim(); let content_max = self.gutter.content_max_width(self.inner_width); - let mut ml = MatchLine::new(&self.gutter, Some(*line_number), is_selected, info.skip); + let mut ml = MatchLine::new( + &self.gutter, + Some(*line_number), + is_selected, + info.skip, + self.focused, + ); if info.skip { let t = TruncatedLine::new(before, matched, None, after, content_max); @@ -376,7 +386,13 @@ impl<'a> PreviewBuilder<'a> { let make_line = |line_number: Option, marker: &str, content: String, style: Style| { - let mut ml = MatchLine::new(&self.gutter, line_number, is_selected, info.skip); + let mut ml = MatchLine::new( + &self.gutter, + line_number, + is_selected, + info.skip, + self.focused, + ); ml.push(Span::styled(format!("{marker} {content}"), style)); ml.into() }; @@ -583,7 +599,7 @@ mod tests { } #[test] - fn selected_match_line_has_indicator_and_bold() { + fn selected_match_line_indicator() { let matches = vec![make_info()]; let data = PreviewData { matches: vec![make_preview_single("hello world", 0, 5)].into(), @@ -607,18 +623,20 @@ mod tests { } #[test] - fn unselected_match_line_has_blank_indicator() { + fn unfocused_selected_match_indicator() { let matches = vec![make_info()]; let data = PreviewData { matches: vec![make_preview_single("hello world", 0, 5)].into(), size: 0, }; + // test_builder is unfocused: the selection stays visible but the gutter is not bold let builder = test_builder(&matches, &data, "", MatchMode::Literal); let lines = builder.build(0..usize::MAX); let match_line = &lines[0]; - assert_eq!(match_line.spans[0].content.as_ref(), " "); - // before/after spans (Span::raw) should not carry BOLD when the match isn't selected - let before_span = &match_line.spans[2]; - assert!(!before_span.style.add_modifier.contains(Modifier::BOLD)); + let indicator = &match_line.spans[0]; + let line_number = &match_line.spans[1]; + assert_eq!(indicator.content.as_ref(), ">"); + assert!(!indicator.style.add_modifier.contains(Modifier::BOLD)); + assert!(!line_number.style.add_modifier.contains(Modifier::BOLD)); } }