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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user