1use crate::compare_cells;
6use crate::data::{CellValue, ColumnKind, GridData};
7use crate::filter::{
8 cell_passes_filter, parse_ymd_to_unix, uses_number_ops, ColumnFilter, FilterPredicate,
9 NumberOp, TextOp,
10};
11use crate::format::format_cell;
12use crate::grid::state::state_inner::apply_edge_scroll;
13use crate::grid::theme::GridTheme;
14
15use crate::config::{GridConfig, ResolvedColumnFormat};
16use gpui::{
17 px, App, Bounds, FocusHandle, Keystroke, MouseButton, Pixels, Point, ScrollHandle, Size,
18};
19use std::sync::Arc;
20
21use crate::grid::menu as menu_mod;
23#[allow(unused_imports)]
24pub(crate) use crate::grid::menu::{ContextMenu, MenuAction, MenuItem};
25use crate::grid::selection::{
26 is_cell_selected, is_row_selected, HitResult, ScrollbarAxis, Selection, SortDirection,
27};
28
29use crate::grid::context_menu::{
30 ColumnContext, ContextMenuItem, ContextMenuProviderHandle, ContextMenuRequest,
31 ContextMenuSelection, ContextMenuTarget, PendingCustomContextMenuAction,
32};
33
34pub mod state_inner {
38 use super::{
39 format_cell, CellValue, GridState, HitResult, Pixels, Point, ResolvedColumnFormat,
40 };
41 pub use crate::grid::selection::screen_to_content;
42 pub use crate::grid::selection::to_grid_relative;
43 use std::fmt::Write as _;
44
45 const REALLY_FAST: f32 = 16.0;
59 pub fn edge_scroll_speed(dist_from_edge: f32) -> f32 {
60 if dist_from_edge > 90.0 {
61 return 0.0;
62 }
63 if dist_from_edge < 0.0 {
64 return REALLY_FAST;
67 }
68 if dist_from_edge < 30.0 {
69 REALLY_FAST
70 } else if dist_from_edge < 60.0 {
71 8.0
72 } else {
73 4.0
74 }
75 }
76
77 pub fn apply_edge_scroll(state: &mut GridState) -> bool {
78 if !state.is_dragging {
79 return false;
80 }
81 let Some(pos) = state.last_mouse_pos else {
82 return false;
83 };
84 let bounds = state.bounds;
85 let vw: f32 = bounds.size.width.into();
94 let vh: f32 = bounds.size.height.into();
95 let px: f32 = pos.x.into();
96 let py: f32 = pos.y.into();
97 let right_dist = vw - px;
98 let left_dist = px - state.row_header_width;
99 let bottom_dist = vh - py;
100 let top_dist = py - state.header_height;
101 let mut dx = 0.0_f32;
102 let mut dy = 0.0_f32;
103 if right_dist < 90.0 && right_dist <= left_dist {
104 dx = edge_scroll_speed(right_dist);
105 } else if left_dist < 90.0 {
106 dx = -edge_scroll_speed(left_dist);
107 }
108 if bottom_dist < 90.0 && bottom_dist <= top_dist {
109 dy = edge_scroll_speed(bottom_dist);
110 } else if top_dist < 90.0 {
111 dy = -edge_scroll_speed(top_dist);
112 }
113 if dx == 0.0 && dy == 0.0 {
114 return false;
115 }
116 state.scroll_one_edge_tick(dx, dy);
117 if state.drag_start.is_some() {
118 state.update_drag_from_last();
119 }
120 true
121 }
122
123 #[must_use]
124 pub fn format_current_status(state: &GridState) -> String {
125 let scroll = state.scroll_handle.offset();
126 let (click_col, click_row) = col_row_from_hit(state.click_hit);
127 let (hover_col, hover_row) = col_row_from_hit(state.hover_hit);
128 let mut out = String::new();
129 let _ = write!(
130 out,
131 "Click: {} Scroll@Click: {} Cell: {} | Cur: {} Scroll: {} Over: {}",
132 fmt_point(state.click_pos),
133 fmt_point(state.scroll_at_click),
134 fmt_cr(click_col, click_row),
135 fmt_point(state.last_mouse_pos),
136 fmt_point(Some(scroll)),
137 fmt_cr(hover_col, hover_row),
138 );
139 out
140 }
141
142 fn col_row_from_hit(hit: Option<HitResult>) -> (Option<usize>, Option<usize>) {
143 match hit {
144 Some(HitResult::Cell(r, c)) => (Some(c), Some(r)),
145 Some(HitResult::RowHeader(r)) => (None, Some(r)),
146 Some(HitResult::ColumnHeader(c)) | Some(HitResult::SortButton(c)) => (Some(c), None),
147 _ => (None, None),
148 }
149 }
150
151 fn fmt_point(p: Option<Point<Pixels>>) -> String {
152 match p {
153 Some(p) => format!("({:.0}, {:.0})", f32::from(p.x), f32::from(p.y)),
154 None => "—".into(),
155 }
156 }
157
158 fn fmt_cr(c: Option<usize>, r: Option<usize>) -> String {
159 match (c, r) {
160 (Some(c), Some(r)) => format!("(col {c}, row {r})"),
161 (Some(c), None) => format!("(col {c})"),
162 (None, Some(r)) => format!("(row {r})"),
163 (None, None) => "—".into(),
164 }
165 }
166
167 #[must_use]
168 pub fn cell_text(cell: &CellValue, fmt: &ResolvedColumnFormat) -> String {
169 format_cell(cell, fmt).0
170 }
171}
172
173pub const SCROLLBAR_SIZE: f32 = 20.0;
175pub const EDGE_SCROLL_TICK_MS: u64 = 16;
177
178#[derive(Debug)]
180pub struct GridState {
181 pub data: GridData,
182 pub config: GridConfig,
183 pub resolved_formats: Vec<ResolvedColumnFormat>,
187 pub(crate) data_rows: Arc<Vec<Vec<CellValue>>>,
191 pub display_indices: Arc<Vec<usize>>,
192 pub selection: Selection,
193 pub(crate) range_anchor: Option<(usize, usize)>,
197 pub(crate) range_active: Option<(usize, usize)>,
200 pub sort: Option<(usize, SortDirection)>,
201 pub filters: Vec<ColumnFilter>,
202 pub scroll_handle: ScrollHandle,
203 pub focus_handle: FocusHandle,
204 pub bounds: Bounds<Pixels>,
205 pub row_height: f32,
206 pub header_height: f32,
207 pub row_header_width: f32,
208 pub font_size: f32,
209 pub char_width: f32,
210 pub theme: GridTheme,
211 pub is_dragging: bool,
212 pub drag_start: Option<Point<Pixels>>,
213 pub drag_start_hit: Option<HitResult>,
214 pub scroll_at_click: Option<Point<Pixels>>,
215 pub last_mouse_pos: Option<Point<Pixels>>,
216 pub status_bar_height: f32,
217 pub debug_bar_enabled: bool,
222 pub click_pos: Option<Point<Pixels>>,
223 pub click_hit: Option<HitResult>,
224 pub hover_hit: Option<HitResult>,
225 pub resizing_col: Option<usize>,
226 pub resize_start_x: f32,
227 pub resize_start_width: f32,
228 pub context_menu: Option<ContextMenu>,
229 pub filter_panel: Option<FilterPanel>,
230 pub pending_action: Option<(MenuAction, usize)>,
231 pub(crate) pending_custom_context_menu_action: Option<PendingCustomContextMenuAction>,
232 pub(crate) context_menu_provider: Option<ContextMenuProviderHandle>,
233 pub scrollbar_drag: Option<ScrollbarAxis>,
234 pub scrollbar_drag_start_offset: f32,
235 pub scrollbar_drag_start_pos: f32,
236 pub(crate) window_viewport: Size<Pixels>,
240 pub(crate) edge_scroll_active: bool,
244 pub(crate) column_meta: Arc<[ColumnContext]>,
248 pub(crate) self_weak: Option<gpui::WeakEntity<GridState>>,
252 pub(crate) busy: Option<BusyState>,
256}
257
258#[derive(Clone, Debug)]
262pub struct BusyState {
263 pub label: String,
265 pub progress: Option<f32>,
268}
269
270#[derive(Clone, Debug, Default)]
274pub struct TextInput {
275 pub value: String,
277 pub cursor_chars: usize,
279}
280
281impl TextInput {
282 fn new(value: String) -> Self {
283 let cursor_chars = value.chars().count();
284 Self {
285 value,
286 cursor_chars,
287 }
288 }
289
290 fn clamp_cursor(&mut self) {
291 let total = self.value.chars().count();
292 if self.cursor_chars > total {
293 self.cursor_chars = total;
294 }
295 }
296
297 fn insert_char(&mut self, ch: char) {
298 let byte_idx = byte_index_for_char(&self.value, self.cursor_chars);
299 self.value.insert(byte_idx, ch);
300 self.cursor_chars += 1;
301 }
302
303 fn backspace(&mut self) {
304 if self.cursor_chars == 0 {
305 return;
306 }
307 let end = byte_index_for_char(&self.value, self.cursor_chars);
308 let start = byte_index_for_char(&self.value, self.cursor_chars - 1);
309 self.value.replace_range(start..end, "");
310 self.cursor_chars -= 1;
311 }
312
313 fn move_left(&mut self) {
314 if self.cursor_chars > 0 {
315 self.cursor_chars -= 1;
316 }
317 }
318
319 fn move_right(&mut self) {
320 self.clamp_cursor();
321 if self.cursor_chars < self.value.chars().count() {
322 self.cursor_chars += 1;
323 }
324 }
325}
326
327#[derive(Clone, Copy, Debug, PartialEq, Eq)]
329pub enum FilterInput {
330 Search,
332 OperandA,
334 OperandB,
336}
337
338#[derive(Clone, Debug)]
340pub struct FilterValueRow {
341 pub label: String,
343 pub checked: bool,
345}
346
347#[derive(Clone, Debug)]
354pub struct FilterPanel {
355 pub col: usize,
357 pub anchor: Point<Pixels>,
359 pub kind: ColumnKind,
361 pub search: TextInput,
363 pub op_index: usize,
366 pub op_menu_open: bool,
368 pub operand_a: TextInput,
370 pub operand_b: TextInput,
372 pub focus: FilterInput,
374 pub auto_apply: bool,
376 pub distinct: Vec<FilterValueRow>,
378}
379
380const TEXT_OP_LABELS: &[&str] = &[
384 "Choose One",
385 "contains",
386 "does not contain",
387 "begins with",
388 "ends with",
389 "is",
390 "is not",
391 "matches (regex)",
392];
393
394const NUMBER_OP_LABELS: &[&str] = &[
396 "Choose One",
397 "equal to",
398 "not equal to",
399 "greater than",
400 "greater than or equal to",
401 "less than",
402 "less than or equal to",
403 "between",
404 "not between",
405];
406
407impl FilterPanel {
408 #[must_use]
410 pub fn op_labels(&self) -> &'static [&'static str] {
411 if uses_number_ops(self.kind) {
412 NUMBER_OP_LABELS
413 } else {
414 TEXT_OP_LABELS
415 }
416 }
417
418 #[must_use]
420 pub fn current_op_label(&self) -> &'static str {
421 self.op_labels()
422 .get(self.op_index)
423 .copied()
424 .unwrap_or("Choose One")
425 }
426
427 #[must_use]
429 pub fn needs_operand(&self) -> bool {
430 self.op_index != 0
431 }
432
433 #[must_use]
435 pub fn needs_second_operand(&self) -> bool {
436 uses_number_ops(self.kind) && matches!(self.op_index, 7 | 8)
437 }
438
439 fn text_op_for_index(index: usize) -> Option<TextOp> {
440 match index {
441 1 => Some(TextOp::Contains),
442 2 => Some(TextOp::DoesNotContain),
443 3 => Some(TextOp::BeginsWith),
444 4 => Some(TextOp::EndsWith),
445 5 => Some(TextOp::Is),
446 6 => Some(TextOp::IsNot),
447 7 => Some(TextOp::Matches),
448 _ => None,
449 }
450 }
451
452 fn number_op_for_index(index: usize) -> Option<NumberOp> {
453 match index {
454 1 => Some(NumberOp::Eq),
455 2 => Some(NumberOp::Ne),
456 3 => Some(NumberOp::Gt),
457 4 => Some(NumberOp::Ge),
458 5 => Some(NumberOp::Lt),
459 6 => Some(NumberOp::Le),
460 7 => Some(NumberOp::Between),
461 8 => Some(NumberOp::NotBetween),
462 _ => None,
463 }
464 }
465
466 fn active_input_mut(&mut self) -> &mut TextInput {
467 match self.focus {
468 FilterInput::Search => &mut self.search,
469 FilterInput::OperandA => &mut self.operand_a,
470 FilterInput::OperandB => &mut self.operand_b,
471 }
472 }
473
474 #[must_use]
478 pub fn visible_indices(&self) -> Vec<usize> {
479 let needle = self.search.value.to_lowercase();
480 self.distinct
481 .iter()
482 .enumerate()
483 .filter(|(_, row)| needle.is_empty() || row.label.to_lowercase().contains(&needle))
484 .map(|(i, _)| i)
485 .collect()
486 }
487
488 #[must_use]
492 pub fn all_checked(&self) -> bool {
493 !self.distinct.is_empty() && self.distinct.iter().all(|r| r.checked)
494 }
495
496 fn to_filter(&self) -> ColumnFilter {
499 let predicate = self.build_predicate();
500 let all_checked = self.distinct.iter().all(|r| r.checked);
501 let values = if all_checked {
502 None
503 } else {
504 Some(
505 self.distinct
506 .iter()
507 .filter(|r| r.checked)
508 .map(|r| r.label.clone())
509 .collect(),
510 )
511 };
512 ColumnFilter { predicate, values }
513 }
514
515 fn build_predicate(&self) -> FilterPredicate {
516 if self.op_index == 0 {
517 return FilterPredicate::None;
518 }
519 if uses_number_ops(self.kind) {
520 let Some(op) = Self::number_op_for_index(self.op_index) else {
521 return FilterPredicate::None;
522 };
523 let Some(a) = self.parse_number_operand(&self.operand_a.value) else {
524 return FilterPredicate::None;
525 };
526 let b = if self.needs_second_operand() {
527 self.parse_number_operand(&self.operand_b.value)
528 .unwrap_or(a)
529 } else {
530 a
531 };
532 FilterPredicate::Number { op, a, b }
533 } else {
534 let Some(op) = Self::text_op_for_index(self.op_index) else {
535 return FilterPredicate::None;
536 };
537 FilterPredicate::Text {
538 op,
539 operand: self.operand_a.value.clone(),
540 }
541 }
542 }
543
544 fn parse_number_operand(&self, s: &str) -> Option<f64> {
545 let t = s.trim();
546 if t.is_empty() {
547 return None;
548 }
549 if self.kind == ColumnKind::Date {
550 return parse_ymd_to_unix(t).map(|v| v as f64);
551 }
552 t.replace(',', "").parse::<f64>().ok()
554 }
555}
556
557fn byte_index_for_char(input: &str, char_idx: usize) -> usize {
558 input
559 .char_indices()
560 .nth(char_idx)
561 .map_or(input.len(), |(idx, _)| idx)
562}
563
564fn seed_operator(kind: ColumnKind, predicate: &FilterPredicate) -> (usize, String, String) {
567 match predicate {
568 FilterPredicate::None => (0, String::new(), String::new()),
569 FilterPredicate::Text { op, operand } => {
570 (text_op_index(*op), operand.clone(), String::new())
571 }
572 FilterPredicate::Number { op, a, b } => {
573 let b_str = if matches!(op, NumberOp::Between | NumberOp::NotBetween) {
574 fmt_number_operand(kind, *b)
575 } else {
576 String::new()
577 };
578 (number_op_index(*op), fmt_number_operand(kind, *a), b_str)
579 }
580 }
581}
582
583fn text_op_index(op: TextOp) -> usize {
584 match op {
585 TextOp::Contains => 1,
586 TextOp::DoesNotContain => 2,
587 TextOp::BeginsWith => 3,
588 TextOp::EndsWith => 4,
589 TextOp::Is => 5,
590 TextOp::IsNot => 6,
591 TextOp::Matches => 7,
592 }
593}
594
595fn number_op_index(op: NumberOp) -> usize {
596 match op {
597 NumberOp::Eq => 1,
598 NumberOp::Ne => 2,
599 NumberOp::Gt => 3,
600 NumberOp::Ge => 4,
601 NumberOp::Lt => 5,
602 NumberOp::Le => 6,
603 NumberOp::Between => 7,
604 NumberOp::NotBetween => 8,
605 }
606}
607
608fn fmt_number_operand(kind: ColumnKind, v: f64) -> String {
609 if kind == ColumnKind::Date {
610 let secs = v as i64;
611 let fmt = crate::config::DateFormat {
612 format: "%Y-%m-%d".into(),
613 ..Default::default()
614 };
615 crate::format::format_date_at(secs, secs, &fmt)
616 } else {
617 v.to_string()
619 }
620}
621
622impl GridState {
623 #[must_use]
624 pub fn new(data: GridData, config: GridConfig, focus_handle: FocusHandle) -> Self {
625 let resolved_formats = config.resolve_all(&data.columns);
626 let col_count = data.columns.len();
627 let display_indices = Arc::new((0..data.rows.len()).collect::<Vec<_>>());
628 let data_rows = Arc::new(data.rows.clone());
629 let column_meta: Arc<[ColumnContext]> = data
630 .columns
631 .iter()
632 .enumerate()
633 .map(|(index, col)| ColumnContext {
634 index,
635 name: col.name.clone(),
636 kind: col.kind,
637 })
638 .collect();
639 Self {
640 data,
641 config,
642 resolved_formats,
643 data_rows,
644 display_indices,
645 selection: Selection::None,
646 range_anchor: None,
647 range_active: None,
648 sort: None,
649 filters: vec![ColumnFilter::default(); col_count],
650 scroll_handle: ScrollHandle::new(),
651 focus_handle,
652 bounds: Bounds::default(),
653 row_height: 24.0,
654 header_height: 32.0,
655 row_header_width: 50.0,
656 font_size: 14.0,
657 char_width: 7.6,
658 theme: GridTheme::default(),
659 is_dragging: false,
660 drag_start: None,
661 drag_start_hit: None,
662 scroll_at_click: None,
663 last_mouse_pos: None,
664 status_bar_height: 24.0,
665 debug_bar_enabled: false,
666 click_pos: None,
667 click_hit: None,
668 hover_hit: None,
669 resizing_col: None,
670 resize_start_x: 0.0,
671 resize_start_width: 0.0,
672 context_menu: None,
673 filter_panel: None,
674 pending_action: None,
675 pending_custom_context_menu_action: None,
676 context_menu_provider: None,
677 scrollbar_drag: None,
678 scrollbar_drag_start_offset: 0.0,
679 scrollbar_drag_start_pos: 0.0,
680 window_viewport: Size::default(),
681 edge_scroll_active: false,
682 column_meta,
683 self_weak: None,
684 busy: None,
685 }
686 }
687
688 pub fn set_config(&mut self, config: GridConfig) {
689 self.config = config;
690 self.rebuild_resolved_formats();
691 self.recompute();
692 }
693
694 pub fn set_debug_bar_enabled(&mut self, enabled: bool) {
698 self.debug_bar_enabled = enabled;
699 }
700
701 #[must_use]
704 pub fn is_busy(&self) -> bool {
705 self.busy.is_some()
706 }
707
708 #[must_use]
710 pub fn busy(&self) -> Option<&BusyState> {
711 self.busy.as_ref()
712 }
713
714 pub fn set_busy(&mut self, label: impl Into<String>) {
719 self.busy = Some(BusyState {
720 label: label.into(),
721 progress: None,
722 });
723 }
724
725 pub fn set_busy_progress(&mut self, progress: f32) {
728 if let Some(b) = self.busy.as_mut() {
729 b.progress = Some(progress.clamp(0.0, 1.0));
730 }
731 }
732
733 pub fn clear_busy(&mut self) {
735 self.busy = None;
736 }
737
738 pub fn spawn_background<R, W, D>(
755 &mut self,
756 cx: &mut App,
757 label: impl Into<String>,
758 work: W,
759 on_done: D,
760 ) where
761 R: Send + 'static,
762 W: FnOnce() -> R + Send + 'static,
763 D: FnOnce(R, &mut GridState, &mut App) + 'static,
764 {
765 let Some(weak) = self.self_weak.clone() else {
766 let result = work();
768 on_done(result, self, cx);
769 return;
770 };
771
772 self.busy = Some(BusyState {
773 label: label.into(),
774 progress: None,
775 });
776
777 let background = cx.background_executor().clone();
778 cx.spawn(async move |cx| {
779 let _ = cx.update(|app| {
781 let _ = weak.update(app, |_s, c| c.notify());
782 });
783 let result = background.spawn(async move { work() }).await;
784 let _ = cx.update(|app| {
785 let _ = weak.update(app, |s, c| {
786 s.busy = None;
787 on_done(result, s, c);
788 c.notify();
789 });
790 });
791 })
792 .detach();
793 }
794
795 fn rebuild_resolved_formats(&mut self) {
796 self.resolved_formats = self.config.resolve_all(&self.data.columns);
797 }
798
799 pub fn recompute(&mut self) {
800 let mut indices: Vec<usize> = (0..self.data.rows.len())
801 .filter(|&row_idx| {
802 self.data.columns.iter().enumerate().all(|(col_idx, _col)| {
803 let filter = &self.filters[col_idx];
804 if !filter.is_active() {
805 return true;
806 }
807 let cell = &self.data.rows[row_idx][col_idx];
808 cell_passes_filter(cell, &self.resolved_formats[col_idx], filter)
809 })
810 })
811 .collect();
812
813 if let Some((sort_col, direction)) = self.sort {
814 indices.sort_by(|&a, &b| {
815 let cell_a = &self.data.rows[a][sort_col];
816 let cell_b = &self.data.rows[b][sort_col];
817 let ord = compare_cells(cell_a, cell_b);
818 match direction {
819 SortDirection::Ascending => ord,
820 SortDirection::Descending => ord.reverse(),
821 }
822 });
823 }
824 self.display_indices = Arc::new(indices);
825 }
826
827 fn content_size(&self) -> (f32, f32) {
828 let cw: f32 = self.data.columns.iter().map(|c| c.width).sum();
829 let ch = self.display_indices.len() as f32 * self.row_height;
830 (cw, ch)
831 }
832
833 pub(crate) fn max_scroll(&self) -> (f32, f32) {
834 let (cw, ch) = self.content_size();
835 let (rw, rh) = self.scrollbar_reserved();
836 let vw: f32 = self.bounds.size.width.into();
837 let vh: f32 = self.bounds.size.height.into();
838 let vw = vw - self.row_header_width - rw;
839 let vh = vh - self.header_height - rh;
840 ((cw - vw).max(0.0), (ch - vh).max(0.0))
841 }
842
843 fn scrollbar_reserved(&self) -> (f32, f32) {
844 let (cw, ch) = self.content_size();
845 let vw: f32 = self.bounds.size.width.into();
846 let vh: f32 = self.bounds.size.height.into();
847 let vw = vw - self.row_header_width;
848 let vh = vh - self.header_height;
849 let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
850 let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
851 (reserved_w, reserved_h)
852 }
853
854 fn vbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
855 let (_, ch) = self.content_size();
856 let (_, rh) = self.scrollbar_reserved();
857 let vh: f32 = self.bounds.size.height.into();
858 let vh = vh - self.header_height - rh;
859 if ch <= vh {
860 return None;
861 }
862 let sw: f32 = self.bounds.size.width.into();
865 let sh: f32 = self.bounds.size.height.into();
866 let track_x = sw - SCROLLBAR_SIZE;
867 let track_y = self.header_height;
868 let track_h = sh - self.header_height - rh;
869 let thumb_h = ((track_h * (vh / ch)).max(20.0)).min(track_h);
870 Some((track_x, track_y, SCROLLBAR_SIZE, track_h, thumb_h))
871 }
872
873 fn hbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
874 let (cw, _) = self.content_size();
875 let (rw, _) = self.scrollbar_reserved();
876 let vw: f32 = self.bounds.size.width.into();
877 let vw = vw - self.row_header_width - rw;
878 if cw <= vw {
879 return None;
880 }
881 let sw: f32 = self.bounds.size.width.into();
884 let sh: f32 = self.bounds.size.height.into();
885 let track_x = self.row_header_width;
886 let track_y = sh - SCROLLBAR_SIZE;
887 let track_w = sw - self.row_header_width - rw;
888 let thumb_w = ((track_w * (vw / cw)).max(20.0)).min(track_w);
889 Some((track_x, track_y, track_w, SCROLLBAR_SIZE, thumb_w))
890 }
891
892 pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
893 if let Some((_, track_y, _, track_h, thumb_h)) = self.vbar_geom() {
894 let (_, max_y) = self.max_scroll();
895 let range = (track_h - thumb_h).max(0.0);
896 let rel = (mouse_y - track_y - thumb_h * 0.5).clamp(0.0, range);
897 let frac = if range > 0.0 { rel / range } else { 0.0 };
898 let new_y = frac * max_y;
899 let x = self.scroll_handle.offset().x;
900 self.scroll_handle.set_offset(Point { x, y: px(new_y) });
901 }
902 }
903
904 pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
905 if let Some((track_x, _, track_w, _, thumb_w)) = self.hbar_geom() {
906 let (max_x, _) = self.max_scroll();
907 let range = (track_w - thumb_w).max(0.0);
908 let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
909 let frac = if range > 0.0 { rel / range } else { 0.0 };
910 let new_x = frac * max_x;
911 let y = self.scroll_handle.offset().y;
912 self.scroll_handle.set_offset(Point { x: px(new_x), y });
913 }
914 }
915
916 pub(crate) fn scroll_one_edge_tick(&mut self, dx: f32, dy: f32) {
917 let (mx, my) = self.max_scroll();
918 let s = self.scroll_handle.offset();
919 let new_x: f32 = (f32::from(s.x) + dx).clamp(0.0, mx);
920 let new_y: f32 = (f32::from(s.y) + dy).clamp(0.0, my);
921 self.scroll_handle.set_offset(Point {
922 x: px(new_x),
923 y: px(new_y),
924 });
925 }
926
927 pub fn toggle_sort(&mut self, col: usize) {
928 self.sort = match self.sort {
929 Some((c, SortDirection::Ascending)) if c == col => {
930 Some((col, SortDirection::Descending))
931 }
932 Some((c, SortDirection::Descending)) if c == col => None,
933 _ => Some((col, SortDirection::Ascending)),
934 };
935 self.recompute();
936 }
937
938 pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
939 let hit = self.hit_test(pos);
940 self.click_pos = Some(pos);
941 self.click_hit = Some(hit);
942 match hit {
943 HitResult::VerticalScrollbar => {
944 self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
945 self.scroll_to_vbar(f32::from(pos.y));
946 self.clear_drag();
947 }
948 HitResult::HorizontalScrollbar => {
949 self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
950 self.scroll_to_hbar(f32::from(pos.x));
951 self.clear_drag();
952 }
953 HitResult::ColumnBorder(col) => {
954 self.resizing_col = Some(col);
955 self.resize_start_x = f32::from(pos.x);
956 self.resize_start_width = self.data.columns[col].width;
957 self.clear_drag();
958 }
959 HitResult::ColumnHeader(col) => {
960 self.selection = Selection::Column(col);
961 self.clear_drag();
962 }
963 HitResult::SortButton(col) => {
964 self.toggle_sort(col);
967 self.clear_drag();
968 }
969 HitResult::ContextMenuItem(_) => {}
970 HitResult::RowHeader(row) => {
971 self.selection = if shift {
972 if let Selection::Row(prev) = self.selection {
973 let (s, e) = (prev, row);
974 Selection::RowRange(s.min(e), s.max(e))
975 } else {
976 Selection::Row(row)
977 }
978 } else {
979 Selection::Row(row)
980 };
981 self.start_drag(pos);
982 self.drag_start_hit = Some(HitResult::RowHeader(row));
983 }
984 HitResult::Cell(row, col) => {
985 self.selection = if shift {
986 let anchor = self
988 .range_anchor
989 .or(match self.selection {
990 Selection::Cell(pr, pc) => Some((pr, pc)),
991 _ => None,
992 })
993 .unwrap_or((row, col));
994 self.range_anchor = Some(anchor);
995 self.range_active = Some((row, col));
996 Selection::CellRange(
997 anchor.0.min(row),
998 anchor.1.min(col),
999 anchor.0.max(row),
1000 anchor.1.max(col),
1001 )
1002 } else {
1003 self.range_anchor = Some((row, col));
1004 self.range_active = Some((row, col));
1005 Selection::Cell(row, col)
1006 };
1007 self.start_drag(pos);
1008 self.drag_start_hit = Some(HitResult::Cell(row, col));
1009 }
1010 HitResult::Corner | HitResult::None => {
1011 self.selection = Selection::None;
1012 self.range_anchor = None;
1013 self.range_active = None;
1014 self.context_menu = None;
1015 self.filter_panel = None;
1016 self.clear_drag();
1017 }
1018 }
1019 }
1020
1021 fn start_drag(&mut self, pos: Point<Pixels>) {
1022 self.is_dragging = false;
1023 self.drag_start = Some(pos);
1024 self.scroll_at_click = Some(self.scroll_handle.offset());
1025 self.last_mouse_pos = Some(pos);
1026 }
1027
1028 pub(crate) fn open_context_menu(&mut self, col: usize, anchor: Point<Pixels>) {
1029 self.context_menu = Some(menu_mod::ContextMenu::standard(col, anchor));
1030 self.filter_panel = None;
1031 }
1032
1033 pub(crate) fn context_menu_target_from_hit(&self, hit: HitResult) -> Option<ContextMenuTarget> {
1036 match hit {
1037 HitResult::Cell(row, col) => {
1038 let source_row = self.display_indices.get(row).copied().unwrap_or(row);
1039 Some(ContextMenuTarget::Cell {
1040 display_row_index: row,
1041 source_row_index: source_row,
1042 column_index: col,
1043 })
1044 }
1045 HitResult::RowHeader(row) => {
1046 let source_row = self.display_indices.get(row).copied().unwrap_or(row);
1047 Some(ContextMenuTarget::RowHeader {
1048 display_row_index: row,
1049 source_row_index: source_row,
1050 })
1051 }
1052 HitResult::ColumnHeader(col) => {
1053 Some(ContextMenuTarget::ColumnHeader { column_index: col })
1054 }
1055 HitResult::SortButton(col) => Some(ContextMenuTarget::SortButton { column_index: col }),
1056 _ => None,
1057 }
1058 }
1059
1060 pub(crate) fn effective_selection_for_context_target(
1065 &self,
1066 target: &ContextMenuTarget,
1067 ) -> Selection {
1068 match target {
1069 ContextMenuTarget::Cell {
1070 display_row_index,
1071 column_index,
1072 ..
1073 } => {
1074 if is_cell_selected(&self.selection, *display_row_index, *column_index) {
1075 self.selection.clone()
1076 } else {
1077 Selection::Cell(*display_row_index, *column_index)
1078 }
1079 }
1080 ContextMenuTarget::RowHeader {
1081 display_row_index, ..
1082 } => {
1083 if is_row_selected(&self.selection, *display_row_index) {
1084 self.selection.clone()
1085 } else {
1086 Selection::Row(*display_row_index)
1087 }
1088 }
1089 ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. } => {
1090 self.selection.clone()
1091 }
1092 }
1093 }
1094
1095 pub(crate) fn build_context_menu_request(
1107 &self,
1108 target: ContextMenuTarget,
1109 selection: &Selection,
1110 ) -> ContextMenuRequest {
1111 let nrows = self.display_indices.len();
1112 let ncols = self.data.columns.len();
1113
1114 let (r1, c1, r2, c2) = match selection.normalized_bounds() {
1115 Some((r1, c1, r2, c2)) => {
1116 let r1 = r1.min(nrows.saturating_sub(1));
1117 let r2 = r2.min(nrows.saturating_sub(1));
1118 let c1 = c1.min(ncols.saturating_sub(1));
1119 let c2 = c2.min(ncols.saturating_sub(1));
1120 (r1, c1, r2, c2)
1121 }
1122 None => match &target {
1123 ContextMenuTarget::Cell {
1124 display_row_index,
1125 column_index,
1126 ..
1127 } => (
1128 *display_row_index,
1129 *column_index,
1130 *display_row_index,
1131 *column_index,
1132 ),
1133 ContextMenuTarget::RowHeader {
1134 display_row_index, ..
1135 } => (
1136 *display_row_index,
1137 0,
1138 *display_row_index,
1139 ncols.saturating_sub(1),
1140 ),
1141 ContextMenuTarget::ColumnHeader { column_index }
1142 | ContextMenuTarget::SortButton { column_index } => {
1143 (0, *column_index, nrows.saturating_sub(1), *column_index)
1144 }
1145 },
1146 };
1147
1148 let menu_selection = ContextMenuSelection {
1149 row_start: r1,
1150 row_end: r2,
1151 column_start: c1,
1152 column_end: c2,
1153 };
1154
1155 let column_oriented = matches!(
1160 target,
1161 ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. }
1162 ) || matches!(selection, Selection::Column(_));
1163
1164 ContextMenuRequest::new(
1165 target,
1166 Some(menu_selection),
1167 Arc::clone(&self.data_rows),
1168 Arc::clone(&self.display_indices),
1169 Arc::clone(&self.column_meta),
1170 column_oriented,
1171 )
1172 }
1173
1174 pub(crate) fn execute_custom_context_menu_action(
1178 &mut self,
1179 pending: PendingCustomContextMenuAction,
1180 cx: &mut App,
1181 ) {
1182 self.context_menu = None;
1183 self.filter_panel = None;
1184
1185 let Some(provider) = self.context_menu_provider.clone() else {
1186 return;
1187 };
1188
1189 provider.on_action(&pending.id, &pending.request, self, cx);
1190 }
1191
1192 pub(crate) fn convert_context_menu_items(items: Vec<ContextMenuItem>) -> Vec<MenuItem> {
1195 items
1196 .into_iter()
1197 .map(|item| match item {
1198 ContextMenuItem::BuiltIn(action) => MenuItem::Action(action),
1199 ContextMenuItem::Action { id, label } => MenuItem::Custom { id, label },
1200 ContextMenuItem::Separator => MenuItem::Separator,
1201 })
1202 .collect()
1203 }
1204
1205 pub fn execute_action(&mut self, action: MenuAction, col: usize, cx: &mut App) {
1206 match action {
1207 MenuAction::SelectColumn => {
1208 self.selection = Selection::Column(col);
1209 }
1210 MenuAction::CopyColumn => {
1211 let text = self.column_text(col);
1212 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1213 }
1214 MenuAction::CopyColumnWithHeaders => {
1215 let mut text = String::new();
1216 text.push_str(&self.data.columns[col].name);
1217 text.push('\n');
1218 text.push_str(&self.column_text(col));
1219 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1220 }
1221 MenuAction::SortAscending => {
1222 self.sort = Some((col, SortDirection::Ascending));
1223 self.recompute();
1224 }
1225 MenuAction::SortDescending => {
1226 self.sort = Some((col, SortDirection::Descending));
1227 self.recompute();
1228 }
1229 MenuAction::ClearSort => {
1230 self.sort = None;
1231 self.recompute();
1232 }
1233 MenuAction::FilterPrompt => {
1234 let anchor = self.context_menu.as_ref().map(|m| m.anchor);
1235 self.open_filter_panel(col, anchor);
1236 }
1237 MenuAction::ClearFilter => {
1238 if col < self.filters.len() {
1239 self.filters[col] = ColumnFilter::default();
1240 self.recompute();
1241 }
1242 }
1243 }
1244 self.context_menu = None;
1245 }
1246
1247 pub fn open_filter_panel(&mut self, col: usize, _anchor: Option<Point<Pixels>>) {
1258 if col >= self.data.columns.len() {
1259 return;
1260 }
1261 let sx = f32::from(self.scroll_handle.offset().x);
1262 let col_x = self.row_header_width
1263 + self.data.columns[..col]
1264 .iter()
1265 .map(|c| c.width)
1266 .sum::<f32>()
1267 - sx;
1268 let anchor = Point {
1269 x: px(col_x + self.data.columns[col].width * 0.5),
1270 y: px(0.0),
1271 };
1272 let kind = self.data.columns[col].kind;
1273 let existing = self.filters.get(col).cloned().unwrap_or_default();
1274
1275 let distinct = {
1277 let fmt = &self.resolved_formats[col];
1278 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1279 let mut pairs: Vec<(String, &CellValue)> = Vec::new();
1280 for row in &self.data.rows {
1281 let cell = &row[col];
1282 let (label, _) = format_cell(cell, fmt);
1283 if seen.insert(label.clone()) {
1284 pairs.push((label, cell));
1285 }
1286 }
1287 pairs.sort_by(|(_, a), (_, b)| compare_cells(a, b));
1288 pairs
1289 .into_iter()
1290 .map(|(label, _)| {
1291 let checked = match &existing.values {
1292 None => true,
1293 Some(set) => set.contains(&label),
1294 };
1295 FilterValueRow { label, checked }
1296 })
1297 .collect()
1298 };
1299
1300 let (op_index, operand_a, operand_b) = seed_operator(kind, &existing.predicate);
1301
1302 self.context_menu = None;
1303 self.filter_panel = Some(FilterPanel {
1304 col,
1305 anchor,
1306 kind,
1307 search: TextInput::default(),
1308 op_index,
1309 op_menu_open: false,
1310 operand_a: TextInput::new(operand_a),
1311 operand_b: TextInput::new(operand_b),
1312 focus: FilterInput::Search,
1313 auto_apply: true,
1314 distinct,
1315 });
1316 }
1317
1318 pub fn apply_filter_panel(&mut self) {
1321 let Some(panel) = &self.filter_panel else {
1322 return;
1323 };
1324 let col = panel.col;
1325 let filter = panel.to_filter();
1326 if col < self.filters.len() {
1327 self.filters[col] = filter;
1328 self.recompute();
1329 }
1330 }
1331
1332 pub fn maybe_auto_apply(&mut self) {
1334 if self.filter_panel.is_some() {
1335 self.apply_filter_panel();
1336 }
1337 }
1338
1339 pub fn clear_filter_panel(&mut self) {
1342 let mut target_col = None;
1343 if let Some(panel) = &mut self.filter_panel {
1344 panel.op_index = 0;
1345 panel.op_menu_open = false;
1346 panel.operand_a = TextInput::default();
1347 panel.operand_b = TextInput::default();
1348 panel.search = TextInput::default();
1349 for row in &mut panel.distinct {
1350 row.checked = true;
1351 }
1352 target_col = Some(panel.col);
1353 }
1354 if let Some(col) = target_col {
1355 if col < self.filters.len() {
1356 self.filters[col] = ColumnFilter::default();
1357 }
1358 }
1359 self.recompute();
1360 }
1361
1362 pub fn set_panel_sort(&mut self, direction: SortDirection) {
1365 if let Some(panel) = &self.filter_panel {
1366 let col = panel.col;
1367 self.sort = match self.sort {
1368 Some((c, d)) if c == col && d == direction => None,
1369 _ => Some((col, direction)),
1370 };
1371 self.recompute();
1372 }
1373 }
1374
1375 pub fn toggle_filter_value(&mut self, index: usize) {
1378 if let Some(panel) = &mut self.filter_panel {
1379 if let Some(row) = panel.distinct.get_mut(index) {
1380 row.checked = !row.checked;
1381 }
1382 }
1383 self.maybe_auto_apply();
1384 }
1385
1386 pub fn toggle_filter_select_all(&mut self) {
1391 if let Some(panel) = &mut self.filter_panel {
1392 let target = !panel.all_checked();
1393 for row in &mut panel.distinct {
1394 row.checked = target;
1395 }
1396 }
1397 self.maybe_auto_apply();
1398 }
1399
1400 pub fn set_filter_operator(&mut self, op_index: usize) {
1403 if let Some(panel) = &mut self.filter_panel {
1404 panel.op_index = op_index;
1405 panel.op_menu_open = false;
1406 if op_index != 0 {
1407 panel.focus = FilterInput::OperandA;
1408 }
1409 }
1410 self.maybe_auto_apply();
1411 }
1412
1413 pub fn toggle_filter_op_menu(&mut self) {
1415 if let Some(panel) = &mut self.filter_panel {
1416 panel.op_menu_open = !panel.op_menu_open;
1417 }
1418 }
1419
1420 pub fn set_filter_focus(&mut self, focus: FilterInput) {
1422 if let Some(panel) = &mut self.filter_panel {
1423 panel.focus = focus;
1424 }
1425 }
1426
1427 pub fn toggle_filter_auto_apply(&mut self) {
1429 if let Some(panel) = &mut self.filter_panel {
1430 panel.auto_apply = !panel.auto_apply;
1431 }
1432 self.maybe_auto_apply();
1433 }
1434
1435 fn column_text(&self, col: usize) -> String {
1436 let mut text = String::new();
1437 let fmt = &self.resolved_formats[col];
1438 for &row_idx in self.display_indices.iter() {
1439 let cell = &self.data.rows[row_idx][col];
1440 let (s, _) = format_cell(cell, fmt);
1441 text.push_str(&s);
1442 text.push('\n');
1443 }
1444 text
1445 }
1446
1447 fn clear_drag(&mut self) {
1448 self.is_dragging = false;
1449 self.drag_start = None;
1450 self.drag_start_hit = None;
1451 self.scroll_at_click = None;
1452 }
1453
1454 fn drag_world_corners(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1455 let start = self.drag_start?;
1456 let mouse = self.last_mouse_pos?;
1457 let click_scroll = self
1458 .scroll_at_click
1459 .unwrap_or_else(|| self.scroll_handle.offset());
1460 let scroll = self.scroll_handle.offset();
1461 let sx_click: f32 = click_scroll.x.into();
1462 let sy_click: f32 = click_scroll.y.into();
1463 let sx: f32 = scroll.x.into();
1464 let sy: f32 = scroll.y.into();
1465 let sx0: f32 = start.x.into();
1466 let sy0: f32 = start.y.into();
1467 let mx: f32 = mouse.x.into();
1468 let my: f32 = mouse.y.into();
1469 let start_world = Point {
1470 x: px(sx0 + sx_click),
1471 y: px(sy0 + sy_click),
1472 };
1473 let end_world = Point {
1474 x: px(mx + sx),
1475 y: px(my + sy),
1476 };
1477 Some((start_world, end_world))
1478 }
1479
1480 pub fn drag_screen_rect(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1481 if !self.is_dragging {
1482 return None;
1483 }
1484 let (start_world, end_world) = self.drag_world_corners()?;
1485 let scroll = self.scroll_handle.offset();
1486 let sx: f32 = scroll.x.into();
1487 let sy: f32 = scroll.y.into();
1488 let start_screen = Point {
1489 x: px(f32::from(start_world.x) - sx),
1490 y: px(f32::from(start_world.y) - sy),
1491 };
1492 let end_screen = Point {
1493 x: px(f32::from(end_world.x) - sx),
1494 y: px(f32::from(end_world.y) - sy),
1495 };
1496 Some((start_screen, end_screen))
1497 }
1498
1499 fn update_drag(&mut self) {
1500 let (start_world, end_world) = match self.drag_world_corners() {
1501 Some(c) => c,
1502 None => return,
1503 };
1504 if !self.is_dragging {
1505 let dx = f32::from(end_world.x) - f32::from(start_world.x);
1506 let dy = f32::from(end_world.y) - f32::from(start_world.y);
1507 if dx * dx + dy * dy <= 400.0 {
1508 return;
1509 }
1510 self.is_dragging = true;
1511 }
1512 let r1 = match self.drag_start_hit {
1513 Some(h) => h,
1514 None => return,
1515 };
1516 let r2 = self.hit_test_content(f32::from(end_world.x), f32::from(end_world.y), 0.0, 0.0);
1520 match (r1, r2) {
1521 (HitResult::Cell(r1c, c1), HitResult::Cell(r2c, c2)) => {
1522 self.selection =
1523 Selection::CellRange(r1c.min(r2c), c1.min(c2), r1c.max(r2c), c1.max(c2));
1524 }
1525 (HitResult::RowHeader(r1r), HitResult::RowHeader(r2r)) => {
1526 self.selection = Selection::RowRange(r1r.min(r2r), r1r.max(r2r));
1527 }
1528 _ => {}
1529 }
1530 }
1531
1532 fn update_drag_from_last(&mut self) {
1533 self.update_drag();
1534 }
1535
1536 pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, pressed_button: Option<MouseButton>) {
1537 if self.is_dragging && pressed_button != Some(MouseButton::Left) {
1538 self.handle_mouse_up();
1539 return;
1540 }
1541 if let Some(col) = self.resizing_col {
1542 if pressed_button != Some(MouseButton::Left) {
1543 self.resizing_col = None;
1544 return;
1545 }
1546 let new_w =
1547 (self.resize_start_width + (f32::from(pos.x) - self.resize_start_x)).max(40.0);
1548 self.data.columns[col].width = new_w;
1549 return;
1550 }
1551 if let Some(axis) = self.scrollbar_drag {
1552 if pressed_button != Some(MouseButton::Left) {
1553 self.scrollbar_drag = None;
1554 return;
1555 }
1556 match axis {
1557 ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
1558 ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
1559 }
1560 self.last_mouse_pos = Some(pos);
1561 return;
1562 }
1563 self.last_mouse_pos = Some(pos);
1564 if self.context_menu.is_some() {
1565 return;
1570 }
1571 self.hover_hit = Some(self.hit_test(pos));
1572 if self.drag_start.is_none() {
1573 return;
1574 }
1575 self.update_drag();
1576 }
1577
1578 pub fn handle_scroll_drag(&mut self) {
1579 if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
1580 self.update_drag();
1581 }
1582 }
1583
1584 pub fn handle_mouse_up(&mut self) {
1585 self.resizing_col = None;
1586 self.scrollbar_drag = None;
1587 self.clear_drag();
1588 }
1589
1590 pub fn apply_edge_scroll(&mut self) -> bool {
1591 apply_edge_scroll(self)
1592 }
1593
1594 pub fn select_all(&mut self) {
1595 let nrows = self.display_indices.len();
1596 let ncols = self.data.columns.len();
1597 if nrows > 0 && ncols > 0 {
1598 self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
1599 }
1600 }
1601
1602 pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
1603 let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
1604 return;
1605 };
1606 if self.display_indices.is_empty() || self.data.columns.is_empty() {
1607 return;
1608 }
1609 let last_row = self.display_indices.len() - 1;
1610 let last_col = self.data.columns.len() - 1;
1611 let r1 = raw_r1.min(last_row);
1612 let r2 = raw_r2.min(last_row);
1613 let c1 = raw_c1.min(last_col);
1614 let c2 = raw_c2.min(last_col);
1615 let mut text = String::new();
1616 if with_headers {
1617 for c in c1..=c2 {
1618 if c > c1 {
1619 text.push('\t');
1620 }
1621 text.push_str(&self.data.columns[c].name);
1622 }
1623 text.push('\n');
1624 }
1625 for dr in r1..=r2 {
1626 let row_idx = self.display_indices[dr];
1627 for c in c1..=c2 {
1628 if c > c1 {
1629 text.push('\t');
1630 }
1631 let cell = &self.data.rows[row_idx][c];
1632 let (s, _) = format_cell(cell, &self.resolved_formats[c]);
1633 text.push_str(&s);
1634 }
1635 text.push('\n');
1636 }
1637 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1638 }
1639
1640 pub fn page_up(&mut self) {
1641 let vh: f32 = self.bounds.size.height.into();
1642 let rows = ((vh - self.header_height) / self.row_height) as i32;
1643 self.move_selection(0, -rows);
1644 }
1645
1646 pub fn page_down(&mut self) {
1647 let vh: f32 = self.bounds.size.height.into();
1648 let rows = ((vh - self.header_height) / self.row_height) as i32;
1649 self.move_selection(0, rows);
1650 }
1651
1652 pub fn handle_key(&mut self, keystroke: &Keystroke) {
1653 if self.filter_panel.is_some() {
1654 match keystroke.key.as_str() {
1655 "escape" => {
1656 self.filter_panel = None;
1657 return;
1658 }
1659 "enter" => {
1660 self.apply_filter_panel();
1661 return;
1662 }
1663 _ => {}
1664 }
1665 let mut edited = false;
1666 if let Some(panel) = &mut self.filter_panel {
1667 let input = panel.active_input_mut();
1668 match keystroke.key.as_str() {
1669 "backspace" => {
1670 input.backspace();
1671 edited = true;
1672 }
1673 "left" => input.move_left(),
1674 "right" => input.move_right(),
1675 _ => {
1676 if let Some(ch) = keystroke_to_char(keystroke) {
1677 input.insert_char(ch);
1678 edited = true;
1679 }
1680 }
1681 }
1682 }
1683 if edited {
1686 self.maybe_auto_apply();
1687 }
1688 return;
1689 }
1690 if self.context_menu.is_some() {
1691 if keystroke.key.as_str() == "escape" {
1692 self.context_menu = None;
1693 }
1694 return;
1695 }
1696 let shift = keystroke.modifiers.shift;
1697 match keystroke.key.as_str() {
1698 "up" if shift => self.extend_selection(0, -1),
1699 "down" if shift => self.extend_selection(0, 1),
1700 "left" if shift => self.extend_selection(-1, 0),
1701 "right" if shift => self.extend_selection(1, 0),
1702 "up" => self.move_selection(0, -1),
1703 "down" => self.move_selection(0, 1),
1704 "left" => self.move_selection(-1, 0),
1705 "right" => self.move_selection(1, 0),
1706 "escape" => {
1707 self.selection = Selection::None;
1708 self.range_anchor = None;
1709 self.range_active = None;
1710 }
1711 _ => {}
1712 }
1713 }
1714
1715 fn move_selection(&mut self, dx: i32, dy: i32) {
1716 let nrows = self.display_indices.len() as i32;
1717 let ncols = self.data.columns.len() as i32;
1718 if nrows == 0 || ncols == 0 {
1719 return;
1720 }
1721 let last_row = nrows - 1;
1722 let last_col = ncols - 1;
1723 match self.selection {
1724 Selection::Cell(row, col) => {
1725 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1726 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1727 self.selection = Selection::Cell(nr, nc);
1728 self.range_anchor = Some((nr, nc));
1729 self.range_active = Some((nr, nc));
1730 }
1731 Selection::Row(row) if dy != 0 => {
1732 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1733 self.selection = Selection::Row(nr);
1734 }
1735 Selection::Column(col) if dx != 0 => {
1736 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1737 self.selection = Selection::Column(nc);
1738 }
1739 _ => {
1740 self.selection = Selection::Cell(0, 0);
1741 self.range_anchor = Some((0, 0));
1742 self.range_active = Some((0, 0));
1743 }
1744 }
1745 }
1746
1747 fn extend_selection(&mut self, dx: i32, dy: i32) {
1751 let nrows = self.display_indices.len() as i32;
1752 let ncols = self.data.columns.len() as i32;
1753 if nrows == 0 || ncols == 0 {
1754 return;
1755 }
1756 let last_row = nrows - 1;
1757 let last_col = ncols - 1;
1758
1759 if self.range_anchor.is_none() || self.range_active.is_none() {
1761 match self.selection {
1762 Selection::Cell(r, c) => {
1763 self.range_anchor = Some((r, c));
1764 self.range_active = Some((r, c));
1765 }
1766 Selection::CellRange(r1, c1, r2, c2) => {
1767 self.range_anchor = Some((r1, c1));
1768 self.range_active = Some((r2, c2));
1769 }
1770 _ => {
1771 self.range_anchor = Some((0, 0));
1772 self.range_active = Some((0, 0));
1773 self.selection = Selection::Cell(0, 0);
1774 }
1775 }
1776 }
1777
1778 let anchor = self.range_anchor.unwrap_or((0, 0));
1779 let active = self.range_active.unwrap_or(anchor);
1780 let nr = (active.0 as i32 + dy).clamp(0, last_row) as usize;
1781 let nc = (active.1 as i32 + dx).clamp(0, last_col) as usize;
1782 self.range_active = Some((nr, nc));
1783
1784 self.selection = if (nr, nc) == anchor {
1785 Selection::Cell(nr, nc)
1786 } else {
1787 Selection::CellRange(
1788 anchor.0.min(nr),
1789 anchor.1.min(nc),
1790 anchor.0.max(nr),
1791 anchor.1.max(nc),
1792 )
1793 };
1794 }
1795
1796 pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
1797 let bounds = self.bounds;
1798 let (sx, sy) = (
1799 f32::from(self.scroll_handle.offset().x),
1800 f32::from(self.scroll_handle.offset().y),
1801 );
1802 let bw: f32 = bounds.size.width.into();
1803 let bh: f32 = bounds.size.height.into();
1804 let (mx, my) = self.max_scroll();
1805 if let Some(menu) = &self.context_menu {
1806 let cw = self.char_width;
1807 let x_rel = f32::from(pos.x);
1810 let y_rel = f32::from(pos.y);
1811 if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
1812 return HitResult::ContextMenuItem(idx);
1813 }
1814 }
1815 if my > 0.0
1816 && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
1817 && f32::from(pos.y) >= self.header_height
1818 {
1819 return HitResult::VerticalScrollbar;
1820 }
1821 if mx > 0.0
1822 && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
1823 && f32::from(pos.x) >= self.row_header_width
1824 {
1825 return HitResult::HorizontalScrollbar;
1826 }
1827 let px = f32::from(pos.x);
1833 let py = f32::from(pos.y);
1834 if px < 0.0 || py < 0.0 || px > bw || py > bh {
1835 return HitResult::None;
1836 }
1837 self.hit_test_content(px, py, sx, sy)
1838 }
1839
1840 fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
1841 if y < self.header_height {
1842 if x < self.row_header_width {
1843 return HitResult::Corner;
1844 }
1845 let col_x = x - self.row_header_width + sx;
1846 let mut acc = 0.0;
1847 for (i, col) in self.data.columns.iter().enumerate() {
1848 let right = acc + col.width;
1849 if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
1850 return HitResult::ColumnBorder(i);
1851 }
1852 if col_x >= acc && col_x < right {
1853 if col_x >= right - 20.0 {
1854 return HitResult::SortButton(i);
1855 }
1856 return HitResult::ColumnHeader(i);
1857 }
1858 acc = right;
1859 }
1860 return HitResult::None;
1861 }
1862 if x < self.row_header_width {
1863 let row_y = y - self.header_height + sy;
1864 if row_y < 0.0 {
1865 return HitResult::None;
1866 }
1867 let row_idx = (row_y / self.row_height) as usize;
1868 if row_idx < self.display_indices.len() {
1869 return HitResult::RowHeader(row_idx);
1870 }
1871 return HitResult::None;
1872 }
1873 let col_x = x - self.row_header_width + sx;
1874 let row_y = y - self.header_height + sy;
1875 if row_y < 0.0 {
1876 return HitResult::None;
1877 }
1878 let row_idx = (row_y / self.row_height) as usize;
1879 if row_idx >= self.display_indices.len() {
1880 return HitResult::None;
1881 }
1882 let mut acc = 0.0;
1883 for (i, col) in self.data.columns.iter().enumerate() {
1884 if col_x >= acc && col_x < acc + col.width {
1885 return HitResult::Cell(row_idx, i);
1886 }
1887 acc += col.width;
1888 }
1889 HitResult::None
1890 }
1891
1892 #[must_use]
1893 pub fn wants_edge_scroll_tick(&self) -> bool {
1894 self.is_dragging
1895 }
1896}
1897
1898fn keystroke_to_char(k: &Keystroke) -> Option<char> {
1899 if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
1900 return None;
1901 }
1902 if let Some(key_char) = k.key_char.as_ref() {
1903 return key_char.chars().next();
1904 }
1905 if k.key.chars().count() == 1 {
1906 let c = k.key.chars().next()?;
1907 if k.modifiers.shift {
1908 Some(c.to_ascii_uppercase())
1909 } else {
1910 Some(c)
1911 }
1912 } else {
1913 None
1914 }
1915}
1916
1917#[cfg(test)]
1918#[allow(
1919 clippy::unwrap_used,
1920 clippy::expect_used,
1921 clippy::field_reassign_with_default
1922)]
1923mod tests {
1924 use super::*;
1925 use crate::data::{CellValue, Column, ColumnKind};
1926 use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
1927
1928 fn input_with(text: &str, cursor: usize) -> TextInput {
1929 let mut p = TextInput::new(text.to_owned());
1930 p.cursor_chars = cursor;
1931 p
1932 }
1933
1934 #[test]
1935 fn text_input_new_cursors_at_char_count_not_bytes() {
1936 let p = TextInput::new("hé🙂".into());
1938 assert_eq!(p.cursor_chars, 3);
1939 assert_eq!(p.value.len(), 7);
1940 }
1941
1942 #[test]
1943 fn text_input_insert_emoji_at_start_does_not_panic() {
1944 let mut p = input_with("ab", 0);
1945 p.insert_char('\u{1F600}');
1946 assert_eq!(p.value, "\u{1F600}ab");
1947 assert_eq!(p.cursor_chars, 1);
1948 }
1949
1950 #[test]
1951 fn text_input_insert_in_middle_keeps_cursor_at_char_position() {
1952 let mut p = input_with("helloworld", 5);
1953 p.insert_char(' ');
1954 assert_eq!(p.value, "hello world");
1955 assert_eq!(p.cursor_chars, 6);
1956 }
1957
1958 #[test]
1959 fn text_input_backspace_at_zero_is_noop() {
1960 let mut p = input_with("abc", 0);
1961 p.backspace();
1962 assert_eq!(p.value, "abc");
1963 assert_eq!(p.cursor_chars, 0);
1964 }
1965
1966 #[test]
1967 fn text_input_backspace_removes_one_char_value() {
1968 let mut p = input_with("héx", 2);
1970 p.backspace();
1971 assert_eq!(p.value, "hx");
1972 assert_eq!(p.cursor_chars, 1);
1973 }
1974
1975 #[test]
1976 fn text_input_clamp_cursor_pulls_back_past_end() {
1977 let mut p = input_with("abc", 99);
1978 p.clamp_cursor();
1979 assert_eq!(p.cursor_chars, 3);
1980 }
1981
1982 #[test]
1983 fn text_input_move_left_and_right_respect_bounds() {
1984 let mut p = input_with("ab", 2);
1985 p.move_right();
1986 assert_eq!(p.cursor_chars, 2);
1987 p.move_left();
1988 p.move_left();
1989 p.move_left();
1990 assert_eq!(p.cursor_chars, 0);
1991 }
1992
1993 #[test]
1994 fn edge_scroll_speed_stops_outside_band() {
1995 assert_eq!(edge_scroll_speed(120.0), 0.0);
1997 assert_eq!(edge_scroll_speed(90.01), 0.0);
1998 assert_eq!(edge_scroll_speed(90.0), 4.0);
2000 assert_eq!(edge_scroll_speed(60.0), 4.0);
2001 assert_eq!(edge_scroll_speed(59.99), 8.0);
2002 assert_eq!(edge_scroll_speed(30.0), 8.0);
2004 assert_eq!(edge_scroll_speed(29.99), 16.0);
2005 assert_eq!(edge_scroll_speed(0.0), 16.0);
2007 assert_eq!(edge_scroll_speed(29.99), 16.0);
2008 }
2009
2010 #[test]
2011 fn edge_scroll_speed_caps_negative_runaway() {
2012 assert_eq!(edge_scroll_speed(-100.0), 16.0);
2014 assert_eq!(edge_scroll_speed(-1000.0), 16.0);
2015 }
2016
2017 #[allow(clippy::expect_used, clippy::unwrap_used)]
2025 #[test]
2026 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2027 fn grid_state_behavior_under_application() {
2028 gpui::Application::new().run(|cx| {
2029 let focus = cx.focus_handle();
2030
2031 let mut state = GridState::new(
2033 GridData::new(
2034 vec![Column::new("n", ColumnKind::Integer, 100.0)],
2035 vec![vec![CellValue::Integer(1)]],
2036 )
2037 .expect("rectangular"),
2038 crate::config::GridConfig::default(),
2039 focus.clone(),
2040 );
2041 let _ = format_current_status(&state);
2042 assert_eq!(state.selection, Selection::None);
2043
2044 state.last_mouse_pos = Some(Point {
2046 x: px(120.0),
2047 y: px(80.0),
2048 });
2049 let s = format_current_status(&state);
2050 assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
2051
2052 let mut state = GridState::new(
2054 GridData::new(
2055 vec![Column::new("name", ColumnKind::Text, 100.0)],
2056 vec![
2057 vec![CellValue::Text("alpha".into())],
2058 vec![CellValue::Text("beeb".into())],
2059 vec![CellValue::Text("gamma".into())],
2060 ],
2061 )
2062 .expect("rectangular"),
2063 crate::config::GridConfig::default(),
2064 focus.clone(),
2065 );
2066 state.filters[0] = ColumnFilter {
2067 predicate: FilterPredicate::Text {
2068 op: TextOp::Contains,
2069 operand: "a".into(),
2070 },
2071 values: None,
2072 };
2073 state.toggle_sort(0);
2074 state.recompute();
2075 assert_eq!(state.display_indices.as_slice(), &[0, 2]);
2076 state.toggle_sort(0);
2077 state.recompute();
2078 assert_eq!(state.display_indices.as_slice(), &[2, 0]);
2079 state.filters[0] = ColumnFilter::default();
2080 state.toggle_sort(0);
2081 state.recompute();
2082 assert_eq!(state.display_indices.as_slice(), &[0, 1, 2]);
2083
2084 let mut state = GridState::new(
2086 GridData::new(
2087 vec![Column::new("v", ColumnKind::Integer, 80.0)],
2088 vec![vec![CellValue::Integer(1)]],
2089 )
2090 .expect("rectangular"),
2091 crate::config::GridConfig::default(),
2092 focus.clone(),
2093 );
2094 state.toggle_sort(0);
2095 assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
2096 state.toggle_sort(0);
2097 assert_eq!(state.sort, Some((0, SortDirection::Descending)));
2098 state.toggle_sort(0);
2099 assert_eq!(state.sort, None);
2100
2101 let mut state = GridState::new(
2103 GridData::new(
2104 vec![
2105 Column::new("a", ColumnKind::Integer, 80.0),
2106 Column::new("b", ColumnKind::Integer, 80.0),
2107 ],
2108 vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
2109 )
2110 .expect("rectangular"),
2111 crate::config::GridConfig::default(),
2112 focus.clone(),
2113 );
2114 state.select_all();
2115 assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
2116
2117 let mut state = GridState::new(
2119 GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
2120 .expect("rectangular"),
2121 crate::config::GridConfig::default(),
2122 focus.clone(),
2123 );
2124 state.select_all();
2125 assert_eq!(state.selection, Selection::None);
2126
2127 let mut state = GridState::new(
2129 GridData::new(
2130 vec![Column::new("v", ColumnKind::Decimal, 100.0)],
2131 vec![vec![CellValue::Decimal(1.234)]],
2132 )
2133 .expect("rectangular"),
2134 crate::config::GridConfig::default(),
2135 focus.clone(),
2136 );
2137 assert_eq!(state.resolved_formats[0].number.decimals, 2);
2138 let mut cfg = crate::config::GridConfig::default();
2139 cfg.column_overrides = vec![crate::config::ColumnOverride {
2140 number: Some(crate::config::NumberFormat {
2141 decimals: 6,
2142 ..Default::default()
2143 }),
2144 ..Default::default()
2145 }];
2146 state.set_config(cfg);
2147 assert_eq!(state.resolved_formats[0].number.decimals, 6);
2148
2149 let mut state = GridState::new(
2151 GridData::new(
2152 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2153 vec![vec![CellValue::Integer(1)]],
2154 )
2155 .expect("rectangular"),
2156 crate::config::GridConfig::default(),
2157 focus.clone(),
2158 );
2159 assert!(!state.wants_edge_scroll_tick());
2160 state.is_dragging = true;
2161 assert!(state.wants_edge_scroll_tick());
2162
2163 cx.quit();
2164 });
2165 }
2166
2167 #[allow(clippy::expect_used, clippy::unwrap_used)]
2168 #[test]
2169 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2170 fn context_menu_request_construction() {
2171 use crate::grid::context_menu::ContextMenuTarget;
2172
2173 gpui::Application::new().run(|cx| {
2174 let focus = cx.focus_handle();
2175
2176 let mut state = GridState::new(
2178 GridData::new(
2179 vec![
2180 Column::new("id", ColumnKind::Integer, 80.0),
2181 Column::new("name", ColumnKind::Text, 100.0),
2182 ],
2183 vec![
2184 vec![CellValue::Integer(1), CellValue::Text("alpha".into())],
2185 vec![CellValue::Integer(2), CellValue::Text("beta".into())],
2186 vec![CellValue::Integer(3), CellValue::Text("gamma".into())],
2187 ],
2188 )
2189 .expect("rectangular"),
2190 crate::config::GridConfig::default(),
2191 focus.clone(),
2192 );
2193 state.sort = Some((0, SortDirection::Descending));
2195 state.recompute();
2196 assert_eq!(state.display_indices.as_slice(), &[2, 1, 0]);
2197
2198 let target = ContextMenuTarget::Cell {
2200 display_row_index: 0,
2201 source_row_index: 2,
2202 column_index: 1,
2203 };
2204 let sel = Selection::Cell(0, 1);
2205 let req = state.build_context_menu_request(target, &sel);
2206 assert_eq!(req.target.column_index(), Some(1));
2207 let cells = req.selected_cells();
2208 assert_eq!(cells.len(), 1);
2209 assert_eq!(cells[0].source_row_index, 2);
2210 assert_eq!(cells[0].column_name, "name");
2211 assert_eq!(cells[0].value, CellValue::Text("gamma".into()));
2212 let rows = req.selected_rows();
2213 assert_eq!(rows.len(), 1);
2214 assert_eq!(rows[0].source_row_index, 2);
2215 assert_eq!(rows[0].value_by_name("id"), Some(&CellValue::Integer(3)));
2216
2217 let target = ContextMenuTarget::Cell {
2219 display_row_index: 0,
2220 source_row_index: 2,
2221 column_index: 0,
2222 };
2223 let sel = Selection::CellRange(0, 0, 1, 1);
2224 let req = state.build_context_menu_request(target, &sel);
2225 assert_eq!(req.selected_cell_count(), 4); let rows = req.selected_rows();
2227 assert_eq!(rows.len(), 2);
2228 assert_eq!(rows[0].source_row_index, 2);
2230 assert_eq!(rows[1].source_row_index, 1);
2231
2232 let target = ContextMenuTarget::RowHeader {
2234 display_row_index: 1,
2235 source_row_index: 1,
2236 };
2237 let sel = Selection::RowRange(0, 2);
2238 let req = state.build_context_menu_request(target, &sel);
2239 let rows = req.selected_rows();
2240 assert_eq!(rows.len(), 3);
2241 assert_eq!(rows[0].values.len(), 2);
2243 assert_eq!(req.selected_cell_count(), 6); let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2249 let sel = Selection::Column(0);
2250 let req = state.build_context_menu_request(target, &sel);
2251 assert!(req.is_column_oriented());
2252 assert_eq!(req.selected_row_count(), 0);
2253 assert!(req.selected_rows().is_empty());
2254 assert_eq!(req.selected_cells().len(), 3); let empty_state = GridState::new(
2258 GridData::new(vec![Column::new("x", ColumnKind::Integer, 80.0)], vec![])
2259 .expect("rectangular"),
2260 crate::config::GridConfig::default(),
2261 focus.clone(),
2262 );
2263 let target = ContextMenuTarget::Cell {
2264 display_row_index: 0,
2265 source_row_index: 0,
2266 column_index: 0,
2267 };
2268 let req = empty_state.build_context_menu_request(target, &Selection::None);
2269 assert!(req.selected_cells().is_empty());
2270 assert!(req.selected_rows().is_empty());
2271
2272 cx.quit();
2273 });
2274 }
2275
2276 #[allow(clippy::expect_used, clippy::unwrap_used)]
2277 #[test]
2278 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2279 fn effective_selection_for_context_target() {
2280 gpui::Application::new().run(|cx| {
2281 let focus = cx.focus_handle();
2282 let mut state = GridState::new(
2283 GridData::new(
2284 vec![
2285 Column::new("a", ColumnKind::Integer, 80.0),
2286 Column::new("b", ColumnKind::Integer, 80.0),
2287 ],
2288 vec![
2289 vec![CellValue::Integer(1), CellValue::Integer(2)],
2290 vec![CellValue::Integer(3), CellValue::Integer(4)],
2291 ],
2292 )
2293 .expect("rectangular"),
2294 crate::config::GridConfig::default(),
2295 focus,
2296 );
2297
2298 state.selection = Selection::Cell(0, 0);
2300 let target = ContextMenuTarget::Cell {
2301 display_row_index: 1,
2302 source_row_index: 1,
2303 column_index: 1,
2304 };
2305 let eff = state.effective_selection_for_context_target(&target);
2306 assert_eq!(eff, Selection::Cell(1, 1));
2307
2308 state.selection = Selection::CellRange(0, 0, 1, 1);
2310 let target = ContextMenuTarget::Cell {
2311 display_row_index: 1,
2312 source_row_index: 1,
2313 column_index: 1,
2314 };
2315 let eff = state.effective_selection_for_context_target(&target);
2316 assert_eq!(eff, Selection::CellRange(0, 0, 1, 1));
2317
2318 state.selection = Selection::Cell(0, 0);
2320 let target = ContextMenuTarget::RowHeader {
2321 display_row_index: 1,
2322 source_row_index: 1,
2323 };
2324 let eff = state.effective_selection_for_context_target(&target);
2325 assert_eq!(eff, Selection::Row(1));
2326
2327 state.selection = Selection::RowRange(0, 1);
2329 let target = ContextMenuTarget::RowHeader {
2330 display_row_index: 1,
2331 source_row_index: 1,
2332 };
2333 let eff = state.effective_selection_for_context_target(&target);
2334 assert_eq!(eff, Selection::RowRange(0, 1));
2335
2336 state.selection = Selection::Cell(1, 1);
2338 let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2339 let eff = state.effective_selection_for_context_target(&target);
2340 assert_eq!(eff, Selection::Cell(1, 1));
2341
2342 cx.quit();
2343 });
2344 }
2345
2346 #[allow(clippy::expect_used, clippy::unwrap_used)]
2347 #[test]
2348 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2349 fn context_menu_target_from_hit_maps_correctly() {
2350 gpui::Application::new().run(|cx| {
2351 let focus = cx.focus_handle();
2352 let state = GridState::new(
2353 GridData::new(
2354 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2355 vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(2)]],
2356 )
2357 .expect("rectangular"),
2358 crate::config::GridConfig::default(),
2359 focus,
2360 );
2361
2362 let t = state
2364 .context_menu_target_from_hit(HitResult::Cell(1, 0))
2365 .unwrap();
2366 assert_eq!(
2367 t,
2368 ContextMenuTarget::Cell {
2369 display_row_index: 1,
2370 source_row_index: 1,
2371 column_index: 0,
2372 }
2373 );
2374
2375 let t = state
2377 .context_menu_target_from_hit(HitResult::RowHeader(0))
2378 .unwrap();
2379 assert_eq!(
2380 t,
2381 ContextMenuTarget::RowHeader {
2382 display_row_index: 0,
2383 source_row_index: 0,
2384 }
2385 );
2386
2387 let t = state
2389 .context_menu_target_from_hit(HitResult::ColumnHeader(0))
2390 .unwrap();
2391 assert_eq!(t, ContextMenuTarget::ColumnHeader { column_index: 0 });
2392
2393 let t = state
2395 .context_menu_target_from_hit(HitResult::SortButton(0))
2396 .unwrap();
2397 assert_eq!(t, ContextMenuTarget::SortButton { column_index: 0 });
2398
2399 assert!(state
2401 .context_menu_target_from_hit(HitResult::VerticalScrollbar)
2402 .is_none());
2403 assert!(state
2404 .context_menu_target_from_hit(HitResult::None)
2405 .is_none());
2406
2407 cx.quit();
2408 });
2409 }
2410
2411 #[allow(clippy::expect_used, clippy::unwrap_used)]
2412 #[test]
2413 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2414 fn convert_context_menu_items_maps_variants() {
2415 use crate::grid::context_menu::ContextMenuItem;
2416
2417 let items = vec![
2418 ContextMenuItem::BuiltIn(MenuAction::SortAscending),
2419 ContextMenuItem::action("copy", "Copy value"),
2420 ContextMenuItem::separator(),
2421 ];
2422 let internal = GridState::convert_context_menu_items(items);
2423 assert!(matches!(
2424 internal[0],
2425 MenuItem::Action(MenuAction::SortAscending)
2426 ));
2427 assert!(
2428 matches!(&internal[1], MenuItem::Custom { id, label } if id == "copy" && label == "Copy value")
2429 );
2430 assert!(matches!(internal[2], MenuItem::Separator));
2431 }
2432
2433 #[allow(clippy::expect_used, clippy::unwrap_used)]
2434 #[test]
2435 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2436 fn execute_custom_context_menu_action_invokes_provider() {
2437 use crate::grid::context_menu::{
2438 ContextMenuProvider, ContextMenuProviderHandle, ContextMenuRequest,
2439 };
2440 use std::sync::{Arc, Mutex};
2441
2442 #[derive(Default)]
2443 struct TestProvider {
2444 last_action: Arc<Mutex<Option<String>>>,
2445 }
2446 impl ContextMenuProvider for TestProvider {
2447 fn menu_items(&self, _request: &ContextMenuRequest) -> Vec<ContextMenuItem> {
2448 vec![ContextMenuItem::action("test", "Test")]
2449 }
2450 fn on_action(
2451 &self,
2452 action_id: &str,
2453 _request: &ContextMenuRequest,
2454 _state: &mut GridState,
2455 _cx: &mut gpui::App,
2456 ) {
2457 *self.last_action.lock().unwrap() = Some(action_id.to_string());
2458 }
2459 }
2460
2461 gpui::Application::new().run(|cx| {
2462 let focus = cx.focus_handle();
2463 let mut state = GridState::new(
2464 GridData::new(
2465 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2466 vec![vec![CellValue::Integer(1)]],
2467 )
2468 .expect("rectangular"),
2469 crate::config::GridConfig::default(),
2470 focus,
2471 );
2472
2473 let last = Arc::new(Mutex::new(None));
2474 state.context_menu_provider = Some(ContextMenuProviderHandle::new(TestProvider {
2475 last_action: last.clone(),
2476 }));
2477
2478 let target = ContextMenuTarget::Cell {
2479 display_row_index: 0,
2480 source_row_index: 0,
2481 column_index: 0,
2482 };
2483 let request = state.build_context_menu_request(target, &Selection::Cell(0, 0));
2484 state.execute_custom_context_menu_action(
2485 PendingCustomContextMenuAction {
2486 id: "test".into(),
2487 request,
2488 },
2489 cx,
2490 );
2491 assert_eq!(*last.lock().unwrap(), Some("test".to_string()));
2492 assert!(state.context_menu.is_none());
2493
2494 cx.quit();
2495 });
2496 }
2497
2498 #[test]
2499 fn filter_panel_to_filter_with_all_checked_has_no_value_set() {
2500 let panel = FilterPanel {
2501 col: 0,
2502 anchor: Point {
2503 x: px(0.0),
2504 y: px(0.0),
2505 },
2506 kind: ColumnKind::Text,
2507 search: TextInput::default(),
2508 op_index: 0,
2509 op_menu_open: false,
2510 operand_a: TextInput::default(),
2511 operand_b: TextInput::default(),
2512 focus: FilterInput::Search,
2513 auto_apply: true,
2514 distinct: vec![
2515 FilterValueRow {
2516 label: "alpha".into(),
2517 checked: true,
2518 },
2519 FilterValueRow {
2520 label: "beta".into(),
2521 checked: true,
2522 },
2523 ],
2524 };
2525 let f = panel.to_filter();
2526 assert!(f.values.is_none(), "all checked => no value allow-list");
2527 assert!(
2528 !f.is_active(),
2529 "default predicate + all checked => inactive"
2530 );
2531 }
2532
2533 #[test]
2534 fn filter_panel_to_filter_with_unchecked_value_builds_allow_set() {
2535 let panel = FilterPanel {
2536 col: 0,
2537 anchor: Point {
2538 x: px(0.0),
2539 y: px(0.0),
2540 },
2541 kind: ColumnKind::Text,
2542 search: TextInput::default(),
2543 op_index: 0,
2544 op_menu_open: false,
2545 operand_a: TextInput::default(),
2546 operand_b: TextInput::default(),
2547 focus: FilterInput::Search,
2548 auto_apply: true,
2549 distinct: vec![
2550 FilterValueRow {
2551 label: "alpha".into(),
2552 checked: true,
2553 },
2554 FilterValueRow {
2555 label: "beta".into(),
2556 checked: false,
2557 },
2558 ],
2559 };
2560 let f = panel.to_filter();
2561 assert!(f.is_active(), "unchecked value => active filter");
2562 let set = f.values.expect("should have a value set");
2563 assert!(set.contains("alpha"));
2564 assert!(!set.contains("beta"));
2565 }
2566
2567 #[test]
2568 fn filter_panel_visible_indices_respects_search() {
2569 let panel = FilterPanel {
2570 col: 0,
2571 anchor: Point {
2572 x: px(0.0),
2573 y: px(0.0),
2574 },
2575 kind: ColumnKind::Text,
2576 search: TextInput::new("al".into()),
2577 op_index: 0,
2578 op_menu_open: false,
2579 operand_a: TextInput::default(),
2580 operand_b: TextInput::default(),
2581 focus: FilterInput::Search,
2582 auto_apply: true,
2583 distinct: vec![
2584 FilterValueRow {
2585 label: "alpha".into(),
2586 checked: true,
2587 },
2588 FilterValueRow {
2589 label: "beta".into(),
2590 checked: true,
2591 },
2592 FilterValueRow {
2593 label: "gamma".into(),
2594 checked: true,
2595 },
2596 ],
2597 };
2598 let vis = panel.visible_indices();
2599 assert_eq!(vis, vec![0], "search 'al' matches only alpha");
2600 }
2601
2602 #[test]
2603 fn filter_panel_all_checked_ignores_search() {
2604 let mut panel = FilterPanel {
2605 col: 0,
2606 anchor: Point {
2607 x: px(0.0),
2608 y: px(0.0),
2609 },
2610 kind: ColumnKind::Text,
2611 search: TextInput::new("al".into()),
2612 op_index: 0,
2613 op_menu_open: false,
2614 operand_a: TextInput::default(),
2615 operand_b: TextInput::default(),
2616 focus: FilterInput::Search,
2617 auto_apply: true,
2618 distinct: vec![
2619 FilterValueRow {
2620 label: "alpha".into(),
2621 checked: true,
2622 },
2623 FilterValueRow {
2624 label: "beta".into(),
2625 checked: false,
2626 },
2627 FilterValueRow {
2628 label: "gamma".into(),
2629 checked: true,
2630 },
2631 ],
2632 };
2633 assert!(
2636 !panel.all_checked(),
2637 "beta is unchecked, so not all values are checked (search is irrelevant)"
2638 );
2639
2640 panel.search = TextInput::new("zzz".into());
2642 for row in &mut panel.distinct {
2643 row.checked = true;
2644 }
2645 assert!(
2646 panel.all_checked(),
2647 "all values checked -> Select All stays checked regardless of empty search"
2648 );
2649 }
2650
2651 #[allow(clippy::expect_used, clippy::unwrap_used)]
2652 #[test]
2653 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2654 fn filter_panel_open_apply_clear_state_flow() {
2655 gpui::Application::new().run(|cx| {
2656 let focus = cx.focus_handle();
2657 let mut state = GridState::new(
2658 GridData::new(
2659 vec![Column::new("name", ColumnKind::Text, 100.0)],
2660 vec![
2661 vec![CellValue::Text("alpha".into())],
2662 vec![CellValue::Text("beta".into())],
2663 vec![CellValue::Text("gamma".into())],
2664 ],
2665 )
2666 .expect("rectangular"),
2667 crate::config::GridConfig::default(),
2668 focus,
2669 );
2670
2671 let anchor = Point {
2673 x: px(50.0),
2674 y: px(20.0),
2675 };
2676 state.open_filter_panel(0, Some(anchor));
2677 let panel = state.filter_panel.as_ref().expect("panel should be open");
2678 assert_eq!(panel.col, 0);
2679 assert_eq!(panel.anchor, anchor);
2680 assert_eq!(panel.distinct.len(), 3);
2681 assert!(
2682 panel.distinct.iter().all(|r| r.checked),
2683 "all checked by default"
2684 );
2685 assert!(panel.auto_apply, "auto_apply defaults to true");
2686 assert_eq!(panel.kind, ColumnKind::Text);
2687
2688 state.toggle_filter_value(1);
2690 state.apply_filter_panel();
2691 assert_eq!(
2692 state.display_indices.as_slice(),
2693 &[0, 2],
2694 "beta should be filtered out"
2695 );
2696
2697 state.clear_filter_panel();
2699 assert_eq!(
2700 state.display_indices.as_slice(),
2701 &[0, 1, 2],
2702 "all rows visible after clear"
2703 );
2704 assert!(
2705 state.filters[0] == ColumnFilter::default(),
2706 "filter reset to default"
2707 );
2708
2709 state.open_filter_panel(0, Some(anchor));
2711 let panel = state.filter_panel.as_mut().expect("panel open");
2712 panel.op_index = 1; panel.operand_a = TextInput::new("a".into());
2714 state.apply_filter_panel();
2715 assert_eq!(
2716 state.display_indices.as_slice(),
2717 &[0, 2],
2718 "contains 'a' matches alpha and gamma"
2719 );
2720
2721 state.clear_filter_panel();
2723 assert_eq!(state.display_indices.as_slice(), &[0, 1, 2]);
2724
2725 cx.quit();
2726 });
2727 }
2728}