use std::time::{Duration, Instant};
use alacritty_terminal::term::TermMode;
use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
pub const MULTI_CLICK_WINDOW: Duration = Duration::from_millis(400);
const WHEEL_LINES: i32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hit {
Tree { row: usize },
TabStrip { group: usize, tab: Option<usize> },
Terminal { group: usize, col: u16, line: u16 },
Splitter { index: usize },
Chrome,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GroupRegion {
pub strip: Rect,
pub grid: Rect,
pub tab_spans: Vec<(u16, u16)>,
}
impl GroupRegion {
fn tab_at(&self, col: u16) -> Option<usize> {
self.tab_spans
.iter()
.position(|(start, end)| col >= *start && col < *end)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RegionMap {
pub tree: Rect,
pub tree_offset: usize,
pub groups: Vec<GroupRegion>,
pub splitters: Vec<u16>,
pub splitter_cols: (u16, u16),
}
impl RegionMap {
pub fn hit(&self, col: u16, row: u16) -> Hit {
for (index, group) in self.groups.iter().enumerate() {
if contains(group.strip, col, row) {
return Hit::TabStrip {
group: index,
tab: group.tab_at(col),
};
}
}
if col >= self.splitter_cols.0 && col < self.splitter_cols.1 {
if let Some((index, _)) = self
.splitters
.iter()
.enumerate()
.filter(|(_, r)| r.abs_diff(row) <= SPLITTER_REACH)
.min_by_key(|(_, r)| r.abs_diff(row))
{
return Hit::Splitter { index };
}
}
for (index, group) in self.groups.iter().enumerate() {
if contains(group.grid, col, row) {
return Hit::Terminal {
group: index,
col: col - group.grid.x,
line: row - group.grid.y,
};
}
}
if contains(self.tree, col, row) {
return Hit::Tree {
row: self.tree_offset + usize::from(row - self.tree.y),
};
}
Hit::Chrome
}
pub fn clamp_to_grid(&self, index: usize, col: u16, row: u16) -> Option<(u16, u16)> {
let grid = self
.groups
.get(index)
.map(|g| g.grid)
.filter(|r| r.width > 0 && r.height > 0)?;
let col = col.clamp(grid.x, grid.x + grid.width - 1) - grid.x;
let line = row.clamp(grid.y, grid.y + grid.height - 1) - grid.y;
Some((col, line))
}
pub fn tree_row_clamped(&self, row: u16) -> usize {
if self.tree.height == 0 {
return self.tree_offset;
}
let row = row.clamp(self.tree.y, self.tree.y + self.tree.height - 1);
self.tree_offset + usize::from(row - self.tree.y)
}
}
const SPLITTER_REACH: u16 = super::layout::SPLITTER_HIT_HEIGHT / 2;
fn contains(rect: Rect, col: u16, row: u16) -> bool {
col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeClick {
Focus,
ExtendRange,
ToggleMark,
Open,
}
pub fn classify_tree_click(button: MouseButton, modifiers: KeyModifiers, count: u8) -> TreeClick {
if button != MouseButton::Left {
return TreeClick::Focus;
}
if modifiers.contains(KeyModifiers::CONTROL) {
TreeClick::ToggleMark
} else if modifiers.contains(KeyModifiers::SHIFT) {
TreeClick::ExtendRange
} else if count >= 2 {
TreeClick::Open
} else {
TreeClick::Focus
}
}
#[derive(Debug, Default)]
pub struct ClickTracker {
last: Option<(Instant, u16, u16)>,
count: u8,
}
impl ClickTracker {
pub fn click(&mut self, col: u16, row: u16, now: Instant) -> u8 {
let repeat = self.last.is_some_and(|(at, c, r)| {
c == col && r == row && now.saturating_duration_since(at) <= MULTI_CLICK_WINDOW
});
self.count = if repeat && self.count < 3 {
self.count + 1
} else {
1
};
self.last = Some((now, col, row));
self.count
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DragOrigin {
#[default]
None,
Tree { anchor: usize },
Terminal { group: usize },
Splitter { index: usize },
Child { group: usize },
}
pub fn forwards_to_child(mode: TermMode, modifiers: KeyModifiers) -> bool {
mode.intersects(TermMode::MOUSE_MODE) && !modifiers.contains(KeyModifiers::ALT)
}
pub fn encode_mouse(
kind: MouseEventKind,
modifiers: KeyModifiers,
col: u16,
line: u16,
mode: TermMode,
) -> Option<Vec<u8>> {
let (button, release) = match kind {
MouseEventKind::Down(b) => (button_code(b), false),
MouseEventKind::Up(b) => (button_code(b), true),
MouseEventKind::Drag(b) => {
if !mode.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) {
return None;
}
(button_code(b) + 32, false)
}
MouseEventKind::Moved => {
if !mode.contains(TermMode::MOUSE_MOTION) {
return None;
}
(3 + 32, false)
}
MouseEventKind::ScrollUp => (64, false),
MouseEventKind::ScrollDown => (65, false),
MouseEventKind::ScrollLeft => (66, false),
MouseEventKind::ScrollRight => (67, false),
};
let mut code = button;
if modifiers.contains(KeyModifiers::SHIFT) {
code += 4;
}
if modifiers.contains(KeyModifiers::ALT) {
code += 8;
}
if modifiers.contains(KeyModifiers::CONTROL) {
code += 16;
}
let (x, y) = (u32::from(col) + 1, u32::from(line) + 1);
if mode.contains(TermMode::SGR_MOUSE) {
let suffix = if release { 'm' } else { 'M' };
return Some(format!("\x1b[<{code};{x};{y}{suffix}").into_bytes());
}
let code = if release { 3 } else { code };
let mut out = b"\x1b[M".to_vec();
for value in [code + 32, x + 32, y + 32] {
if mode.contains(TermMode::UTF8_MOUSE) && value > 127 {
let ch = char::from_u32(value)?;
let mut buf = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
} else {
out.push(u8::try_from(value).ok()?);
}
}
Some(out)
}
fn button_code(button: MouseButton) -> u32 {
match button {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WheelRoute {
Forward(Vec<u8>),
ArrowKeys(Vec<u8>),
ScrollDisplay(i32),
}
pub fn route_wheel(
up: bool,
modifiers: KeyModifiers,
col: u16,
line: u16,
mode: TermMode,
) -> WheelRoute {
let kind = if up {
MouseEventKind::ScrollUp
} else {
MouseEventKind::ScrollDown
};
if forwards_to_child(mode, modifiers) {
if let Some(bytes) = encode_mouse(kind, modifiers, col, line, mode) {
return WheelRoute::Forward(bytes);
}
}
if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
let key: &[u8] = match (up, mode.contains(TermMode::APP_CURSOR)) {
(true, true) => b"\x1bOA",
(true, false) => b"\x1b[A",
(false, true) => b"\x1bOB",
(false, false) => b"\x1b[B",
};
return WheelRoute::ArrowKeys(key.repeat(WHEEL_LINES as usize));
}
WheelRoute::ScrollDisplay(if up { WHEEL_LINES } else { -WHEEL_LINES })
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn map() -> RegionMap {
RegionMap {
tree: Rect::new(1, 1, 30, 10),
tree_offset: 5,
groups: vec![
GroupRegion {
strip: Rect::new(40, 0, 41, 1),
grid: Rect::new(41, 2, 39, 4),
tab_spans: vec![(40, 49), (50, 60)],
},
GroupRegion {
strip: Rect::new(40, 8, 41, 1),
grid: Rect::new(41, 10, 39, 4),
tab_spans: vec![(40, 49)],
},
],
splitters: vec![7],
splitter_cols: (40, 81),
}
}
#[test]
fn hit_maps_each_region_and_applies_the_tree_offset() {
let map = map();
assert_eq!(map.hit(1, 1), Hit::Tree { row: 5 });
assert_eq!(map.hit(30, 10), Hit::Tree { row: 14 });
assert_eq!(
map.hit(41, 2),
Hit::Terminal {
group: 0,
col: 0,
line: 0
}
);
assert_eq!(
map.hit(79, 13),
Hit::Terminal {
group: 1,
col: 38,
line: 3
}
);
assert_eq!(map.hit(0, 0), Hit::Chrome, "the border");
assert_eq!(map.hit(35, 5), Hit::Chrome, "the gap between panes");
assert_eq!(map.hit(5, 11), Hit::Chrome, "below the tree");
assert_eq!(map.hit(41, 1), Hit::Chrome, "the group's top border");
let no_groups = RegionMap {
groups: Vec::new(),
splitters: Vec::new(),
..map
};
assert_eq!(no_groups.hit(50, 5), Hit::Chrome);
}
#[test]
fn tab_strips_hit_test_to_a_tab_or_the_empty_space_after_them() {
let map = map();
assert_eq!(
map.hit(41, 0),
Hit::TabStrip {
group: 0,
tab: Some(0)
}
);
assert_eq!(
map.hit(55, 0),
Hit::TabStrip {
group: 0,
tab: Some(1)
}
);
assert_eq!(
map.hit(49, 0),
Hit::TabStrip {
group: 0,
tab: None
},
"the gap between two tabs"
);
assert_eq!(
map.hit(70, 0),
Hit::TabStrip {
group: 0,
tab: None
},
"past the last tab"
);
assert_eq!(
map.hit(42, 8),
Hit::TabStrip {
group: 1,
tab: Some(0)
},
"group 1's strip is clickable despite the splitter above it"
);
}
#[test]
fn the_splitter_answers_beside_its_row_but_never_steals_a_tab_strip() {
let map = map();
for row in [6, 7] {
assert_eq!(map.hit(60, row), Hit::Splitter { index: 0 }, "row {row}");
}
assert_eq!(
map.hit(60, 8),
Hit::TabStrip {
group: 1,
tab: None
}
);
assert_ne!(map.hit(60, 5), Hit::Splitter { index: 0 });
assert_ne!(map.hit(60, 9), Hit::Splitter { index: 0 });
assert_eq!(map.hit(5, 7), Hit::Tree { row: 11 });
}
#[test]
fn drags_are_clamped_to_the_region_they_started_in() {
let map = map();
assert_eq!(map.clamp_to_grid(0, 0, 0), Some((0, 0)));
assert_eq!(map.clamp_to_grid(0, 200, 200), Some((38, 3)));
assert_eq!(map.clamp_to_grid(0, 45, 4), Some((4, 2)));
assert_eq!(map.clamp_to_grid(1, 60, 0), Some((19, 0)));
assert_eq!(map.clamp_to_grid(9, 1, 1), None, "no such group");
assert_eq!(map.tree_row_clamped(0), 5);
assert_eq!(map.tree_row_clamped(200), 14);
assert_eq!(map.tree_row_clamped(3), 7);
let empty = RegionMap::default();
assert_eq!(empty.clamp_to_grid(0, 3, 3), None);
assert_eq!(empty.tree_row_clamped(3), 0);
}
#[test]
fn tree_clicks_classify_by_button_modifier_and_count() {
use MouseButton::{Left, Middle, Right};
let none = KeyModifiers::NONE;
assert_eq!(classify_tree_click(Left, none, 1), TreeClick::Focus);
assert_eq!(classify_tree_click(Left, none, 2), TreeClick::Open);
assert_eq!(
classify_tree_click(Left, KeyModifiers::SHIFT, 1),
TreeClick::ExtendRange
);
assert_eq!(
classify_tree_click(Left, KeyModifiers::CONTROL, 2),
TreeClick::ToggleMark,
"a modifier beats the click count"
);
assert_eq!(classify_tree_click(Right, none, 2), TreeClick::Focus);
assert_eq!(
classify_tree_click(Middle, KeyModifiers::SHIFT, 1),
TreeClick::Focus
);
}
#[test]
fn click_tracker_counts_repeats_on_one_cell_within_the_window() {
let mut tracker = ClickTracker::default();
let t0 = Instant::now();
assert_eq!(tracker.click(3, 4, t0), 1);
assert_eq!(tracker.click(3, 4, t0 + Duration::from_millis(100)), 2);
assert_eq!(tracker.click(3, 4, t0 + Duration::from_millis(200)), 3);
assert_eq!(
tracker.click(3, 4, t0 + Duration::from_millis(300)),
1,
"wraps"
);
assert_eq!(tracker.click(3, 4, t0 + Duration::from_millis(350)), 2);
assert_eq!(
tracker.click(9, 4, t0 + Duration::from_millis(360)),
1,
"another cell"
);
assert_eq!(
tracker.click(9, 4, t0 + Duration::from_secs(5)),
1,
"too slow"
);
}
#[test]
fn a_child_gets_the_mouse_only_when_it_asked_and_alt_is_up() {
assert!(!forwards_to_child(TermMode::empty(), KeyModifiers::NONE));
assert!(forwards_to_child(
TermMode::MOUSE_REPORT_CLICK,
KeyModifiers::NONE
));
assert!(forwards_to_child(
TermMode::MOUSE_DRAG | TermMode::SGR_MOUSE,
KeyModifiers::CONTROL
));
assert!(!forwards_to_child(
TermMode::MOUSE_MOTION,
KeyModifiers::ALT
));
}
#[test]
fn sgr_encoding_matches_xterm() {
let sgr = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE;
let enc = |kind, mods, mode| encode_mouse(kind, mods, 4, 2, mode).unwrap();
assert_eq!(
enc(
MouseEventKind::Down(MouseButton::Left),
KeyModifiers::NONE,
sgr
),
b"\x1b[<0;5;3M"
);
assert_eq!(
enc(
MouseEventKind::Up(MouseButton::Left),
KeyModifiers::NONE,
sgr
),
b"\x1b[<0;5;3m",
"SGR names the button on release"
);
assert_eq!(
enc(
MouseEventKind::Down(MouseButton::Right),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
sgr
),
b"\x1b[<22;5;3M"
);
assert_eq!(
enc(MouseEventKind::ScrollUp, KeyModifiers::NONE, sgr),
b"\x1b[<64;5;3M"
);
assert_eq!(
enc(MouseEventKind::ScrollDown, KeyModifiers::ALT, sgr),
b"\x1b[<73;5;3M"
);
assert_eq!(
enc(
MouseEventKind::Drag(MouseButton::Middle),
KeyModifiers::NONE,
sgr | TermMode::MOUSE_DRAG
),
b"\x1b[<33;5;3M"
);
assert_eq!(
enc(
MouseEventKind::Moved,
KeyModifiers::NONE,
sgr | TermMode::MOUSE_MOTION
),
b"\x1b[<35;5;3M"
);
}
#[test]
fn events_the_child_did_not_ask_for_are_not_encoded() {
let click_only = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE;
assert_eq!(
encode_mouse(
MouseEventKind::Drag(MouseButton::Left),
KeyModifiers::NONE,
0,
0,
click_only
),
None
);
assert_eq!(
encode_mouse(
MouseEventKind::Moved,
KeyModifiers::NONE,
0,
0,
click_only | TermMode::MOUSE_DRAG
),
None
);
assert!(encode_mouse(
MouseEventKind::Drag(MouseButton::Left),
KeyModifiers::NONE,
0,
0,
click_only | TermMode::MOUSE_DRAG
)
.is_some());
}
#[test]
fn legacy_encoding_uses_the_offset_triple_and_widens_only_under_utf8() {
let legacy = TermMode::MOUSE_REPORT_CLICK;
assert_eq!(
encode_mouse(
MouseEventKind::Down(MouseButton::Left),
KeyModifiers::NONE,
4,
2,
legacy
)
.unwrap(),
b"\x1b[M\x20\x25\x23"
);
assert_eq!(
encode_mouse(
MouseEventKind::Up(MouseButton::Left),
KeyModifiers::NONE,
4,
2,
legacy
)
.unwrap(),
b"\x1b[M\x23\x25\x23",
"legacy release is button 3"
);
let raw = encode_mouse(
MouseEventKind::Down(MouseButton::Left),
KeyModifiers::NONE,
200,
0,
legacy,
)
.unwrap();
assert_eq!(raw[4], 233);
let utf8 = encode_mouse(
MouseEventKind::Down(MouseButton::Left),
KeyModifiers::NONE,
200,
0,
legacy | TermMode::UTF8_MOUSE,
)
.unwrap();
assert_eq!(&utf8[4..6], "é".as_bytes());
assert_eq!(
encode_mouse(
MouseEventKind::Down(MouseButton::Left),
KeyModifiers::NONE,
300,
0,
legacy
),
None
);
}
#[test]
fn wheel_routes_to_child_then_alt_screen_arrows_then_scrollback() {
let none = KeyModifiers::NONE;
let reporting = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE;
assert_eq!(
route_wheel(true, none, 0, 0, reporting),
WheelRoute::Forward(b"\x1b[<64;1;1M".to_vec())
);
assert_eq!(
route_wheel(true, KeyModifiers::ALT, 0, 0, reporting),
WheelRoute::ScrollDisplay(WHEEL_LINES),
"alt takes the wheel back"
);
let alt_screen = TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL;
assert_eq!(
route_wheel(true, none, 0, 0, alt_screen),
WheelRoute::ArrowKeys(b"\x1b[A\x1b[A\x1b[A".to_vec())
);
assert_eq!(
route_wheel(false, none, 0, 0, alt_screen | TermMode::APP_CURSOR),
WheelRoute::ArrowKeys(b"\x1bOB\x1bOB\x1bOB".to_vec())
);
assert_eq!(
route_wheel(false, none, 0, 0, TermMode::ALT_SCREEN),
WheelRoute::ScrollDisplay(-WHEEL_LINES),
"alt screen without alternate-scroll is plain scrollback"
);
}
}