tlc: D5 — Mouse support wiring (click + scroll + double-click)

Mirrors MC's panel.c:4080-4197 mouse handling. Wraps the
Tui screen in termion's MouseTerminal (enables GPM on Linux /
xterm mouse on others — without it mouse events never reach
ratatui). The translate_mouse function maps termion MouseEvent
to a MouseAction enum that the FileManager dispatches as
cursor moves / entries / scroll (deferring HistoryPrev/Next/
List and ToggleHidden which need dedicated Cmds not yet in
the keymap).

New APIs:
  - terminal::event::translate_mouse(event, panel_top,
    panel_height, panel_width) -> MouseAction
  - terminal::event::MouseAction (Click, DoubleClick,
    ScrollUp/Down, HistoryPrev/Next/List, ToggleHidden,
    Unhandled)
  - FileManager::handle_mouse_event(m, size) routes
    MouseAction to existing cursor_up_n / cursor_down_n /
    Panel::enter / Panel::set_cursor helpers.
  - app.rs routes TermEvent::Mouse through handle_mouse_event
    before the keyboard-dispatch path.

Tests (9 new in terminal::event::mouse_tests):
  - click_on_file_row_emits_click_action
  - click_below_panel_is_unhandled
  - click_on_header_history_prev_button (col 1)
  - click_on_header_history_next_button (col width-2)
  - click_on_header_toggle_hidden_button (col width-6)
  - scroll_up_emits_scroll_up
  - scroll_down_emits_scroll_down
  - click_on_column_name_row_is_unhandled (sort-by-column
    not yet wired)
  - click_respects_panel_top_offset (panel_top parameter
    works for menu-bar offset)

1345 passing (was 1336; +9 new).
This commit is contained in:
kellito
2026-07-05 22:06:01 +03:00
parent 47abccbc13
commit 7a2b0d5160
4 changed files with 302 additions and 4 deletions
+9 -1
View File
@@ -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,
@@ -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();
@@ -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 }));
}
}
@@ -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<RawTerminal<io::Stdout>>;
type Screen = MouseTerminal<AlternateScreen<RawTerminal<io::Stdout>>>;
/// Terminal backend type used by TLC.
pub type Backend = TermionBackend<Screen>;
@@ -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();