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::app::form::{FieldKind, Form};
use crate::table::CellHit;
use crate::{App, InputMode, Overlay};
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::table::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.column_forms
.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
.quality
.expected_form
.as_mut()
.and_then(|f| id.focus_in(f))
})
.or_else(|| {
analysis
.quality
.intent_form
.as_mut()
.and_then(|f| id.focus_in(f))
})?;
analysis.focus = crate::analysis::analysis_modal::AnalysisFocus::Main;
Some(kind)
}
pub fn point_at_tool(&mut self, tool: usize) {
self.flash = None;
self.analysis_modal.focus = crate::analysis::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.overlay == Overlay::Chart
&& self.chart.modal.picker.is_none()
&& !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;