Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 31 additions & 7 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::{
io::stdout,
path::PathBuf,
sync::{Arc, RwLock, atomic::AtomicBool, mpsc},
thread,
Expand All @@ -8,15 +9,18 @@ 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::{
config::{ConfigResult, Options},
preview::{PreviewCommand, PreviewResult, PreviewWorker, WantedSet},
search::{FileMatches, SearchResult, SearchWorker, WorkerCommand},
spinner::SpinnerState,
types::Pane,
types::{Pane, PaneAreas},
ui::{self, preview::PreviewState},
};

Expand All @@ -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<Self> {
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,
Expand All @@ -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<String>,
pub searching: bool,
pub truncated: bool,
Expand Down Expand Up @@ -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,
Expand All @@ -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()?;
Expand Down Expand Up @@ -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(())
}
Expand Down
117 changes: 108 additions & 9 deletions src/app/input.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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() => {
Expand Down Expand Up @@ -184,3 +277,9 @@ impl App {
}
}
}

#[derive(Clone, Copy)]
enum ScrollDir {
Up,
Down,
}
44 changes: 44 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<Pane> {
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;
Expand Down
23 changes: 17 additions & 6 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading