use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use std::time::{Duration, Instant};
use crate::event::{Mouse, MouseButton, MouseKind};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SelectionRange {
pub start: (u16, u16),
pub end: (u16, u16),
}
impl SelectionRange {
fn between(a: (u16, u16), b: (u16, u16)) -> Self {
let (start, end) = if (a.1, a.0) <= (b.1, b.0) {
(a, b)
} else {
(b, a)
};
SelectionRange { start, end }
}
fn row_span(&self, row: u16, area: Rect) -> Option<(u16, u16)> {
if row < self.start.1 || row > self.end.1 {
return None;
}
let left = if row == self.start.1 {
self.start.0
} else {
area.x
};
let right = if row == self.end.1 {
self.end.0
} else {
area.right().saturating_sub(1)
};
let left = left.max(area.x);
let right = right.min(area.right().saturating_sub(1));
(left <= right).then_some((left, right))
}
pub fn contains(&self, column: u16, row: u16, area: Rect) -> bool {
self.row_span(row, area)
.is_some_and(|(l, r)| column >= l && column <= r)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SelectionState {
anchor: (u16, u16),
cursor: (u16, u16),
pressed: bool,
selecting: bool,
last_click: Option<((u16, u16), Instant)>,
pending_word: Option<(u16, u16)>,
}
impl SelectionState {
pub fn new() -> Self {
Self::default()
}
pub fn handle(&mut self, m: &Mouse) -> bool {
match m.kind {
MouseKind::Down(MouseButton::Left) => {
self.anchor = (m.column, m.row);
self.cursor = (m.column, m.row);
self.pressed = true;
let had = self.selecting;
self.selecting = false;
had
}
MouseKind::Drag(MouseButton::Left) if self.pressed => {
self.cursor = (m.column, m.row);
self.selecting = self.cursor != self.anchor;
self.last_click = None;
self.pending_word = None;
true
}
MouseKind::Up(MouseButton::Left) if self.pressed => {
self.cursor = (m.column, m.row);
self.pressed = false;
self.selecting = self.cursor != self.anchor;
if self.selecting {
self.last_click = None;
} else {
let position = self.cursor;
let now = Instant::now();
let is_double = self.last_click.is_some_and(|(previous, at)| {
previous == position && now.duration_since(at) <= DOUBLE_CLICK_INTERVAL
});
self.last_click = Some((position, now));
self.pending_word = is_double.then_some(position);
}
true
}
_ => false,
}
}
pub fn resolve(&mut self, buffer: &Buffer, area: Rect) -> bool {
let Some((column, row)) = self.pending_word.take() else {
return false;
};
let Some(range) = word_at(buffer, area, column, row) else {
return false;
};
self.anchor = range.start;
self.cursor = range.end;
self.selecting = true;
true
}
pub fn clear(&mut self) {
*self = Self::default();
}
pub fn range(&self) -> Option<SelectionRange> {
self.selecting
.then(|| SelectionRange::between(self.anchor, self.cursor))
}
pub fn is_active(&self) -> bool {
self.selecting
}
}
const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, PartialEq, Eq)]
enum CellClass {
Word,
Punctuation,
Blank,
}
fn cell_class(buffer: &Buffer, column: u16, row: u16) -> CellClass {
let symbol = buffer[(column, row)].symbol();
let Some(ch) = symbol.chars().next() else {
return CellClass::Blank;
};
if ch.is_whitespace() {
CellClass::Blank
} else if ch.is_alphanumeric() || ch == '_' {
CellClass::Word
} else {
CellClass::Punctuation
}
}
pub fn word_at(buffer: &Buffer, area: Rect, column: u16, row: u16) -> Option<SelectionRange> {
if !in_rect(area, column, row) {
return None;
}
let class = cell_class(buffer, column, row);
if class == CellClass::Blank {
return None;
}
let mut left = column;
while left > area.x && cell_class(buffer, left - 1, row) == class {
left -= 1;
}
let mut right = column;
while right + 1 < area.right() && cell_class(buffer, right + 1, row) == class {
right += 1;
}
Some(SelectionRange::between((left, row), (right, row)))
}
pub fn selected_text(buffer: &Buffer, area: Rect, sel: SelectionRange) -> String {
let mut out = String::new();
let mut first = true;
for row in sel.start.1..=sel.end.1 {
let Some((left, right)) = sel.row_span(row, area) else {
continue;
};
if !first {
out.push('\n');
}
first = false;
let mut line = String::new();
for col in left..=right {
line.push_str(buffer[(col, row)].symbol());
}
out.push_str(line.trim_end_matches(' '));
}
out
}
pub fn highlight(buffer: &mut Buffer, area: Rect, sel: SelectionRange, style: Style) {
for row in sel.start.1..=sel.end.1 {
let Some((left, right)) = sel.row_span(row, area) else {
continue;
};
for col in left..=right {
buffer[(col, row)].set_style(style);
}
}
}
#[derive(Clone, Debug)]
pub struct HitMap<T> {
regions: Vec<(Rect, T)>,
}
impl<T> Default for HitMap<T> {
fn default() -> Self {
Self {
regions: Vec::new(),
}
}
}
impl<T> HitMap<T> {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, area: Rect, value: T) {
self.regions.push((area, value));
}
pub fn clear(&mut self) {
self.regions.clear();
}
pub fn hit(&self, column: u16, row: u16) -> Option<&T> {
self.regions
.iter()
.rev()
.find(|(r, _)| in_rect(*r, column, row))
.map(|(_, v)| v)
}
pub fn hit_event(&self, m: &Mouse) -> Option<&T> {
self.hit(m.column, m.row)
}
}
fn in_rect(r: Rect, col: u16, row: u16) -> bool {
r.width > 0 && r.height > 0 && col >= r.x && col < r.right() && row >= r.y && row < r.bottom()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Click {
pub column: u16,
pub row: u16,
pub button: MouseButton,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ClickTracker {
down: Option<(u16, u16, MouseButton)>,
}
impl ClickTracker {
pub fn new() -> Self {
Self::default()
}
pub fn handle(&mut self, m: &Mouse) -> Option<Click> {
match m.kind {
MouseKind::Down(button) => {
self.down = Some((m.column, m.row, button));
None
}
MouseKind::Drag(_) | MouseKind::Moved => {
self.down = None;
None
}
MouseKind::Up(button) => match self.down.take() {
Some((col, row, pressed))
if pressed == button && col == m.column && row == m.row =>
{
Some(Click {
column: m.column,
row: m.row,
button,
})
}
_ => None,
},
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::Text;
use crate::event::{Mouse, MouseButton, MouseKind};
use crate::style::Theme;
use ratatui_core::layout::Rect;
use ratatui_core::style::{Color, Style};
fn down(col: u16, row: u16) -> Mouse {
Mouse::at(MouseKind::Down(MouseButton::Left), col, row)
}
fn drag(col: u16, row: u16) -> Mouse {
Mouse::at(MouseKind::Drag(MouseButton::Left), col, row)
}
fn up(col: u16, row: u16) -> Mouse {
Mouse::at(MouseKind::Up(MouseButton::Left), col, row)
}
#[test]
fn selection_tracks_a_left_drag() {
let mut sel = SelectionState::new();
assert!(!sel.handle(&down(1, 0))); assert!(sel.handle(&drag(4, 0)));
assert!(sel.handle(&up(4, 0)));
let range = sel.range().expect("a drag selects");
assert_eq!(range.start, (1, 0));
assert_eq!(range.end, (4, 0));
assert!(sel.is_active());
}
#[test]
fn selection_normalizes_a_backwards_drag() {
let mut sel = SelectionState::new();
sel.handle(&down(5, 1));
sel.handle(&drag(2, 0));
sel.handle(&up(2, 0));
let range = sel.range().expect("selection");
assert_eq!(range.start, (2, 0));
assert_eq!(range.end, (5, 1));
}
#[test]
fn a_plain_click_leaves_no_selection() {
let mut sel = SelectionState::new();
sel.handle(&down(3, 0));
sel.handle(&up(3, 0)); assert!(sel.range().is_none());
assert!(!sel.is_active());
}
#[test]
fn a_new_press_clears_the_previous_selection() {
let mut sel = SelectionState::new();
sel.handle(&down(0, 0));
sel.handle(&drag(3, 0));
sel.handle(&up(3, 0));
assert!(sel.range().is_some());
assert!(sel.handle(&down(1, 1)));
assert!(sel.range().is_none());
}
#[test]
fn double_click_selects_the_word_under_the_pointer() {
let theme = Theme::default();
let buf = crate::testing::render(&Text::raw("hello, brave_world!"), 19, 1, &theme);
let mut sel = SelectionState::new();
sel.handle(&down(9, 0));
sel.handle(&up(9, 0));
sel.handle(&down(9, 0));
sel.handle(&up(9, 0));
assert!(sel.resolve(&buf, buf.area));
let range = sel.range().expect("double click selects a word");
assert_eq!(range.start, (7, 0));
assert_eq!(range.end, (17, 0));
assert_eq!(selected_text(&buf, buf.area, range), "brave_world");
}
#[test]
fn double_click_on_blank_space_does_not_select() {
let theme = Theme::default();
let buf = crate::testing::render(&Text::raw("hi"), 5, 1, &theme);
let mut sel = SelectionState::new();
sel.handle(&down(4, 0));
sel.handle(&up(4, 0));
sel.handle(&down(4, 0));
sel.handle(&up(4, 0));
assert!(!sel.resolve(&buf, buf.area));
assert!(sel.range().is_none());
}
#[test]
fn selected_text_reads_one_row() {
let theme = Theme::default();
let buf = crate::testing::render(&Text::raw("hello world"), 11, 1, &theme);
let mut sel = SelectionState::new();
sel.handle(&down(0, 0));
sel.handle(&drag(4, 0));
sel.handle(&up(4, 0));
let text = selected_text(&buf, buf.area, sel.range().unwrap());
assert_eq!(text, "hello");
}
#[test]
fn selected_text_spans_rows_linearly() {
use ratatui_core::text::Line;
let theme = Theme::default();
let lines = vec![Line::from("hello"), Line::from("world")];
let buf = crate::testing::render(&Text::new(lines), 5, 2, &theme);
let mut sel = SelectionState::new();
sel.handle(&down(2, 0));
sel.handle(&drag(1, 1));
sel.handle(&up(1, 1));
let text = selected_text(&buf, buf.area, sel.range().unwrap());
assert_eq!(text, "llo\nwo");
}
#[test]
fn selected_text_trims_trailing_blanks() {
let theme = Theme::default();
let buf = crate::testing::render(&Text::raw("hi"), 10, 1, &theme);
let mut sel = SelectionState::new();
sel.handle(&down(0, 0));
sel.handle(&drag(9, 0)); sel.handle(&up(9, 0));
let text = selected_text(&buf, buf.area, sel.range().unwrap());
assert_eq!(text, "hi");
}
#[test]
fn highlight_patches_selected_cells_only() {
let theme = Theme::default();
let mut buf = crate::testing::render(&Text::raw("hello"), 5, 1, &theme);
let area = buf.area;
let mut sel = SelectionState::new();
sel.handle(&down(0, 0));
sel.handle(&drag(2, 0));
sel.handle(&up(2, 0));
highlight(
&mut buf,
area,
sel.range().unwrap(),
Style::default().bg(Color::Blue),
);
for col in 0..=2 {
assert_eq!(
buf[(col, 0)].bg,
Color::Blue,
"cell {col} should be highlighted"
);
}
assert_eq!(
buf[(0, 0)].symbol(),
"h",
"highlight must not clobber the glyph"
);
assert_ne!(buf[(4, 0)].bg, Color::Blue);
}
#[test]
fn hit_map_last_region_wins_and_misses_return_none() {
let mut hits: HitMap<&str> = HitMap::new();
hits.push(Rect::new(0, 0, 10, 10), "background");
hits.push(Rect::new(2, 2, 3, 3), "panel"); hits.push(Rect::new(0, 0, 0, 0), "zero"); assert_eq!(hits.hit(3, 3), Some(&"panel"));
assert_eq!(hits.hit(8, 8), Some(&"background"));
assert_eq!(hits.hit(50, 50), None);
assert_eq!(hits.hit_event(&down(3, 3)), Some(&"panel"));
}
#[test]
fn click_tracker_detects_a_click() {
let mut clicks = ClickTracker::new();
assert!(clicks.handle(&down(4, 2)).is_none()); let click = clicks
.handle(&up(4, 2))
.expect("down+up on one cell is a click");
assert_eq!(
(click.column, click.row, click.button),
(4, 2, MouseButton::Left)
);
}
#[test]
fn click_tracker_drag_cancels_the_click() {
let mut clicks = ClickTracker::new();
clicks.handle(&down(4, 2));
clicks.handle(&drag(6, 2)); assert!(clicks.handle(&up(6, 2)).is_none());
}
#[test]
fn click_tracker_release_on_another_cell_is_not_a_click() {
let mut clicks = ClickTracker::new();
clicks.handle(&down(4, 2));
assert!(clicks.handle(&up(9, 9)).is_none());
}
}