diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index 313104fe07..6990328d59 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -111,7 +111,15 @@ impl Application { }; let key = match event { TermEvent::Key(k) => translate_key(k), - TermEvent::Mouse(_) => continue, + TermEvent::Mouse(m) => { + // D5: route mouse events through the same + // handle_mouse_event helper used by tests, then + // render and continue (don't fall through to + // the keyboard-dispatch path). + fm.handle_mouse_event(m, tui.size()); + render(&mut tui, &mut fm)?; + continue; + } TermEvent::Unsupported(bytes) => { match crate::terminal::event::parse_unsupported_key(&bytes) { Some(k) => k, diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index aa0ee0d134..dd891aad07 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -382,7 +382,6 @@ impl DialogState { DialogState::ScreenList(_) => false, DialogState::EditHistory(_) => false, DialogState::FilteredView(_) => false, - DialogState::DisplayBits(_) => false, DialogState::VfsSettings(_) => false, DialogState::LearnKeys(_) => false, DialogState::Compare(_) => false, @@ -480,6 +479,71 @@ impl FileManager { } } + /// Handle a mouse event. Mirrors the mouse-routing in MC's + /// `panel.c:4080-4197` (`MSG_MOUSE_DOWN` selects an entry, + /// `MSG_MOUSE_DRAG` extends, `MSG_MOUSE_CLICK` enters, wheel + /// scrolls). For D5 the implementation covers click + scroll; + /// drag-with-mark and double-click are deferred. + /// + /// `size` is the current terminal size (width, height) — the + /// caller reads it from `Tui::size()` and passes it in so + /// `handle_mouse_event` doesn't need a Tui reference. + pub fn handle_mouse_event(&mut self, m: termion::event::MouseEvent, size: (u16, u16)) { + use crate::terminal::event::{translate_mouse, MouseAction}; + let cols = size.0; + // The active panel starts at y=1 (after the menu bar row). + // Panel width is the terminal width minus the inactive + // panel column (0 if panels are hidden, width/2 otherwise). + let panel_width = if self.panels_visible { cols / 2 } else { cols }; + let panel_top = 1u16; + let panel_height = size.1.saturating_sub(2); + let action = translate_mouse(m, panel_top, panel_height, panel_width); + match action { + MouseAction::Click { row, col: _ } => { + // The Panel API exposes relative cursor moves + // (cursor_up_n / cursor_down_n) but not an absolute + // setter. Compute the relative delta and apply it. + let p = self.active_panel_mut(); + if (row as usize) < p.entries().len() { + let current = p.cursor(); + if row as usize > current { + p.cursor_down_n(row as usize - current); + } else if (row as usize) < current { + p.cursor_up_n(current - row as usize); + } + } + } + MouseAction::DoubleClick { row, .. } => { + let p = self.active_panel_mut(); + if (row as usize) < p.entries().len() { + let current = p.cursor(); + if row as usize > current { + p.cursor_down_n(row as usize - current); + } else if (row as usize) < current { + p.cursor_up_n(current - row as usize); + } + let _ = p.enter(); + } + } + MouseAction::ScrollUp { .. } => { + self.active_panel_mut().cursor_up(); + } + MouseAction::ScrollDown { .. } => { + self.active_panel_mut().cursor_down(); + } + MouseAction::HistoryPrev + | MouseAction::HistoryNext + | MouseAction::HistoryList + | MouseAction::ToggleHidden + | MouseAction::Unhandled => { + // These require dedicated Cmds (ShowHidden, + // HistoryPrev, etc.) that aren't in the keymap yet. + // Deferred to a follow-up — the dialog opens but + // has no wired action. + } + } + } + /// Switch focus to the other panel. pub fn switch_focus(&mut self) { self.active = self.active.other(); diff --git a/local/recipes/tui/tlc/source/src/terminal/event.rs b/local/recipes/tui/tlc/source/src/terminal/event.rs index 0e733b45df..3627b688af 100644 --- a/local/recipes/tui/tlc/source/src/terminal/event.rs +++ b/local/recipes/tui/tlc/source/src/terminal/event.rs @@ -375,3 +375,223 @@ mod tests { assert_eq!(k.code, 0xF103); } } + +// ========================================================================= +// Mouse event translation (D5). +// +// Mirrors MC's panel.c:4080-4197 which handles: +// - MSG_MOUSE_DOWN: select item, mark if shift held +// - MSG_MOUSE_DRAG: same as DOWN (with mark) +// - MSG_MOUSE_UP: nothing +// - MSG_MOUSE_CLICK (double): open the file under cursor +// - MSG_MOUSE_SCROLL_UP / DOWN: page or step +// - MSG_MOUSE_MOVE: nothing +// +// TLC's translation is simpler: we compute the row/column under the +// mouse and emit the same Cmd actions the keyboard path uses, so +// every mouse interaction goes through the existing dispatcher. +// ========================================================================= + +/// What to do with a mouse event. The caller dispatches the +/// corresponding `Cmd` to the filemanager (just like keyboard +/// input). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MouseAction { + /// Left-click on a file item — select it. + Click { row: u16, col: u16 }, + /// Double-click on a file item — enter the item. + DoubleClick { row: u16, col: u16 }, + /// Scroll up by one row, or one page if `pages` is true. + ScrollUp { pages: bool }, + /// Scroll down by one row, or one page if `pages` is true. + ScrollDown { pages: bool }, + /// Click on the "<" history button (top of panel). + HistoryPrev, + /// Click on the ">" history button. + HistoryNext, + /// Click on the "^" history list button. + HistoryList, + /// Click on the "." show/hide hidden button. + ToggleHidden, + /// Event not handled — propagate to the rest of the stack. + Unhandled, +} + +/// Translate a termion [`MouseEvent`] into a [`MouseAction`]. +/// +/// `panel_top` is the y-coordinate (in cells) of the top of the +/// panel (after the menu bar). `panel_height` is the visible row +/// count. `cols` is the panel width in cells (used to detect the +/// top-frame "history" / "hidden" buttons at the right edge). +pub fn translate_mouse( + event: termion::event::MouseEvent, + panel_top: u16, + panel_height: u16, + panel_width: u16, +) -> MouseAction { + use termion::event::{MouseButton, MouseEvent}; + + // MC's panel layout (panel.c:3969): + // row 0: header bar with <, >, ^, . buttons at right edge + // row 1: column-name line (click to sort) + // row 2..: file entries + // row last: status/footer + // + // TLC's layout is the same (header + column-name + entries). + // The caller passes panel_top (y of header row) so we compute + // relative y. termion 4.x MouseEvent is a tuple variant: + // Press(MouseButton, col, row) + // Release(col, row) + // Hold(MouseButton, col, row) + let (y, x) = match event { + MouseEvent::Press(_, col, row) => (row, col), + MouseEvent::Release(col, row) => (row, col), + MouseEvent::Hold(col, row) => (row, col), + }; + let y = y.saturating_sub(panel_top); + + // Top-frame buttons: < at col 1, > at col width-2, ^ at + // width-5..width-3, . at width-6. Matches panel.c:4088-4099. + if y == 0 { + if x == 1 { + return MouseAction::HistoryPrev; + } + if x == panel_width.saturating_sub(2) { + return MouseAction::HistoryNext; + } + if x + 2 >= panel_width.saturating_sub(2) && x + 4 <= panel_width.saturating_sub(2) { + return MouseAction::HistoryList; + } + if x + 6 == panel_width { + return MouseAction::ToggleHidden; + } + return MouseAction::Unhandled; + } + + // Column-name line: sort by that column. + // (TLC's sort-by-column-click isn't yet wired to a Cmd; treat + // as Unhandled for now so the click does nothing harmful.) + if y == 1 { + return MouseAction::Unhandled; + } + + // Past the header (y >= 2) is the file-list area. y=0 is the + // title bar, y=1 is the column header, y=2..2+panel_height are + // the entries. + match event { + MouseEvent::Press(MouseButton::WheelUp, ..) => { + // MC: "if mouse_move_pages, prev_page(); else move_up()". + // We default to single-row; pages is set by the caller + // if a modifier is held. (Currently: false.) + MouseAction::ScrollUp { pages: false } + } + MouseEvent::Press(MouseButton::WheelDown, ..) => { + MouseAction::ScrollDown { pages: false } + } + MouseEvent::Press(MouseButton::Left, ..) => { + // Single click — select. The caller dispatches a Cmd + // like "SetCursorToEntry(row)" using the y. + let row = y.saturating_sub(2); + if row < panel_height { + MouseAction::Click { row, col: x } + } else { + MouseAction::Unhandled + } + } + MouseEvent::Release(..) => { + // termion encodes double-click as two consecutive + // Release events with the same coordinates. Detect that + // pattern: we can't see the previous event here (we'd + // need a small state machine), so we conservatively + // treat Release as Unhandled and let the Press handler + // emit Click. (MC has a more elaborate state machine in + // panel.c:4161-4165.) + MouseAction::Unhandled + } + _ => MouseAction::Unhandled, + } +} + +#[cfg(test)] +mod mouse_tests { + use super::*; + use termion::event::{MouseButton, MouseEvent}; + + #[test] + fn click_on_file_row_emits_click_action() { + let ev = MouseEvent::Press(MouseButton::Left, 5, 5); + let action = translate_mouse(ev, 0, 20, 80); + // y=5 (absolute) - panel_top=0 = 5; subtract 2 (title + col header) = 3 + assert!(matches!(action, MouseAction::Click { row: 3, col: 5 })); + } + + #[test] + fn click_below_panel_is_unhandled() { + let ev = MouseEvent::Press(MouseButton::Left, 5, 100); + let action = translate_mouse(ev, 0, 20, 80); + assert_eq!(action, MouseAction::Unhandled); + } + + #[test] + fn click_on_header_history_prev_button() { + // MC panel.c:4088 — < button is at col 1. + let ev = MouseEvent::Press(MouseButton::Left, 1, 0); + let action = translate_mouse(ev, 0, 20, 80); + assert_eq!(action, MouseAction::HistoryPrev); + } + + #[test] + fn click_on_header_history_next_button() { + // MC panel.c:4091 — > button is at col width-2. + let ev = MouseEvent::Press(MouseButton::Left, 78, 0); + let action = translate_mouse(ev, 0, 20, 80); + assert_eq!(action, MouseAction::HistoryNext); + } + + #[test] + fn click_on_header_toggle_hidden_button() { + // MC panel.c:4097 — . button is at col width-6. + let ev = MouseEvent::Press(MouseButton::Left, 74, 0); + let action = translate_mouse(ev, 0, 20, 80); + assert_eq!(action, MouseAction::ToggleHidden); + } + + #[test] + fn scroll_up_emits_scroll_up() { + let ev = MouseEvent::Press(MouseButton::WheelUp, 5, 5); + let action = translate_mouse(ev, 0, 20, 80); + assert!(matches!( + action, + MouseAction::ScrollUp { pages: false } + )); + } + + #[test] + fn scroll_down_emits_scroll_down() { + let ev = MouseEvent::Press(MouseButton::WheelDown, 5, 5); + let action = translate_mouse(ev, 0, 20, 80); + assert!(matches!( + action, + MouseAction::ScrollDown { pages: false } + )); + } + + #[test] + fn click_on_column_name_row_is_unhandled() { + // MC's sort-by-column-click isn't yet wired in TLC. + let ev = MouseEvent::Press(MouseButton::Left, 5, 1); + let action = translate_mouse(ev, 0, 20, 80); + assert_eq!(action, MouseAction::Unhandled); + } + + #[test] + fn click_respects_panel_top_offset() { + // Caller has a menu bar above the panel; the panel starts + // at y=3. A click at absolute y=5 is row 2 of the panel + // (header at y=3, column-name at y=4, entries from y=5). + let ev = MouseEvent::Press(MouseButton::Left, 5, 5); + let action = translate_mouse(ev, 3, 20, 80); + // y=5 with panel_top=3 -> relative y=2 -> file row 0 + assert!(matches!(action, MouseAction::Click { row: 0, col: 5 })); + } +} diff --git a/local/recipes/tui/tlc/source/src/terminal/mod.rs b/local/recipes/tui/tlc/source/src/terminal/mod.rs index 6015f25da8..1f652499d5 100644 --- a/local/recipes/tui/tlc/source/src/terminal/mod.rs +++ b/local/recipes/tui/tlc/source/src/terminal/mod.rs @@ -22,12 +22,13 @@ use ratatui::backend::TermionBackend; use ratatui::Terminal; use rustix::fd::AsFd; +use termion::input::MouseTerminal; use termion::raw::IntoRawMode; use termion::raw::RawTerminal; use termion::screen::AlternateScreen; use termion::screen::IntoAlternateScreen; -type Screen = AlternateScreen>; +type Screen = MouseTerminal>>; /// Terminal backend type used by TLC. pub type Backend = TermionBackend; @@ -78,7 +79,12 @@ impl Tui { let screen = raw .into_alternate_screen() .map_err(|e| anyhow::anyhow!("alternate screen: {e}"))?; - let backend = TermionBackend::new(screen); + // MouseTerminal enables mouse event capture (GPM on Linux, + // xterm mouse on others). Required for D5 (mouse support). + // Without this wrapper the kernel drops mouse events at the + // TTY layer and ratatui never sees them. + let mouse_screen = MouseTerminal::from(screen); + let backend = TermionBackend::new(mouse_screen); let terminal = Terminal::new(backend).map_err(|e| anyhow::anyhow!("ratatui init: {e}"))?; set_panic_hook();