use std::cell::RefCell;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Position, Rect};
use crate::form::{FieldKind, Form};
use crate::widgets::datatable::CellHit;
use crate::{App, InputMode};
pub const WHEEL_ROWS: usize = 3;
pub const DOUBLE_CLICK: Duration = Duration::from_millis(500);
pub fn wanted(mouse: &MouseEvent) -> bool {
matches!(
mouse.kind,
MouseEventKind::Down(MouseButton::Left | MouseButton::Right)
| MouseEventKind::Drag(MouseButton::Left)
| MouseEventKind::Up(MouseButton::Left)
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollUp
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight
)
}
pub struct EnableMouse;
impl crossterm::Command for EnableMouse {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
f.write_str("\x1b[?1000h\x1b[?1002h\x1b[?1006h")
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
crossterm::Command::execute_winapi(&crossterm::event::EnableMouseCapture)
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
crossterm::Command::is_ansi_code_supported(&crossterm::event::EnableMouseCapture)
}
}
pub fn capture(on: bool, out: &mut impl std::io::Write) {
if on {
let _ = crossterm::execute!(out, EnableMouse);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldId {
form: &'static str,
field: String,
}
impl FieldId {
pub fn of<T: Form>(field: T::Field) -> Self {
Self {
form: std::any::type_name::<T>(),
field: format!("{field:?}"),
}
}
pub fn focus_in<T: Form>(&self, form: &mut T) -> Option<Clicked> {
if self.form != std::any::type_name::<T>() {
return None;
}
let (field, kind) = form
.fields()
.into_iter()
.find(|(f, _)| format!("{f:?}") == self.field)?;
let focused_already = form.focused() == field;
let acts = !form.list_row(field) || focused_already;
form.focus(field).then_some(Clicked { kind, acts })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Clicked {
pub kind: FieldKind,
pub acts: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Hit {
Field(FieldId),
Option {
field: Option<FieldId>,
index: usize,
current: usize,
},
PickerItem {
visible: usize,
selected: usize,
multi: bool,
},
Picker,
Editor,
Tool(usize),
Key(KeyEvent),
Chip(KeyEvent),
MenuItem(usize),
Menu,
Modal,
}
thread_local! {
static DRAWING: RefCell<Option<Vec<(Rect, Hit)>>> = const { RefCell::new(None) };
}
pub fn record(rect: Rect, hit: Hit) {
if rect.width == 0 || rect.height == 0 {
return;
}
DRAWING.with_borrow_mut(|drawing| {
if let Some(regions) = drawing {
regions.push((rect, hit));
}
});
}
pub fn record_field<T: Form>(rect: Rect, field: T::Field) {
record(rect, Hit::Field(FieldId::of::<T>(field)));
}
pub fn record_spans(area: Rect, line: &ratatui::text::Line, spans: Vec<(usize, Hit)>) {
let mut x = area.x;
let mut at = Vec::with_capacity(line.spans.len());
for span in &line.spans {
let w = span.width() as u16;
at.push((x, w));
x = x.saturating_add(w);
}
for (i, hit) in spans {
let Some(&(x, w)) = at.get(i) else { continue };
if x >= area.right() {
continue;
}
record(Rect::new(x, area.y, w.min(area.right() - x), 1), hit);
}
}
#[cfg(test)]
pub(crate) fn recording(draw: impl FnOnce()) -> Vec<(Rect, Hit)> {
begin_recording();
draw();
end_recording()
}
fn begin_recording() {
DRAWING.with_borrow_mut(|drawing| *drawing = Some(Vec::new()));
}
fn end_recording() -> Vec<(Rect, Hit)> {
DRAWING.with_borrow_mut(|drawing| drawing.take().unwrap_or_default())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pointer {
Nothing,
Keys(Vec<KeyEvent>),
Point(Target, Option<KeyEvent>),
Form {
field: Option<FieldId>,
act: Option<bool>,
keys: Vec<KeyEvent>,
},
Tool(usize),
Resize {
column: String,
x: u16,
},
Width {
column: String,
width: u16,
},
Drop {
column: String,
onto: String,
},
Menu(CellHit, Position),
MenuChoose(usize),
CloseMenu,
Redraw,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Table(CellHit),
HomeRow(usize),
HomeStep(isize),
ChartColumn(u16),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Drag {
Move { column: String, over: String },
Resize { column: String, x: u16, width: u16 },
}
#[derive(Debug, Default)]
pub struct Pointing {
chips: Vec<(Rect, KeyEvent)>,
home_list: Option<(Rect, Vec<Option<usize>>)>,
hits: Vec<(Rect, Hit)>,
last_click: Option<(u16, u16, Instant)>,
drag: Option<Drag>,
changed: bool,
}
impl Pointing {
pub fn changed(&mut self) {
self.changed = true;
}
pub fn painted(&mut self) {
self.changed = false;
}
pub fn on_screen(&self) -> bool {
!self.changed
}
pub fn forget_drawn(&mut self) {
self.chips.clear();
self.home_list = None;
self.hits.clear();
begin_recording();
}
pub fn drawn(&mut self) {
self.hits = end_recording();
}
pub fn chips_drawn(&mut self, chips: Vec<(Rect, &str)>) {
self.chips = chips
.into_iter()
.filter_map(|(rect, key)| chip_key(key).map(|key| (rect, key)))
.collect();
}
pub fn home_list_drawn(&mut self, area: Rect, lines: Vec<Option<usize>>) {
self.home_list = Some((area, lines));
}
pub fn drag(&self) -> Option<&Drag> {
self.drag.as_ref()
}
fn chip_at(&self, at: Position) -> Option<KeyEvent> {
self.chips
.iter()
.find(|(rect, _)| rect.contains(at))
.map(|(_, key)| *key)
}
fn home_row_at(&self, at: Position) -> Option<usize> {
let (area, lines) = self.home_list.as_ref()?;
if !area.contains(at) {
return None;
}
lines.get(usize::from(at.y - area.y)).copied().flatten()
}
fn hit_at(&self, at: Position) -> Option<&Hit> {
let hits = match self.hits.iter().rposition(|(_, hit)| *hit == Hit::Modal) {
Some(i) if !self.hits[i].0.contains(at) => return None,
Some(i) => &self.hits[i + 1..],
None => &self.hits[..],
};
let open = hits
.iter()
.any(|(_, hit)| matches!(hit, Hit::Picker | Hit::Editor));
hits.iter()
.rev()
.filter(|(_, hit)| {
!open || matches!(hit, Hit::Picker | Hit::PickerItem { .. } | Hit::Chip(_))
})
.find(|(rect, _)| rect.contains(at))
.map(|(_, hit)| hit)
}
fn over_picker(&self, at: Position) -> bool {
self.hits
.iter()
.any(|(rect, hit)| *hit == Hit::Picker && rect.contains(at))
}
fn second_click(&mut self, at: Position, now: Instant) -> bool {
let double = self.last_click.is_some_and(|(x, y, then)| {
(x, y) == (at.x, at.y) && now.saturating_duration_since(then) <= DOUBLE_CLICK
});
self.last_click = if double {
None
} else {
Some((at.x, at.y, now))
};
double
}
}
fn header_sort_key(state: &crate::widgets::datatable::DataTableState, column: &str) -> char {
if state.view_sort_columns() == [column] {
']'
} else {
'['
}
}
pub fn chip_key(label: &str) -> Option<KeyEvent> {
let named = |code| Some(KeyEvent::new(code, KeyModifiers::NONE));
match label {
"Enter" => return named(KeyCode::Enter),
"Esc" => return named(KeyCode::Esc),
"Tab" => return named(KeyCode::Tab),
"Space" => return named(KeyCode::Char(' ')),
"F1" => return named(KeyCode::F(1)),
_ => {}
}
let mut chars = label.chars();
match (chars.next(), chars.next(), chars.next()) {
(Some('^'), Some(c), None) if c.is_ascii_alphabetic() => Some(KeyEvent::new(
KeyCode::Char(c.to_ascii_lowercase()),
KeyModifiers::CONTROL,
)),
(Some(c), None, None) if c.is_ascii_uppercase() => {
Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT))
}
(Some(c), None, None) if c.is_ascii_graphic() => named(KeyCode::Char(c)),
_ => None,
}
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn steps(delta: isize, back: KeyCode, forward: KeyCode) -> Vec<KeyEvent> {
let code = if delta < 0 { back } else { forward };
vec![press(code); delta.unsigned_abs()]
}
pub fn act_key(kind: FieldKind, back: bool) -> Option<KeyEvent> {
match kind {
FieldKind::Text | FieldKind::MultilineText => None,
FieldKind::Choice | FieldKind::Picker { multi: false } if back => {
Some(press(KeyCode::Left))
}
_ if back => None,
_ => Some(press(KeyCode::Char(' '))),
}
}
fn click_hit(hit: &Hit, back: bool) -> Pointer {
match hit {
Hit::Field(id) => Pointer::Form {
field: Some(id.clone()),
act: Some(back),
keys: Vec::new(),
},
Hit::Option {
field,
index,
current,
} => Pointer::Form {
field: field.clone(),
act: None,
keys: steps(
*index as isize - *current as isize,
KeyCode::Left,
KeyCode::Right,
),
},
Hit::PickerItem {
visible,
selected,
multi,
} => {
let mut keys = steps(
*visible as isize - *selected as isize,
KeyCode::Up,
KeyCode::Down,
);
keys.push(press(if *multi {
KeyCode::Char(' ')
} else {
KeyCode::Enter
}));
Pointer::Keys(keys)
}
Hit::Tool(i) => Pointer::Tool(*i),
Hit::Key(key) | Hit::Chip(key) => Pointer::Keys(vec![*key]),
Hit::MenuItem(i) => Pointer::MenuChoose(*i),
Hit::Picker | Hit::Editor | Hit::Menu | Hit::Modal => Pointer::Nothing,
}
}
impl App {
pub fn pointer(&mut self, mouse: &MouseEvent, now: Instant) -> Pointer {
let shift = mouse.modifiers.contains(KeyModifiers::SHIFT);
if self.context_menu.is_some() && !self.menu_showing() {
self.close_context_menu();
}
let at = Position {
x: mouse.column,
y: mouse.row,
};
match mouse.kind {
MouseEventKind::ScrollDown if shift => self.wheel_across(true),
MouseEventKind::ScrollUp if shift => self.wheel_across(false),
MouseEventKind::ScrollRight => self.wheel_across(true),
MouseEventKind::ScrollLeft => self.wheel_across(false),
MouseEventKind::ScrollDown => self.wheel(true, at),
MouseEventKind::ScrollUp => self.wheel(false, at),
MouseEventKind::Down(MouseButton::Left) => {
self.pointer.drag = None;
self.click(at, now)
}
MouseEventKind::Down(MouseButton::Right) => {
self.pointer.drag = None;
self.right_click(at)
}
MouseEventKind::Drag(MouseButton::Left) => self.drag_to(at),
MouseEventKind::Up(MouseButton::Left) => self.release(),
_ => Pointer::Nothing,
}
}
pub fn cancel_drag(&mut self) {
self.pointer.drag = None;
}
pub fn forget_click(&mut self) {
self.pointer.last_click = None;
self.pointer.drag = None;
}
pub fn point(&mut self, target: &Target) {
self.flash = None;
match target {
Target::Table(hit) => {
if let Some(state) = self.data_table_state.as_mut() {
state.point_at(hit);
}
if let (None, Some(column)) = (hit.row, &hit.column) {
self.pointer.drag = Some(Drag::Move {
column: column.clone(),
over: column.clone(),
});
}
}
Target::HomeRow(row) => {
self.home.status = None;
self.home.select(*row);
}
Target::HomeStep(rows) => {
self.home.status = None;
self.home.page_selection(*rows);
}
Target::ChartColumn(column) => {
self.chart_modal.plot_focus = true;
self.move_crosshair_to(Some(*column));
}
}
}
pub fn point_for_menu(&mut self, hit: &CellHit) -> bool {
self.flash = None;
self.data_table_state
.as_mut()
.is_some_and(|state| state.point_at(hit))
}
pub fn focus_field(&mut self, id: &FieldId) -> Option<Clicked> {
self.flash = None;
if let Some(kind) = id.focus_in(&mut self.chart_modal) {
self.chart_modal.plot_focus = false;
return Some(kind);
}
let shown = id
.focus_in(&mut self.export_modal)
.or_else(|| id.focus_in(&mut self.copy_modal))
.or_else(|| id.focus_in(&mut self.chart_export_modal))
.or_else(|| id.focus_in(&mut self.pivot_melt_modal))
.or_else(|| id.focus_in(&mut self.sort_filter_modal))
.or_else(|| id.focus_in(&mut self.view_modal))
.or_else(|| self.combine.as_mut().and_then(|c| id.focus_in(c)))
.or_else(|| self.sample_form.as_mut().and_then(|f| id.focus_in(f)));
if shown.is_some() {
return shown;
}
let analysis = &mut self.analysis_modal;
let kind = analysis
.sample_form
.as_mut()
.and_then(|f| id.focus_in(f))
.or_else(|| {
analysis
.data_quality_expected_form
.as_mut()
.and_then(|f| id.focus_in(f))
})
.or_else(|| {
analysis
.data_quality_intent_form
.as_mut()
.and_then(|f| id.focus_in(f))
})?;
analysis.focus = crate::analysis_modal::AnalysisFocus::Main;
Some(kind)
}
pub fn point_at_tool(&mut self, tool: usize) {
self.flash = None;
self.analysis_modal.focus = crate::analysis_modal::AnalysisFocus::Sidebar;
self.analysis_modal.sidebar_state.select(Some(tool));
}
pub fn start_resize(&mut self, column: String, x: u16) {
use crate::widgets::column_widths::{UNSEEN_WIDTH, WidthChoice};
let width = self
.data_table_state
.as_ref()
.and_then(|state| match state.width_choice(&column) {
WidthChoice::Manual(w) => Some(w),
_ => state.on_screen_width(&column),
})
.unwrap_or(UNSEEN_WIDTH);
self.pointer.drag = Some(Drag::Resize { column, x, width });
}
pub fn set_dragged_width(&mut self, column: String, width: u16) {
use crate::widgets::column_widths::{MAX_WIDTH, MIN_WIDTH, WidthChoice};
self.flash = None;
if let Some(state) = self.data_table_state.as_mut() {
let width = width.clamp(MIN_WIDTH, MAX_WIDTH);
state.set_width_choices([(column, WidthChoice::Manual(width))]);
}
}
fn home_has_the_keys(&self) -> bool {
self.input_mode == InputMode::Home
&& !self.help_visible()
&& !self.error_modal.active
&& !self.confirmation_modal.active
}
fn chart_has_the_keys(&self) -> bool {
self.input_mode == InputMode::Chart
&& self.chart_modal.active
&& self.chart_modal.picker.is_none()
&& !self.chart_export_modal.active
&& !self.help_visible()
&& !self.error_modal.active
&& !self.confirmation_modal.active
}
fn dialog_over_all(&self) -> bool {
self.help_visible() || self.error_modal.active || self.confirmation_modal.active
}
fn wheel(&self, down: bool, at: Position) -> Pointer {
let code = if down { KeyCode::Down } else { KeyCode::Up };
let arrows = Pointer::Keys(vec![press(code); WHEEL_ROWS]);
if self.context_menu.is_some() {
return Pointer::Nothing;
}
if self.home_has_the_keys() {
if self.home.path_input_active {
return Pointer::Nothing;
}
let rows = WHEEL_ROWS as isize;
return Pointer::Point(Target::HomeStep(if down { rows } else { -rows }), None);
}
if !self.dialog_over_all() && self.pointer.over_picker(at) {
return arrows;
}
if self.text_field_focused() {
return Pointer::Nothing;
}
arrows
}
fn wheel_across(&self, right: bool) -> Pointer {
if !self.in_normal_table_view() || self.data_table_state.is_none() {
return Pointer::Nothing;
}
let code = if right { KeyCode::Right } else { KeyCode::Left };
Pointer::Keys(vec![press(code)])
}
fn click(&mut self, at: Position, now: Instant) -> Pointer {
if self.context_menu.is_some() {
self.pointer.last_click = None;
return match self.pointer.hit_at(at) {
Some(Hit::MenuItem(i)) => Pointer::MenuChoose(*i),
Some(Hit::Menu) => Pointer::Nothing,
_ => Pointer::CloseMenu,
};
}
if let Some(key) = self.pointer.chip_at(at) {
self.pointer.last_click = None;
return Pointer::Keys(vec![key]);
}
if self.dialog_over_all() {
self.pointer.last_click = None;
return match self.pointer.hit_at(at) {
Some(Hit::Chip(key)) => Pointer::Keys(vec![*key]),
_ => Pointer::Nothing,
};
}
if let Some(hit) = self.pointer.hit_at(at).cloned() {
self.pointer.last_click = None;
return click_hit(&hit, false);
}
let double = self.pointer.second_click(at, now);
let enter = double.then(|| press(KeyCode::Enter));
if self.home_has_the_keys() {
return match self.pointer.home_row_at(at) {
Some(row) if !self.home.path_input_active => {
Pointer::Point(Target::HomeRow(row), enter)
}
_ => Pointer::Nothing,
};
}
if self.chart_has_the_keys()
&& self.chart_modal.has_crosshair()
&& self
.chart_modal
.plot
.is_some_and(|plot| plot.graph.contains(at))
{
return Pointer::Point(Target::ChartColumn(at.x), None);
}
if self.in_normal_table_view()
&& let Some(state) = self.data_table_state.as_ref()
{
if let Some(column) = state.drawn_edge(at.x, at.y) {
if double {
let hit = CellHit {
row: None,
column: Some(column),
};
return Pointer::Point(Target::Table(hit), Some(press(KeyCode::Char('='))));
}
return Pointer::Resize { column, x: at.x };
}
if let Some(hit) = state.drawn_cell(at.x, at.y) {
let key = match (&hit.row, &hit.column) {
(Some(_), _) => enter,
(None, Some(column)) if double => {
Some(press(KeyCode::Char(header_sort_key(state, column))))
}
(None, _) => None,
};
return Pointer::Point(Target::Table(hit), key);
}
}
Pointer::Nothing
}
fn right_click(&mut self, at: Position) -> Pointer {
self.pointer.last_click = None;
if self.context_menu.is_some() {
return Pointer::CloseMenu;
}
if self.dialog_over_all() {
return Pointer::Nothing;
}
if let Some(hit) = self.pointer.hit_at(at).cloned() {
return match hit {
Hit::Field(_) => click_hit(&hit, true),
_ => Pointer::Nothing,
};
}
if self.in_normal_table_view()
&& let Some(hit) = self
.data_table_state
.as_ref()
.and_then(|state| state.drawn_cell(at.x, at.y))
&& hit.row.is_some()
&& hit.column.is_some()
{
return Pointer::Menu(hit, at);
}
Pointer::Nothing
}
fn drag_to(&mut self, at: Position) -> Pointer {
match self.pointer.drag.clone() {
Some(Drag::Move { column, .. }) => {
let over = self
.data_table_state
.as_ref()
.and_then(|state| state.drawn_cell(at.x, at.y))
.and_then(|hit| hit.column)
.unwrap_or_else(|| column.clone());
if !matches!(&self.pointer.drag, Some(Drag::Move { over: was, .. }) if *was == over)
{
self.pointer.drag = Some(Drag::Move { column, over });
return Pointer::Redraw;
}
Pointer::Nothing
}
Some(Drag::Resize { column, x, width }) => {
let width = (i32::from(width) + i32::from(at.x) - i32::from(x)).max(0);
Pointer::Width {
column,
width: u16::try_from(width).unwrap_or(u16::MAX),
}
}
None => Pointer::Nothing,
}
}
fn release(&mut self) -> Pointer {
match self.pointer.drag.take() {
Some(Drag::Move { column, over }) if column != over => {
Pointer::Drop { column, onto: over }
}
Some(Drag::Move { .. }) => Pointer::Redraw,
_ => Pointer::Nothing,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_chip_presses_the_key_it_names() {
let key = |code, modifiers| Some(KeyEvent::new(code, modifiers));
assert_eq!(chip_key("Enter"), key(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
chip_key("^O"),
key(KeyCode::Char('o'), KeyModifiers::CONTROL)
);
assert_eq!(chip_key("q"), key(KeyCode::Char('q'), KeyModifiers::NONE));
assert_eq!(chip_key("Q"), key(KeyCode::Char('Q'), KeyModifiers::SHIFT));
assert_eq!(chip_key("/"), key(KeyCode::Char('/'), KeyModifiers::NONE));
assert_eq!(chip_key("F1"), key(KeyCode::F(1), KeyModifiers::NONE));
assert_eq!(chip_key("↑↓"), None);
assert_eq!(chip_key("h/l"), None);
assert_eq!(chip_key("PgUp"), None);
}
#[test]
fn a_second_click_on_the_same_cell_soon_after_is_a_double_click() {
let mut p = Pointing::default();
let at = Position { x: 4, y: 7 };
let t = Instant::now();
assert!(!p.second_click(at, t));
assert!(p.second_click(at, t + Duration::from_millis(200)));
assert!(!p.second_click(at, t + Duration::from_millis(300)));
assert!(!p.second_click(at, t + Duration::from_secs(2)));
assert!(!p.second_click(Position { x: 5, y: 7 }, t + Duration::from_secs(2)));
}
#[test]
fn presses_drags_releases_a_right_press_and_the_wheel_are_wanted() {
let mouse = |kind| MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};
assert!(wanted(&mouse(MouseEventKind::Down(MouseButton::Left))));
assert!(wanted(&mouse(MouseEventKind::ScrollUp)));
assert!(wanted(&mouse(MouseEventKind::ScrollRight)));
assert!(wanted(&mouse(MouseEventKind::Down(MouseButton::Right))));
assert!(wanted(&mouse(MouseEventKind::Up(MouseButton::Left))));
assert!(wanted(&mouse(MouseEventKind::Drag(MouseButton::Left))));
assert!(!wanted(&mouse(MouseEventKind::Moved)));
assert!(!wanted(&mouse(MouseEventKind::Down(MouseButton::Middle))));
assert!(!wanted(&mouse(MouseEventKind::Drag(MouseButton::Right))));
}
#[test]
fn the_mouse_is_taken_only_when_asked_for() {
use crossterm::Command;
let mut out = Vec::new();
capture(false, &mut out);
assert!(out.is_empty(), "off: nothing is asked for");
let mut ansi = String::new();
EnableMouse.write_ansi(&mut ansi).unwrap();
assert_eq!(ansi, "\x1b[?1000h\x1b[?1002h\x1b[?1006h");
assert!(!ansi.contains("?1003h"), "no motion without a button");
#[cfg(not(windows))]
{
capture(true, &mut out);
assert_eq!(String::from_utf8(out).unwrap(), ansi, "on: it is asked for");
}
}
#[test]
fn regions_are_recorded_only_while_a_frame_is_drawn() {
let rect = Rect::new(1, 1, 4, 1);
record(rect, Hit::Tool(0));
let mut p = Pointing::default();
p.forget_drawn();
record(rect, Hit::Tool(1));
record(Rect::new(0, 0, 0, 1), Hit::Tool(2));
p.drawn();
assert_eq!(p.hits, vec![(rect, Hit::Tool(1))]);
record(rect, Hit::Tool(3));
p.forget_drawn();
p.drawn();
assert!(p.hits.is_empty(), "a frame keeps only what it drew");
}
#[test]
fn the_topmost_region_takes_the_click_and_an_open_picker_takes_them_all() {
let field = FieldId {
form: "f",
field: "Name".into(),
};
let mut p = Pointing {
hits: vec![
(Rect::new(0, 0, 10, 1), Hit::Field(field.clone())),
(Rect::new(0, 0, 3, 1), Hit::Tool(2)),
],
..Pointing::default()
};
assert_eq!(p.hit_at(Position { x: 1, y: 0 }), Some(&Hit::Tool(2)));
assert_eq!(
p.hit_at(Position { x: 5, y: 0 }),
Some(&Hit::Field(field.clone()))
);
assert_eq!(p.hit_at(Position { x: 5, y: 1 }), None);
p.hits.push((Rect::new(0, 3, 10, 2), Hit::Picker));
let item = Hit::PickerItem {
visible: 1,
selected: 0,
multi: false,
};
p.hits.push((Rect::new(0, 4, 10, 1), item.clone()));
assert_eq!(p.hit_at(Position { x: 5, y: 0 }), None, "the form waits");
assert_eq!(p.hit_at(Position { x: 5, y: 4 }), Some(&item));
assert!(p.over_picker(Position { x: 5, y: 3 }));
}
#[test]
fn under_a_dialog_only_what_it_drew_takes_clicks() {
let p = Pointing {
hits: vec![
(Rect::new(0, 0, 40, 1), Hit::Tool(0)),
(Rect::new(0, 5, 40, 1), Hit::Tool(1)),
(Rect::new(10, 4, 20, 4), Hit::Modal),
(Rect::new(10, 5, 20, 1), Hit::Tool(2)),
],
..Pointing::default()
};
assert_eq!(p.hit_at(Position { x: 2, y: 0 }), None, "outside: nothing");
assert_eq!(p.hit_at(Position { x: 2, y: 5 }), None);
assert_eq!(p.hit_at(Position { x: 12, y: 5 }), Some(&Hit::Tool(2)));
assert_eq!(
p.hit_at(Position { x: 12, y: 6 }),
None,
"inside, on nothing"
);
}
#[test]
fn a_line_records_where_its_spans_landed() {
use ratatui::text::{Line, Span};
let line = Line::from(vec![
Span::raw(" "),
Span::raw("Pivot"),
Span::raw(" | "),
Span::raw("Melt"),
]);
let mut p = Pointing::default();
p.forget_drawn();
record_spans(
Rect::new(10, 2, 12, 1),
&line,
vec![(1, Hit::Tool(0)), (3, Hit::Tool(1))],
);
p.drawn();
assert_eq!(
p.hits,
vec![
(Rect::new(11, 2, 5, 1), Hit::Tool(0)),
(Rect::new(19, 2, 3, 1), Hit::Tool(1)),
]
);
}
#[test]
fn a_click_on_a_value_steps_to_it_and_on_a_picker_line_chooses_it() {
let right = press(KeyCode::Right);
let left = press(KeyCode::Left);
let option = |index, current| Hit::Option {
field: None,
index,
current,
};
assert_eq!(
click_hit(&option(2, 0), false),
Pointer::Form {
field: None,
act: None,
keys: vec![right, right]
}
);
assert_eq!(
click_hit(&option(0, 1), false),
Pointer::Form {
field: None,
act: None,
keys: vec![left]
}
);
let item = |visible, selected, multi| Hit::PickerItem {
visible,
selected,
multi,
};
assert_eq!(
click_hit(&item(0, 2, false), false),
Pointer::Keys(vec![
press(KeyCode::Up),
press(KeyCode::Up),
press(KeyCode::Enter)
])
);
assert_eq!(
click_hit(&item(1, 1, true), false),
Pointer::Keys(vec![press(KeyCode::Char(' '))])
);
}
#[test]
fn a_click_acts_on_a_field_as_space_and_a_right_click_steps_back() {
let space = Some(press(KeyCode::Char(' ')));
assert_eq!(act_key(FieldKind::Text, false), None);
assert_eq!(act_key(FieldKind::MultilineText, true), None);
assert_eq!(act_key(FieldKind::Checkbox, false), space);
assert_eq!(
act_key(FieldKind::Checkbox, true),
None,
"a right click focuses"
);
assert_eq!(act_key(FieldKind::Choice, false), space);
assert_eq!(act_key(FieldKind::Choice, true), Some(press(KeyCode::Left)));
assert_eq!(act_key(FieldKind::Picker { multi: true }, true), None);
assert_eq!(act_key(FieldKind::Button, true), None);
assert_eq!(act_key(FieldKind::Button, false), space);
}
#[test]
fn a_field_id_focuses_only_its_own_form() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum F {
A,
B,
}
struct One(F, bool);
struct Two(F);
impl Form for One {
type Field = F;
fn fields(&self) -> Vec<(F, FieldKind)> {
let mut f = vec![(F::A, FieldKind::Text)];
if self.1 {
f.push((F::B, FieldKind::Checkbox));
}
f
}
fn focused(&self) -> F {
self.0
}
fn set_focused(&mut self, field: F) {
self.0 = field;
}
}
impl Form for Two {
type Field = F;
fn fields(&self) -> Vec<(F, FieldKind)> {
vec![(F::A, FieldKind::Text), (F::B, FieldKind::Choice)]
}
fn focused(&self) -> F {
self.0
}
fn set_focused(&mut self, field: F) {
self.0 = field;
}
}
let id = FieldId::of::<One>(F::B);
let mut two = Two(F::A);
assert_eq!(id.focus_in(&mut two), None, "another form's field");
assert_eq!(two.0, F::A);
let mut hidden = One(F::A, false);
assert_eq!(id.focus_in(&mut hidden), None, "a field not shown");
let mut one = One(F::A, true);
let clicked = |kind| Some(Clicked { kind, acts: true });
assert_eq!(id.focus_in(&mut one), clicked(FieldKind::Checkbox));
assert_eq!(one.0, F::B);
}
}