use std::sync::Arc;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Position, Rect};
use ratatui::text::Line;
use crate::clipboard::copy_to_clipboard;
use crate::selection;
use crate::wrapcache::{PanelWrap, TextPos};
#[derive(Clone, Copy, Debug)]
pub struct MouseConfig {
pub copy_on_release: bool,
pub clear_on_outside_click: bool,
}
impl Default for MouseConfig {
fn default() -> Self {
Self {
copy_on_release: true,
clear_on_outside_click: true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MouseAction {
Selecting,
Copied,
Cleared,
Ignored,
}
#[derive(Default)]
pub struct SelectablePanel {
wrap: Option<PanelWrap>,
selection: Option<(TextPos, TextPos)>,
}
impl SelectablePanel {
pub fn new() -> Self {
Self::default()
}
pub fn set_content(&mut self, text: Arc<str>, width: usize) {
PanelWrap::rebuild_if_needed(&mut self.wrap, &text, width);
}
pub fn has_content(&self) -> bool {
self.wrap.is_some()
}
pub fn total_rows(&self) -> u32 {
self.wrap.as_ref().map_or(0, PanelWrap::total_rows)
}
pub fn whole_text(&self) -> Option<&str> {
self.wrap.as_ref().map(PanelWrap::source)
}
pub fn begin_selection(&mut self, area: Rect, scroll: u16, point: (u16, u16)) {
let Some(wrap) = self.wrap.as_ref() else {
return;
};
let pos = selection::point_to_textpos(point, area, scroll, wrap);
self.selection = Some((pos, pos));
}
pub fn extend_selection(&mut self, area: Rect, scroll: u16, point: (u16, u16)) {
let Some(wrap) = self.wrap.as_ref() else {
return;
};
if let Some((_, cursor)) = self.selection.as_mut() {
*cursor = selection::point_to_textpos(point, area, scroll, wrap);
}
}
pub fn clear_selection(&mut self) {
self.selection = None;
}
pub fn has_selection(&self) -> bool {
self.selection.is_some()
}
pub fn selected_text(&self) -> Option<String> {
let wrap = self.wrap.as_ref()?;
let (anchor, cursor) = self.selection?;
selection::extract_text(anchor, cursor, wrap, None)
}
pub fn copy_selection(&self) -> bool {
match self.selected_text() {
Some(text) => {
copy_to_clipboard(&text);
true
}
None => false,
}
}
pub fn handle_mouse(
&mut self,
event: MouseEvent,
area: Rect,
scroll: u16,
config: &MouseConfig,
) -> MouseAction {
let point = (event.column, event.row);
let inside = area.contains(Position {
x: event.column,
y: event.row,
});
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if inside {
self.begin_selection(area, scroll, point);
MouseAction::Selecting
} else if config.clear_on_outside_click && self.has_selection() {
self.clear_selection();
MouseAction::Cleared
} else {
MouseAction::Ignored
}
}
MouseEventKind::Drag(MouseButton::Left) if self.has_selection() => {
self.extend_selection(area, scroll, point);
MouseAction::Selecting
}
MouseEventKind::Up(MouseButton::Left) if self.has_selection() => {
if config.copy_on_release && self.copy_selection() {
MouseAction::Copied
} else {
MouseAction::Ignored
}
}
_ => MouseAction::Ignored,
}
}
pub fn visible_rows(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
self.wrap
.as_ref()
.map(|w| w.visible_window(scroll, height))
.unwrap_or_default()
}
pub fn highlight_cells(&self, area: Rect, scroll: u16) -> Vec<(u16, u16, u16)> {
let Some(wrap) = self.wrap.as_ref() else {
return Vec::new();
};
let Some((anchor, cursor)) = self.selection else {
return Vec::new();
};
selection::highlight_cells(anchor, cursor, wrap, area, scroll)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn panel(text: &str, width: usize) -> SelectablePanel {
let mut p = SelectablePanel::new();
p.set_content(Arc::from(text), width);
p
}
#[test]
fn a_fresh_panel_has_no_content_or_selection() {
let p = SelectablePanel::new();
assert!(!p.has_content());
assert!(!p.has_selection());
assert_eq!(p.selected_text(), None);
assert!(p.highlight_cells(Rect::new(0, 0, 10, 5), 0).is_empty());
}
#[test]
fn begin_and_extend_select_the_covered_text() {
let mut p = panel("hello world", 40);
let area = Rect::new(2, 1, 40, 5);
p.begin_selection(area, 0, (2, 1)); p.extend_selection(area, 0, (6, 1)); assert!(p.has_selection());
assert_eq!(p.selected_text().as_deref(), Some("hello"));
}
#[test]
fn a_multi_line_drag_joins_lines_with_newlines() {
let mut p = panel("first\nsecond", 40);
let area = Rect::new(0, 0, 40, 5);
p.begin_selection(area, 0, (2, 0)); p.extend_selection(area, 0, (2, 1)); assert_eq!(p.selected_text().as_deref(), Some("rst\nsec"));
}
#[test]
fn clearing_removes_the_selection_and_its_highlight() {
let mut p = panel("hello", 40);
let area = Rect::new(0, 0, 40, 5);
p.begin_selection(area, 0, (0, 0));
p.extend_selection(area, 0, (4, 0));
assert!(!p.highlight_cells(area, 0).is_empty());
p.clear_selection();
assert!(!p.has_selection());
assert!(p.highlight_cells(area, 0).is_empty());
}
#[test]
fn whole_text_and_total_rows_reflect_the_content() {
let p = panel("0123456789ABCDE\n", 10); assert_eq!(p.whole_text(), Some("0123456789ABCDE\n"));
assert_eq!(p.total_rows(), 2);
}
#[test]
fn selection_survives_a_width_change_by_staying_on_the_same_characters() {
let mut p = panel("hello world", 40);
let area = Rect::new(0, 0, 40, 5);
p.begin_selection(area, 0, (0, 0));
p.extend_selection(area, 0, (4, 0)); assert_eq!(p.selected_text().as_deref(), Some("hello"));
p.set_content(Arc::from("hello world"), 5);
assert_eq!(p.selected_text().as_deref(), Some("hello"));
}
fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent {
use ratatui::crossterm::event::KeyModifiers;
MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn handle_mouse_drags_a_selection_and_copies_on_release() {
let mut p = panel("hello world", 40);
let area = Rect::new(2, 1, 40, 5);
let cfg = MouseConfig::default();
let down = MouseEventKind::Down(MouseButton::Left);
let drag = MouseEventKind::Drag(MouseButton::Left);
let up = MouseEventKind::Up(MouseButton::Left);
assert_eq!(
p.handle_mouse(mouse(down, 2, 1), area, 0, &cfg),
MouseAction::Selecting
);
assert_eq!(
p.handle_mouse(mouse(drag, 6, 1), area, 0, &cfg),
MouseAction::Selecting
);
assert_eq!(p.selected_text().as_deref(), Some("hello"));
assert_eq!(
p.handle_mouse(mouse(up, 6, 1), area, 0, &cfg),
MouseAction::Copied
);
}
#[test]
fn handle_mouse_respects_copy_on_release_false() {
let mut p = panel("hello world", 40);
let area = Rect::new(0, 0, 40, 5);
let cfg = MouseConfig {
copy_on_release: false,
..MouseConfig::default()
};
p.handle_mouse(
mouse(MouseEventKind::Down(MouseButton::Left), 0, 0),
area,
0,
&cfg,
);
p.handle_mouse(
mouse(MouseEventKind::Drag(MouseButton::Left), 4, 0),
area,
0,
&cfg,
);
assert_eq!(
p.handle_mouse(
mouse(MouseEventKind::Up(MouseButton::Left), 4, 0),
area,
0,
&cfg
),
MouseAction::Ignored
);
assert_eq!(p.selected_text().as_deref(), Some("hello"));
}
#[test]
fn handle_mouse_clears_selection_on_outside_click() {
let mut p = panel("hello world", 40);
let area = Rect::new(2, 1, 10, 3);
let cfg = MouseConfig::default();
p.handle_mouse(
mouse(MouseEventKind::Down(MouseButton::Left), 2, 1),
area,
0,
&cfg,
);
p.handle_mouse(
mouse(MouseEventKind::Drag(MouseButton::Left), 6, 1),
area,
0,
&cfg,
);
assert!(p.has_selection());
assert_eq!(
p.handle_mouse(
mouse(MouseEventKind::Down(MouseButton::Left), 30, 20),
area,
0,
&cfg
),
MouseAction::Cleared
);
assert!(!p.has_selection());
}
#[test]
fn handle_mouse_ignores_other_buttons() {
let mut p = panel("hello", 40);
let area = Rect::new(0, 0, 40, 5);
let cfg = MouseConfig::default();
assert_eq!(
p.handle_mouse(
mouse(MouseEventKind::Down(MouseButton::Right), 0, 0),
area,
0,
&cfg
),
MouseAction::Ignored
);
assert!(!p.has_selection());
}
}