Skip to content

Commit 1bc54c0

Browse files
authored
feat: mouse support (#55)
1 parent 48db4a3 commit 1bc54c0

7 files changed

Lines changed: 301 additions & 58 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ directory applies to that directory and its descendants.
161161
- [x] Toggle hidden files
162162
- [x] Toggle gitignored files
163163
- [x] Custom `.swpignore` files
164-
- [ ] Focus pane with mouse
164+
- [x] Mouse support
165165
- [ ] Glob to include/exclude files
166166

167167
## Credits

src/app.rs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::{
2+
io::stdout,
23
path::PathBuf,
34
sync::{Arc, RwLock, atomic::AtomicBool, mpsc},
45
thread,
@@ -8,15 +9,18 @@ use std::{
89
use rat_widget::{list::ListState, text_input::TextInputState};
910
use ratatui::{
1011
DefaultTerminal, Frame,
11-
crossterm::event::{self, Event, KeyEventKind},
12+
crossterm::{
13+
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
14+
execute,
15+
},
1216
};
1317

1418
use crate::{
1519
config::{ConfigResult, Options},
1620
preview::{PreviewCommand, PreviewResult, PreviewWorker, WantedSet},
1721
search::{FileMatches, SearchResult, SearchWorker, WorkerCommand},
1822
spinner::SpinnerState,
19-
types::Pane,
23+
types::{Pane, PaneAreas},
2024
ui::{self, preview::PreviewState},
2125
};
2226

@@ -27,6 +31,22 @@ pub mod search;
2731

2832
const POLL_TIMEOUT: Duration = Duration::from_millis(16);
2933

34+
/// RAII guard that enables mouse capture for its lifetime and disables it on drop.
35+
struct MouseCapture;
36+
37+
impl MouseCapture {
38+
fn enable() -> anyhow::Result<Self> {
39+
execute!(stdout(), EnableMouseCapture)?;
40+
Ok(Self)
41+
}
42+
}
43+
44+
impl Drop for MouseCapture {
45+
fn drop(&mut self) {
46+
let _ = execute!(stdout(), DisableMouseCapture);
47+
}
48+
}
49+
3050
#[expect(clippy::struct_excessive_bools)]
3151
pub struct App {
3252
pub root: PathBuf,
@@ -37,6 +57,7 @@ pub struct App {
3757
pub preview: PreviewState,
3858
pub spinner: SpinnerState,
3959
pub focused_pane: Pane,
60+
pub pane_areas: PaneAreas,
4061
pub status_message: Option<String>,
4162
pub searching: bool,
4263
pub truncated: bool,
@@ -83,6 +104,7 @@ impl App {
83104
options,
84105
results: Vec::new(),
85106
focused_pane: Pane::default(),
107+
pane_areas: PaneAreas::default(),
86108
file_list: ListState::default(),
87109
status_message: warning,
88110
searching: false,
@@ -106,6 +128,7 @@ impl App {
106128
}
107129

108130
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
131+
let _mouse = MouseCapture::enable()?;
109132
while !self.exit {
110133
terminal.draw(|frame| self.draw(frame))?;
111134
self.poll_events()?;
@@ -141,11 +164,12 @@ impl App {
141164
}
142165

143166
fn poll_events(&mut self) -> anyhow::Result<()> {
144-
if event::poll(POLL_TIMEOUT)?
145-
&& let Event::Key(key) = event::read()?
146-
&& key.kind == KeyEventKind::Press
147-
{
148-
self.handle_key(key);
167+
if event::poll(POLL_TIMEOUT)? {
168+
match event::read()? {
169+
Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key(key),
170+
Event::Mouse(mouse) => self.handle_mouse(mouse),
171+
_ => {}
172+
}
149173
}
150174
Ok(())
151175
}

src/app/input.rs

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
use rat_widget::{event::TextOutcome, text_input};
2-
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
2+
use ratatui::{
3+
crossterm::event::{
4+
Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
5+
},
6+
layout::Position,
7+
};
38

49
use crate::{app::App, types::Pane, ui::preview};
510

@@ -84,6 +89,20 @@ impl App {
8489
}
8590
}
8691

92+
pub fn handle_mouse(&mut self, mouse: MouseEvent) {
93+
// modals swallow mouse input, mirroring key handling
94+
if self.confirm_apply_all || self.options_open {
95+
return;
96+
}
97+
let pos = Position::new(mouse.column, mouse.row);
98+
match mouse.kind {
99+
MouseEventKind::Down(MouseButton::Left) => self.handle_click(pos, mouse),
100+
MouseEventKind::ScrollDown => self.handle_scroll(pos, ScrollDir::Down),
101+
MouseEventKind::ScrollUp => self.handle_scroll(pos, ScrollDir::Up),
102+
_ => {}
103+
}
104+
}
105+
87106
fn handle_options_key(&mut self, key: KeyEvent) {
88107
match key.code {
89108
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
@@ -117,6 +136,86 @@ impl App {
117136
}
118137
}
119138

139+
fn select_next_file(&mut self) {
140+
if self.results.is_empty() {
141+
return;
142+
}
143+
let next = (self.selected_file() + 1).min(self.results.len() - 1);
144+
self.file_list.select(Some(next));
145+
self.preview.reset_position();
146+
self.dispatch_preview();
147+
}
148+
149+
fn select_prev_file(&mut self) {
150+
let prev = self.selected_file().saturating_sub(1);
151+
self.file_list.select(Some(prev));
152+
self.preview.reset_position();
153+
self.dispatch_preview();
154+
}
155+
156+
fn handle_click(&mut self, pos: Position, mouse: MouseEvent) {
157+
let Some(pane) = self.pane_areas.pane_at(pos) else {
158+
return;
159+
};
160+
// during search, only input panes are focusable (mirrors Tab)
161+
if self.searching && !pane.is_input() {
162+
return;
163+
}
164+
self.focused_pane = pane;
165+
match pane {
166+
Pane::SearchInput => {
167+
text_input::handle_events(&mut self.search_input, true, &Event::Mouse(mouse));
168+
}
169+
Pane::ReplaceInput => {
170+
text_input::handle_events(&mut self.replace_input, true, &Event::Mouse(mouse));
171+
}
172+
Pane::FileList => {
173+
if let Some(idx) = self.file_list.row_at_clicked((pos.x, pos.y))
174+
&& Some(idx) != self.file_list.selected()
175+
{
176+
self.file_list.select(Some(idx));
177+
self.preview.reset_position();
178+
self.dispatch_preview();
179+
}
180+
}
181+
Pane::Preview => {
182+
if let Some(idx) = self.preview.match_at(pos) {
183+
self.preview.select_match(idx);
184+
}
185+
}
186+
}
187+
}
188+
189+
fn handle_scroll(&mut self, pos: Position, dir: ScrollDir) {
190+
let Some(pane) = self.pane_areas.pane_at(pos) else {
191+
return;
192+
};
193+
if self.searching && !pane.is_input() {
194+
return;
195+
}
196+
match pane {
197+
Pane::FileList => {
198+
if matches!(dir, ScrollDir::Down) {
199+
self.select_next_file();
200+
} else {
201+
self.select_prev_file();
202+
}
203+
}
204+
Pane::Preview => {
205+
let count = self
206+
.results
207+
.get(self.selected_file())
208+
.map_or(0, |fm| fm.matches.len());
209+
if matches!(dir, ScrollDir::Down) {
210+
self.preview.move_down(count);
211+
} else {
212+
self.preview.move_up();
213+
}
214+
}
215+
Pane::SearchInput | Pane::ReplaceInput => {}
216+
}
217+
}
218+
120219
fn handle_non_input_key(&mut self, key: KeyEvent) {
121220
match key.code {
122221
KeyCode::Char('q') => self.exit = true,
@@ -139,17 +238,11 @@ impl App {
139238
return;
140239
}
141240
KeyCode::Char('j') | KeyCode::Down if !self.results.is_empty() => {
142-
let next = (self.selected_file() + 1).min(self.results.len() - 1);
143-
self.file_list.select(Some(next));
144-
self.preview.reset_position();
145-
self.dispatch_preview();
241+
self.select_next_file();
146242
return;
147243
}
148244
KeyCode::Char('k') | KeyCode::Up => {
149-
let prev = self.selected_file().saturating_sub(1);
150-
self.file_list.select(Some(prev));
151-
self.preview.reset_position();
152-
self.dispatch_preview();
245+
self.select_prev_file();
153246
return;
154247
}
155248
KeyCode::Char('l') | KeyCode::Enter | KeyCode::Right if !self.results.is_empty() => {
@@ -184,3 +277,9 @@ impl App {
184277
}
185278
}
186279
}
280+
281+
#[derive(Clone, Copy)]
282+
enum ScrollDir {
283+
Up,
284+
Down,
285+
}

src/types.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use ratatui::layout::{Position, Rect};
2+
13
/// A half-open `[start, end)` byte range within a file's content.
24
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
pub struct ByteRange {
@@ -104,10 +106,52 @@ impl Pane {
104106
}
105107
}
106108

109+
#[derive(Debug, Clone, Copy, Default)]
110+
pub struct PaneAreas {
111+
pub search_input: Rect,
112+
pub replace_input: Rect,
113+
pub file_list: Rect,
114+
pub preview: Rect,
115+
}
116+
117+
impl PaneAreas {
118+
/// Return the pane whose rectangle contains `pos`, if any.
119+
#[must_use]
120+
pub fn pane_at(&self, pos: Position) -> Option<Pane> {
121+
if self.search_input.contains(pos) {
122+
Some(Pane::SearchInput)
123+
} else if self.replace_input.contains(pos) {
124+
Some(Pane::ReplaceInput)
125+
} else if self.file_list.contains(pos) {
126+
Some(Pane::FileList)
127+
} else if self.preview.contains(pos) {
128+
Some(Pane::Preview)
129+
} else {
130+
None
131+
}
132+
}
133+
}
134+
107135
#[cfg(test)]
108136
mod tests {
109137
use super::*;
110138

139+
#[test]
140+
fn pane_at_hits_correct_pane() {
141+
use ratatui::layout::{Position, Rect};
142+
let areas = PaneAreas {
143+
search_input: Rect::new(0, 0, 10, 3),
144+
replace_input: Rect::new(0, 3, 10, 3),
145+
file_list: Rect::new(0, 6, 10, 10),
146+
preview: Rect::new(10, 0, 20, 16),
147+
};
148+
assert_eq!(areas.pane_at(Position::new(5, 1)), Some(Pane::SearchInput));
149+
assert_eq!(areas.pane_at(Position::new(5, 4)), Some(Pane::ReplaceInput));
150+
assert_eq!(areas.pane_at(Position::new(5, 10)), Some(Pane::FileList));
151+
assert_eq!(areas.pane_at(Position::new(15, 5)), Some(Pane::Preview));
152+
assert_eq!(areas.pane_at(Position::new(50, 50)), None);
153+
}
154+
111155
#[test]
112156
fn pane_cycle_forward() {
113157
let mut pane = Pane::SearchInput;

src/ui.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ use ratatui::{
88
widgets::{Block, Clear, Paragraph, StatefulWidget as _},
99
};
1010

11-
use crate::{app::App, replace::effective_replacement, types::Pane, ui::preview::Preview};
11+
use crate::{
12+
app::App,
13+
replace::effective_replacement,
14+
types::{Pane, PaneAreas},
15+
ui::preview::Preview,
16+
};
1217

1318
mod file_list;
1419
pub mod preview;
@@ -39,8 +44,17 @@ pub fn render(app: &mut App, frame: &mut Frame) {
3944
// left column: input area + file list
4045
let [input_area, file_area] =
4146
Layout::vertical([Constraint::Length(6), Constraint::Fill(1)]).areas(left);
47+
let [search_area, replace_area] =
48+
Layout::vertical([Constraint::Length(3), Constraint::Length(3)]).areas(input_area);
49+
50+
app.pane_areas = PaneAreas {
51+
search_input: search_area,
52+
replace_input: replace_area,
53+
file_list: file_area,
54+
preview: right,
55+
};
4256

43-
render_input_area(app, frame, input_area);
57+
render_input_area(app, frame, search_area, replace_area);
4458
file_list::render(app, frame, file_area);
4559
render_preview(app, frame, right);
4660
render_status_bar(app, frame, status_area, hints_area);
@@ -75,10 +89,7 @@ fn focused_border_style(pane: Pane, current: Pane) -> Style {
7589
}
7690
}
7791

78-
fn render_input_area(app: &mut App, frame: &mut Frame, area: Rect) {
79-
let [search_area, replace_area] =
80-
Layout::vertical([Constraint::Length(3), Constraint::Length(3)]).areas(area);
81-
92+
fn render_input_area(app: &mut App, frame: &mut Frame, search_area: Rect, replace_area: Rect) {
8293
let mode_label = format!(
8394
"\u{2500}[{}]\u{2500}Search ({})",
8495
Pane::SearchInput.digit(),

0 commit comments

Comments
 (0)