1use std::cell::RefCell;
12use std::time::{Duration, Instant};
13
14use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
15use ratatui::layout::{Position, Rect};
16
17use crate::app::form::{FieldKind, Form};
18use crate::table::CellHit;
19use crate::{App, InputMode, Overlay};
20
21pub const WHEEL_ROWS: usize = 3;
23
24pub const DOUBLE_CLICK: Duration = Duration::from_millis(500);
26
27pub fn wanted(mouse: &MouseEvent) -> bool {
31 matches!(
32 mouse.kind,
33 MouseEventKind::Down(MouseButton::Left | MouseButton::Right)
34 | MouseEventKind::Drag(MouseButton::Left)
35 | MouseEventKind::Up(MouseButton::Left)
36 | MouseEventKind::ScrollDown
37 | MouseEventKind::ScrollUp
38 | MouseEventKind::ScrollLeft
39 | MouseEventKind::ScrollRight
40 )
41}
42
43pub struct EnableMouse;
47
48impl crossterm::Command for EnableMouse {
49 fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
50 f.write_str("\x1b[?1000h\x1b[?1002h\x1b[?1006h")
51 }
52
53 #[cfg(windows)]
54 fn execute_winapi(&self) -> std::io::Result<()> {
55 crossterm::Command::execute_winapi(&crossterm::event::EnableMouseCapture)
56 }
57
58 #[cfg(windows)]
59 fn is_ansi_code_supported(&self) -> bool {
60 crossterm::Command::is_ansi_code_supported(&crossterm::event::EnableMouseCapture)
61 }
62}
63
64pub fn capture(on: bool, out: &mut impl std::io::Write) {
67 if on {
68 let _ = crossterm::execute!(out, EnableMouse);
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct FieldId {
76 form: &'static str,
77 field: String,
78}
79
80impl FieldId {
81 pub fn of<T: Form>(field: T::Field) -> Self {
82 Self {
83 form: std::any::type_name::<T>(),
84 field: format!("{field:?}"),
85 }
86 }
87
88 pub fn focus_in<T: Form>(&self, form: &mut T) -> Option<Clicked> {
91 if self.form != std::any::type_name::<T>() {
92 return None;
93 }
94 let (field, kind) = form
95 .fields()
96 .into_iter()
97 .find(|(f, _)| format!("{f:?}") == self.field)?;
98 let focused_already = form.focused() == field;
99 let acts = !form.list_row(field) || focused_already;
100 form.focus(field).then_some(Clicked { kind, acts })
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct Clicked {
108 pub kind: FieldKind,
109 pub acts: bool,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Hit {
115 Field(FieldId),
117 Option {
120 field: Option<FieldId>,
121 index: usize,
122 current: usize,
123 },
124 PickerItem {
127 visible: usize,
128 selected: usize,
129 multi: bool,
130 },
131 Picker,
133 Editor,
136 Tool(usize),
138 Key(KeyEvent),
140 Chip(KeyEvent),
143 MenuItem(usize),
145 Menu,
147 Modal,
150}
151
152thread_local! {
153 static DRAWING: RefCell<Option<Vec<(Rect, Hit)>>> = const { RefCell::new(None) };
156}
157
158pub fn record(rect: Rect, hit: Hit) {
160 if rect.width == 0 || rect.height == 0 {
161 return;
162 }
163 DRAWING.with_borrow_mut(|drawing| {
164 if let Some(regions) = drawing {
165 regions.push((rect, hit));
166 }
167 });
168}
169
170pub fn record_field<T: Form>(rect: Rect, field: T::Field) {
172 record(rect, Hit::Field(FieldId::of::<T>(field)));
173}
174
175pub fn record_spans(area: Rect, line: &ratatui::text::Line, spans: Vec<(usize, Hit)>) {
179 let mut x = area.x;
180 let mut at = Vec::with_capacity(line.spans.len());
181 for span in &line.spans {
182 let w = span.width() as u16;
183 at.push((x, w));
184 x = x.saturating_add(w);
185 }
186 for (i, hit) in spans {
187 let Some(&(x, w)) = at.get(i) else { continue };
188 if x >= area.right() {
189 continue;
190 }
191 record(Rect::new(x, area.y, w.min(area.right() - x), 1), hit);
192 }
193}
194
195#[cfg(test)]
197pub(crate) fn recording(draw: impl FnOnce()) -> Vec<(Rect, Hit)> {
198 begin_recording();
199 draw();
200 end_recording()
201}
202
203fn begin_recording() {
204 DRAWING.with_borrow_mut(|drawing| *drawing = Some(Vec::new()));
205}
206
207fn end_recording() -> Vec<(Rect, Hit)> {
208 DRAWING.with_borrow_mut(|drawing| drawing.take().unwrap_or_default())
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum Pointer {
214 Nothing,
215 Keys(Vec<KeyEvent>),
217 Point(Target, Option<KeyEvent>),
220 Form {
223 field: Option<FieldId>,
224 act: Option<bool>,
225 keys: Vec<KeyEvent>,
226 },
227 Tool(usize),
229 Resize {
231 column: String,
232 x: u16,
233 },
234 Width {
236 column: String,
237 width: u16,
238 },
239 Drop {
241 column: String,
242 onto: String,
243 },
244 Menu(CellHit, Position),
246 MenuChoose(usize),
248 CloseMenu,
250 Redraw,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum Target {
258 Table(CellHit),
260 HomeRow(usize),
262 HomeStep(isize),
265 ChartColumn(u16),
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum Drag {
273 Move { column: String, over: String },
275 Resize { column: String, x: u16, width: u16 },
277}
278
279#[derive(Debug, Default)]
281pub struct Pointing {
282 chips: Vec<(Rect, KeyEvent)>,
284 home_list: Option<(Rect, Vec<Option<usize>>)>,
286 hits: Vec<(Rect, Hit)>,
288 last_click: Option<(u16, u16, Instant)>,
289 drag: Option<Drag>,
290 changed: bool,
293}
294
295impl Pointing {
296 pub fn changed(&mut self) {
298 self.changed = true;
299 }
300
301 pub fn painted(&mut self) {
303 self.changed = false;
304 }
305
306 pub fn on_screen(&self) -> bool {
309 !self.changed
310 }
311
312 pub fn forget_drawn(&mut self) {
314 self.chips.clear();
315 self.home_list = None;
316 self.hits.clear();
317 begin_recording();
318 }
319
320 pub fn drawn(&mut self) {
322 self.hits = end_recording();
323 }
324
325 pub fn chips_drawn(&mut self, chips: Vec<(Rect, &str)>) {
327 self.chips = chips
328 .into_iter()
329 .filter_map(|(rect, key)| chip_key(key).map(|key| (rect, key)))
330 .collect();
331 }
332
333 pub fn home_list_drawn(&mut self, area: Rect, lines: Vec<Option<usize>>) {
335 self.home_list = Some((area, lines));
336 }
337
338 pub fn drag(&self) -> Option<&Drag> {
340 self.drag.as_ref()
341 }
342
343 fn chip_at(&self, at: Position) -> Option<KeyEvent> {
344 self.chips
345 .iter()
346 .find(|(rect, _)| rect.contains(at))
347 .map(|(_, key)| *key)
348 }
349
350 fn home_row_at(&self, at: Position) -> Option<usize> {
351 let (area, lines) = self.home_list.as_ref()?;
352 if !area.contains(at) {
353 return None;
354 }
355 lines.get(usize::from(at.y - area.y)).copied().flatten()
356 }
357
358 fn hit_at(&self, at: Position) -> Option<&Hit> {
361 let hits = match self.hits.iter().rposition(|(_, hit)| *hit == Hit::Modal) {
362 Some(i) if !self.hits[i].0.contains(at) => return None,
363 Some(i) => &self.hits[i + 1..],
364 None => &self.hits[..],
365 };
366 let open = hits
367 .iter()
368 .any(|(_, hit)| matches!(hit, Hit::Picker | Hit::Editor));
369 hits.iter()
370 .rev()
371 .filter(|(_, hit)| {
372 !open || matches!(hit, Hit::Picker | Hit::PickerItem { .. } | Hit::Chip(_))
373 })
374 .find(|(rect, _)| rect.contains(at))
375 .map(|(_, hit)| hit)
376 }
377
378 fn over_picker(&self, at: Position) -> bool {
380 self.hits
381 .iter()
382 .any(|(rect, hit)| *hit == Hit::Picker && rect.contains(at))
383 }
384
385 fn second_click(&mut self, at: Position, now: Instant) -> bool {
388 let double = self.last_click.is_some_and(|(x, y, then)| {
389 (x, y) == (at.x, at.y) && now.saturating_duration_since(then) <= DOUBLE_CLICK
390 });
391 self.last_click = if double {
392 None
393 } else {
394 Some((at.x, at.y, now))
395 };
396 double
397 }
398}
399
400fn header_sort_key(state: &crate::table::DataTableState, column: &str) -> char {
403 if state.view_sort_columns() == [column] {
404 ']'
405 } else {
406 '['
407 }
408}
409
410pub fn chip_key(label: &str) -> Option<KeyEvent> {
412 let named = |code| Some(KeyEvent::new(code, KeyModifiers::NONE));
413 match label {
414 "Enter" => return named(KeyCode::Enter),
415 "Esc" => return named(KeyCode::Esc),
416 "Tab" => return named(KeyCode::Tab),
417 "Space" => return named(KeyCode::Char(' ')),
418 "F1" => return named(KeyCode::F(1)),
419 _ => {}
420 }
421 let mut chars = label.chars();
422 match (chars.next(), chars.next(), chars.next()) {
423 (Some('^'), Some(c), None) if c.is_ascii_alphabetic() => Some(KeyEvent::new(
424 KeyCode::Char(c.to_ascii_lowercase()),
425 KeyModifiers::CONTROL,
426 )),
427 (Some(c), None, None) if c.is_ascii_uppercase() => {
429 Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT))
430 }
431 (Some(c), None, None) if c.is_ascii_graphic() => named(KeyCode::Char(c)),
432 _ => None,
433 }
434}
435
436fn press(code: KeyCode) -> KeyEvent {
437 KeyEvent::new(code, KeyModifiers::NONE)
438}
439
440fn steps(delta: isize, back: KeyCode, forward: KeyCode) -> Vec<KeyEvent> {
442 let code = if delta < 0 { back } else { forward };
443 vec![press(code); delta.unsigned_abs()]
444}
445
446pub fn act_key(kind: FieldKind, back: bool) -> Option<KeyEvent> {
450 match kind {
451 FieldKind::Text | FieldKind::MultilineText => None,
452 FieldKind::Choice | FieldKind::Picker { multi: false } if back => {
453 Some(press(KeyCode::Left))
454 }
455 _ if back => None,
456 _ => Some(press(KeyCode::Char(' '))),
457 }
458}
459
460fn click_hit(hit: &Hit, back: bool) -> Pointer {
462 match hit {
463 Hit::Field(id) => Pointer::Form {
464 field: Some(id.clone()),
465 act: Some(back),
466 keys: Vec::new(),
467 },
468 Hit::Option {
469 field,
470 index,
471 current,
472 } => Pointer::Form {
473 field: field.clone(),
474 act: None,
475 keys: steps(
476 *index as isize - *current as isize,
477 KeyCode::Left,
478 KeyCode::Right,
479 ),
480 },
481 Hit::PickerItem {
482 visible,
483 selected,
484 multi,
485 } => {
486 let mut keys = steps(
487 *visible as isize - *selected as isize,
488 KeyCode::Up,
489 KeyCode::Down,
490 );
491 keys.push(press(if *multi {
492 KeyCode::Char(' ')
493 } else {
494 KeyCode::Enter
495 }));
496 Pointer::Keys(keys)
497 }
498 Hit::Tool(i) => Pointer::Tool(*i),
499 Hit::Key(key) | Hit::Chip(key) => Pointer::Keys(vec![*key]),
500 Hit::MenuItem(i) => Pointer::MenuChoose(*i),
501 Hit::Picker | Hit::Editor | Hit::Menu | Hit::Modal => Pointer::Nothing,
502 }
503}
504
505impl App {
506 pub fn pointer(&mut self, mouse: &MouseEvent, now: Instant) -> Pointer {
508 let shift = mouse.modifiers.contains(KeyModifiers::SHIFT);
509 if self.context_menu.is_some() && !self.menu_showing() {
511 self.close_context_menu();
512 }
513 let at = Position {
514 x: mouse.column,
515 y: mouse.row,
516 };
517 match mouse.kind {
518 MouseEventKind::ScrollDown if shift => self.wheel_across(true),
519 MouseEventKind::ScrollUp if shift => self.wheel_across(false),
520 MouseEventKind::ScrollRight => self.wheel_across(true),
521 MouseEventKind::ScrollLeft => self.wheel_across(false),
522 MouseEventKind::ScrollDown => self.wheel(true, at),
523 MouseEventKind::ScrollUp => self.wheel(false, at),
524 MouseEventKind::Down(MouseButton::Left) => {
525 self.pointer.drag = None;
526 self.click(at, now)
527 }
528 MouseEventKind::Down(MouseButton::Right) => {
529 self.pointer.drag = None;
530 self.right_click(at)
531 }
532 MouseEventKind::Drag(MouseButton::Left) => self.drag_to(at),
533 MouseEventKind::Up(MouseButton::Left) => self.release(),
534 _ => Pointer::Nothing,
535 }
536 }
537
538 pub fn cancel_drag(&mut self) {
540 self.pointer.drag = None;
541 }
542
543 pub fn forget_click(&mut self) {
546 self.pointer.last_click = None;
547 self.pointer.drag = None;
548 }
549
550 pub fn point(&mut self, target: &Target) {
553 self.flash = None;
555 match target {
556 Target::Table(hit) => {
557 if let Some(state) = self.data_table_state.as_mut() {
558 state.point_at(hit);
559 }
560 if let (None, Some(column)) = (hit.row, &hit.column) {
561 self.pointer.drag = Some(Drag::Move {
562 column: column.clone(),
563 over: column.clone(),
564 });
565 }
566 }
567 Target::HomeRow(row) => {
568 self.home.status = None;
569 self.home.select(*row);
570 }
571 Target::HomeStep(rows) => {
572 self.home.status = None;
573 self.home.page_selection(*rows);
574 }
575 Target::ChartColumn(column) => {
576 self.chart.modal.plot_focus = true;
577 self.move_crosshair_to(Some(*column));
578 }
579 }
580 }
581
582 pub fn point_for_menu(&mut self, hit: &CellHit) -> bool {
586 self.flash = None;
587 self.data_table_state
588 .as_mut()
589 .is_some_and(|state| state.point_at(hit))
590 }
591
592 pub fn focus_field(&mut self, id: &FieldId) -> Option<Clicked> {
594 self.flash = None;
595 if let Some(kind) = id.focus_in(&mut self.chart.modal) {
596 self.chart.modal.plot_focus = false;
598 return Some(kind);
599 }
600 let shown = id
601 .focus_in(&mut self.export_modal)
602 .or_else(|| id.focus_in(&mut self.copy_modal))
603 .or_else(|| id.focus_in(&mut self.chart.export_modal))
604 .or_else(|| id.focus_in(&mut self.pivot_melt_modal))
605 .or_else(|| id.focus_in(&mut self.sort_filter_modal))
606 .or_else(|| id.focus_in(&mut self.view_modal))
607 .or_else(|| {
608 self.column_forms
609 .combine
610 .as_mut()
611 .and_then(|c| id.focus_in(c))
612 })
613 .or_else(|| self.sample.form.as_mut().and_then(|f| id.focus_in(f)));
614 if shown.is_some() {
615 return shown;
616 }
617 let analysis = &mut self.analysis_modal;
618 let kind = analysis
619 .sample_form
620 .as_mut()
621 .and_then(|f| id.focus_in(f))
622 .or_else(|| {
623 analysis
624 .quality
625 .expected_form
626 .as_mut()
627 .and_then(|f| id.focus_in(f))
628 })
629 .or_else(|| {
630 analysis
631 .quality
632 .intent_form
633 .as_mut()
634 .and_then(|f| id.focus_in(f))
635 })?;
636 analysis.focus = crate::analysis::analysis_modal::AnalysisFocus::Main;
638 Some(kind)
639 }
640
641 pub fn point_at_tool(&mut self, tool: usize) {
644 self.flash = None;
645 self.analysis_modal.focus = crate::analysis::analysis_modal::AnalysisFocus::Sidebar;
646 self.analysis_modal.sidebar_state.select(Some(tool));
647 }
648
649 pub fn start_resize(&mut self, column: String, x: u16) {
651 use crate::widgets::column_widths::{UNSEEN_WIDTH, WidthChoice};
652 let width = self
653 .data_table_state
654 .as_ref()
655 .and_then(|state| match state.width_choice(&column) {
656 WidthChoice::Manual(w) => Some(w),
657 _ => state.on_screen_width(&column),
658 })
659 .unwrap_or(UNSEEN_WIDTH);
660 self.pointer.drag = Some(Drag::Resize { column, x, width });
661 }
662
663 pub fn set_dragged_width(&mut self, column: String, width: u16) {
665 use crate::widgets::column_widths::{MAX_WIDTH, MIN_WIDTH, WidthChoice};
666 self.flash = None;
667 if let Some(state) = self.data_table_state.as_mut() {
668 let width = width.clamp(MIN_WIDTH, MAX_WIDTH);
669 state.set_width_choices([(column, WidthChoice::Manual(width))]);
670 }
671 }
672
673 fn home_has_the_keys(&self) -> bool {
675 self.input_mode == InputMode::Home
676 && !self.help_visible()
677 && !self.error_modal.active
678 && !self.confirmation_modal.active
679 }
680
681 fn chart_has_the_keys(&self) -> bool {
684 self.overlay == Overlay::Chart
685 && self.chart.modal.picker.is_none()
686 && !self.help_visible()
687 && !self.error_modal.active
688 && !self.confirmation_modal.active
689 }
690
691 fn dialog_over_all(&self) -> bool {
694 self.help_visible() || self.error_modal.active || self.confirmation_modal.active
695 }
696
697 fn wheel(&self, down: bool, at: Position) -> Pointer {
701 let code = if down { KeyCode::Down } else { KeyCode::Up };
702 let arrows = Pointer::Keys(vec![press(code); WHEEL_ROWS]);
703 if self.context_menu.is_some() {
704 return Pointer::Nothing;
705 }
706 if self.home_has_the_keys() {
707 if self.home.path_input_active {
708 return Pointer::Nothing;
709 }
710 let rows = WHEEL_ROWS as isize;
711 return Pointer::Point(Target::HomeStep(if down { rows } else { -rows }), None);
712 }
713 if !self.dialog_over_all() && self.pointer.over_picker(at) {
714 return arrows;
715 }
716 if self.text_field_focused() {
717 return Pointer::Nothing;
718 }
719 arrows
720 }
721
722 fn wheel_across(&self, right: bool) -> Pointer {
725 if !self.in_normal_table_view() || self.data_table_state.is_none() {
726 return Pointer::Nothing;
727 }
728 let code = if right { KeyCode::Right } else { KeyCode::Left };
729 Pointer::Keys(vec![press(code)])
730 }
731
732 fn click(&mut self, at: Position, now: Instant) -> Pointer {
733 if self.context_menu.is_some() {
734 self.pointer.last_click = None;
735 return match self.pointer.hit_at(at) {
736 Some(Hit::MenuItem(i)) => Pointer::MenuChoose(*i),
737 Some(Hit::Menu) => Pointer::Nothing,
738 _ => Pointer::CloseMenu,
739 };
740 }
741 if let Some(key) = self.pointer.chip_at(at) {
742 self.pointer.last_click = None;
743 return Pointer::Keys(vec![key]);
744 }
745 if self.dialog_over_all() {
747 self.pointer.last_click = None;
748 return match self.pointer.hit_at(at) {
749 Some(Hit::Chip(key)) => Pointer::Keys(vec![*key]),
750 _ => Pointer::Nothing,
751 };
752 }
753 if let Some(hit) = self.pointer.hit_at(at).cloned() {
754 self.pointer.last_click = None;
755 return click_hit(&hit, false);
756 }
757 let double = self.pointer.second_click(at, now);
758 let enter = double.then(|| press(KeyCode::Enter));
759 if self.home_has_the_keys() {
760 return match self.pointer.home_row_at(at) {
761 Some(row) if !self.home.path_input_active => {
762 Pointer::Point(Target::HomeRow(row), enter)
763 }
764 _ => Pointer::Nothing,
765 };
766 }
767 if self.chart_has_the_keys()
768 && self.chart.modal.has_crosshair()
769 && self
770 .chart
771 .modal
772 .plot
773 .is_some_and(|plot| plot.graph.contains(at))
774 {
775 return Pointer::Point(Target::ChartColumn(at.x), None);
776 }
777 if self.in_normal_table_view()
778 && let Some(state) = self.data_table_state.as_ref()
779 {
780 if let Some(column) = state.drawn_edge(at.x, at.y) {
781 if double {
785 let hit = CellHit {
786 row: None,
787 column: Some(column),
788 };
789 return Pointer::Point(Target::Table(hit), Some(press(KeyCode::Char('='))));
790 }
791 return Pointer::Resize { column, x: at.x };
792 }
793 if let Some(hit) = state.drawn_cell(at.x, at.y) {
794 let key = match (&hit.row, &hit.column) {
797 (Some(_), _) => enter,
798 (None, Some(column)) if double => {
799 Some(press(KeyCode::Char(header_sort_key(state, column))))
800 }
801 (None, _) => None,
802 };
803 return Pointer::Point(Target::Table(hit), key);
804 }
805 }
806 Pointer::Nothing
807 }
808
809 fn right_click(&mut self, at: Position) -> Pointer {
812 self.pointer.last_click = None;
813 if self.context_menu.is_some() {
814 return Pointer::CloseMenu;
815 }
816 if self.dialog_over_all() {
817 return Pointer::Nothing;
818 }
819 if let Some(hit) = self.pointer.hit_at(at).cloned() {
820 return match hit {
821 Hit::Field(_) => click_hit(&hit, true),
822 _ => Pointer::Nothing,
823 };
824 }
825 if self.in_normal_table_view()
826 && let Some(hit) = self
827 .data_table_state
828 .as_ref()
829 .and_then(|state| state.drawn_cell(at.x, at.y))
830 && hit.row.is_some()
831 && hit.column.is_some()
832 {
833 return Pointer::Menu(hit, at);
834 }
835 Pointer::Nothing
836 }
837
838 fn drag_to(&mut self, at: Position) -> Pointer {
841 match self.pointer.drag.clone() {
842 Some(Drag::Move { column, .. }) => {
843 let over = self
846 .data_table_state
847 .as_ref()
848 .and_then(|state| state.drawn_cell(at.x, at.y))
849 .and_then(|hit| hit.column)
850 .unwrap_or_else(|| column.clone());
851 if !matches!(&self.pointer.drag, Some(Drag::Move { over: was, .. }) if *was == over)
852 {
853 self.pointer.drag = Some(Drag::Move { column, over });
855 return Pointer::Redraw;
856 }
857 Pointer::Nothing
858 }
859 Some(Drag::Resize { column, x, width }) => {
860 let width = (i32::from(width) + i32::from(at.x) - i32::from(x)).max(0);
861 Pointer::Width {
862 column,
863 width: u16::try_from(width).unwrap_or(u16::MAX),
864 }
865 }
866 None => Pointer::Nothing,
867 }
868 }
869
870 fn release(&mut self) -> Pointer {
872 match self.pointer.drag.take() {
873 Some(Drag::Move { column, over }) if column != over => {
874 Pointer::Drop { column, onto: over }
875 }
876 Some(Drag::Move { .. }) => Pointer::Redraw,
878 _ => Pointer::Nothing,
879 }
880 }
881}
882
883#[cfg(test)]
884mod tests;