1use crate::compare_cells;
6use crate::data::{CellValue, ColumnKind, GridData, GridDataError};
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 append_rows(&mut self, rows: Vec<Vec<CellValue>>) -> Result<(), GridDataError> {
715 let expected = self.data.columns.len();
716 let base = self.data.rows.len();
717 for (offset, row) in rows.iter().enumerate() {
718 if row.len() != expected {
719 return Err(GridDataError::RaggedRow {
720 row_index: base + offset,
721 expected,
722 actual: row.len(),
723 });
724 }
725 }
726 if rows.is_empty() {
727 return Ok(());
728 }
729 Arc::make_mut(&mut self.data_rows).extend(rows.iter().cloned());
730 self.data.rows.extend(rows);
731 if self.sort.is_some() || self.filters.iter().any(ColumnFilter::is_active) {
732 self.recompute();
733 } else {
734 Arc::make_mut(&mut self.display_indices).extend(base..self.data.rows.len());
735 }
736 Ok(())
737 }
738
739 pub fn set_debug_bar_enabled(&mut self, enabled: bool) {
743 self.debug_bar_enabled = enabled;
744 }
745
746 #[must_use]
749 pub fn is_busy(&self) -> bool {
750 self.busy.is_some()
751 }
752
753 #[must_use]
755 pub fn busy(&self) -> Option<&BusyState> {
756 self.busy.as_ref()
757 }
758
759 pub fn set_busy(&mut self, label: impl Into<String>) {
764 self.busy = Some(BusyState {
765 label: label.into(),
766 progress: None,
767 });
768 }
769
770 pub fn set_busy_progress(&mut self, progress: f32) {
773 if let Some(b) = self.busy.as_mut() {
774 b.progress = Some(progress.clamp(0.0, 1.0));
775 }
776 }
777
778 pub fn clear_busy(&mut self) {
780 self.busy = None;
781 }
782
783 pub fn spawn_background<R, W, D>(
800 &mut self,
801 cx: &mut App,
802 label: impl Into<String>,
803 work: W,
804 on_done: D,
805 ) where
806 R: Send + 'static,
807 W: FnOnce() -> R + Send + 'static,
808 D: FnOnce(R, &mut GridState, &mut App) + 'static,
809 {
810 let Some(weak) = self.self_weak.clone() else {
811 let result = work();
813 on_done(result, self, cx);
814 return;
815 };
816
817 self.busy = Some(BusyState {
818 label: label.into(),
819 progress: None,
820 });
821
822 let background = cx.background_executor().clone();
823 cx.spawn(async move |cx| {
824 let _ = cx.update(|app| {
826 let _ = weak.update(app, |_s, c| c.notify());
827 });
828 let result = background.spawn(async move { work() }).await;
829 let _ = cx.update(|app| {
830 let _ = weak.update(app, |s, c| {
831 s.busy = None;
832 on_done(result, s, c);
833 c.notify();
834 });
835 });
836 })
837 .detach();
838 }
839
840 fn rebuild_resolved_formats(&mut self) {
841 self.resolved_formats = self.config.resolve_all(&self.data.columns);
842 }
843
844 pub fn recompute(&mut self) {
845 let mut indices: Vec<usize> = (0..self.data.rows.len())
846 .filter(|&row_idx| {
847 self.data.columns.iter().enumerate().all(|(col_idx, _col)| {
848 let filter = &self.filters[col_idx];
849 if !filter.is_active() {
850 return true;
851 }
852 let cell = &self.data.rows[row_idx][col_idx];
853 cell_passes_filter(cell, &self.resolved_formats[col_idx], filter)
854 })
855 })
856 .collect();
857
858 if let Some((sort_col, direction)) = self.sort {
859 indices.sort_by(|&a, &b| {
860 let cell_a = &self.data.rows[a][sort_col];
861 let cell_b = &self.data.rows[b][sort_col];
862 let ord = compare_cells(cell_a, cell_b);
863 match direction {
864 SortDirection::Ascending => ord,
865 SortDirection::Descending => ord.reverse(),
866 }
867 });
868 }
869 self.display_indices = Arc::new(indices);
870 }
871
872 fn content_size(&self) -> (f32, f32) {
873 let cw: f32 = self.data.columns.iter().map(|c| c.width).sum();
874 let ch = self.display_indices.len() as f32 * self.row_height;
875 (cw, ch)
876 }
877
878 pub(crate) fn max_scroll(&self) -> (f32, f32) {
879 let (cw, ch) = self.content_size();
880 let (rw, rh) = self.scrollbar_reserved();
881 let vw: f32 = self.bounds.size.width.into();
882 let vh: f32 = self.bounds.size.height.into();
883 let vw = vw - self.row_header_width - rw;
884 let vh = vh - self.header_height - rh;
885 ((cw - vw).max(0.0), (ch - vh).max(0.0))
886 }
887
888 fn scrollbar_reserved(&self) -> (f32, f32) {
889 let (cw, ch) = self.content_size();
890 let vw: f32 = self.bounds.size.width.into();
891 let vh: f32 = self.bounds.size.height.into();
892 let vw = vw - self.row_header_width;
893 let vh = vh - self.header_height;
894 let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
895 let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
896 (reserved_w, reserved_h)
897 }
898
899 fn vbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
900 let (_, ch) = self.content_size();
901 let (_, rh) = self.scrollbar_reserved();
902 let vh: f32 = self.bounds.size.height.into();
903 let vh = vh - self.header_height - rh;
904 if ch <= vh {
905 return None;
906 }
907 let sw: f32 = self.bounds.size.width.into();
910 let sh: f32 = self.bounds.size.height.into();
911 let track_x = sw - SCROLLBAR_SIZE;
912 let track_y = self.header_height;
913 let track_h = sh - self.header_height - rh;
914 let thumb_h = ((track_h * (vh / ch)).max(20.0)).min(track_h);
915 Some((track_x, track_y, SCROLLBAR_SIZE, track_h, thumb_h))
916 }
917
918 fn hbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
919 let (cw, _) = self.content_size();
920 let (rw, _) = self.scrollbar_reserved();
921 let vw: f32 = self.bounds.size.width.into();
922 let vw = vw - self.row_header_width - rw;
923 if cw <= vw {
924 return None;
925 }
926 let sw: f32 = self.bounds.size.width.into();
929 let sh: f32 = self.bounds.size.height.into();
930 let track_x = self.row_header_width;
931 let track_y = sh - SCROLLBAR_SIZE;
932 let track_w = sw - self.row_header_width - rw;
933 let thumb_w = ((track_w * (vw / cw)).max(20.0)).min(track_w);
934 Some((track_x, track_y, track_w, SCROLLBAR_SIZE, thumb_w))
935 }
936
937 pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
938 if let Some((_, track_y, _, track_h, thumb_h)) = self.vbar_geom() {
939 let (_, max_y) = self.max_scroll();
940 let range = (track_h - thumb_h).max(0.0);
941 let rel = (mouse_y - track_y - thumb_h * 0.5).clamp(0.0, range);
942 let frac = if range > 0.0 { rel / range } else { 0.0 };
943 let new_y = frac * max_y;
944 let x = self.scroll_handle.offset().x;
945 self.scroll_handle.set_offset(Point { x, y: px(new_y) });
946 }
947 }
948
949 pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
950 if let Some((track_x, _, track_w, _, thumb_w)) = self.hbar_geom() {
951 let (max_x, _) = self.max_scroll();
952 let range = (track_w - thumb_w).max(0.0);
953 let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
954 let frac = if range > 0.0 { rel / range } else { 0.0 };
955 let new_x = frac * max_x;
956 let y = self.scroll_handle.offset().y;
957 self.scroll_handle.set_offset(Point { x: px(new_x), y });
958 }
959 }
960
961 pub(crate) fn scroll_one_edge_tick(&mut self, dx: f32, dy: f32) {
962 let (mx, my) = self.max_scroll();
963 let s = self.scroll_handle.offset();
964 let new_x: f32 = (f32::from(s.x) + dx).clamp(0.0, mx);
965 let new_y: f32 = (f32::from(s.y) + dy).clamp(0.0, my);
966 self.scroll_handle.set_offset(Point {
967 x: px(new_x),
968 y: px(new_y),
969 });
970 }
971
972 pub fn toggle_sort(&mut self, col: usize) {
973 self.sort = match self.sort {
974 Some((c, SortDirection::Ascending)) if c == col => {
975 Some((col, SortDirection::Descending))
976 }
977 Some((c, SortDirection::Descending)) if c == col => None,
978 _ => Some((col, SortDirection::Ascending)),
979 };
980 self.recompute();
981 }
982
983 pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
984 let hit = self.hit_test(pos);
985 self.click_pos = Some(pos);
986 self.click_hit = Some(hit);
987 match hit {
988 HitResult::VerticalScrollbar => {
989 self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
990 self.scroll_to_vbar(f32::from(pos.y));
991 self.clear_drag();
992 }
993 HitResult::HorizontalScrollbar => {
994 self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
995 self.scroll_to_hbar(f32::from(pos.x));
996 self.clear_drag();
997 }
998 HitResult::ColumnBorder(col) => {
999 self.resizing_col = Some(col);
1000 self.resize_start_x = f32::from(pos.x);
1001 self.resize_start_width = self.data.columns[col].width;
1002 self.clear_drag();
1003 }
1004 HitResult::ColumnHeader(col) => {
1005 self.selection = Selection::Column(col);
1006 self.clear_drag();
1007 }
1008 HitResult::SortButton(col) => {
1009 self.toggle_sort(col);
1012 self.clear_drag();
1013 }
1014 HitResult::ContextMenuItem(_) => {}
1015 HitResult::RowHeader(row) => {
1016 self.selection = if shift {
1017 if let Selection::Row(prev) = self.selection {
1018 let (s, e) = (prev, row);
1019 Selection::RowRange(s.min(e), s.max(e))
1020 } else {
1021 Selection::Row(row)
1022 }
1023 } else {
1024 Selection::Row(row)
1025 };
1026 self.start_drag(pos);
1027 self.drag_start_hit = Some(HitResult::RowHeader(row));
1028 }
1029 HitResult::Cell(row, col) => {
1030 self.selection = if shift {
1031 let anchor = self
1033 .range_anchor
1034 .or(match self.selection {
1035 Selection::Cell(pr, pc) => Some((pr, pc)),
1036 _ => None,
1037 })
1038 .unwrap_or((row, col));
1039 self.range_anchor = Some(anchor);
1040 self.range_active = Some((row, col));
1041 Selection::CellRange(
1042 anchor.0.min(row),
1043 anchor.1.min(col),
1044 anchor.0.max(row),
1045 anchor.1.max(col),
1046 )
1047 } else {
1048 self.range_anchor = Some((row, col));
1049 self.range_active = Some((row, col));
1050 Selection::Cell(row, col)
1051 };
1052 self.start_drag(pos);
1053 self.drag_start_hit = Some(HitResult::Cell(row, col));
1054 }
1055 HitResult::Corner | HitResult::None => {
1056 self.selection = Selection::None;
1057 self.range_anchor = None;
1058 self.range_active = None;
1059 self.context_menu = None;
1060 self.filter_panel = None;
1061 self.clear_drag();
1062 }
1063 }
1064 }
1065
1066 fn start_drag(&mut self, pos: Point<Pixels>) {
1067 self.is_dragging = false;
1068 self.drag_start = Some(pos);
1069 self.scroll_at_click = Some(self.scroll_handle.offset());
1070 self.last_mouse_pos = Some(pos);
1071 }
1072
1073 pub(crate) fn open_context_menu(&mut self, col: usize, anchor: Point<Pixels>) {
1074 self.context_menu = Some(menu_mod::ContextMenu::standard(col, anchor));
1075 self.filter_panel = None;
1076 }
1077
1078 pub(crate) fn context_menu_target_from_hit(&self, hit: HitResult) -> Option<ContextMenuTarget> {
1081 match hit {
1082 HitResult::Cell(row, col) => {
1083 let source_row = self.display_indices.get(row).copied().unwrap_or(row);
1084 Some(ContextMenuTarget::Cell {
1085 display_row_index: row,
1086 source_row_index: source_row,
1087 column_index: col,
1088 })
1089 }
1090 HitResult::RowHeader(row) => {
1091 let source_row = self.display_indices.get(row).copied().unwrap_or(row);
1092 Some(ContextMenuTarget::RowHeader {
1093 display_row_index: row,
1094 source_row_index: source_row,
1095 })
1096 }
1097 HitResult::ColumnHeader(col) => {
1098 Some(ContextMenuTarget::ColumnHeader { column_index: col })
1099 }
1100 HitResult::SortButton(col) => Some(ContextMenuTarget::SortButton { column_index: col }),
1101 _ => None,
1102 }
1103 }
1104
1105 pub(crate) fn effective_selection_for_context_target(
1110 &self,
1111 target: &ContextMenuTarget,
1112 ) -> Selection {
1113 match target {
1114 ContextMenuTarget::Cell {
1115 display_row_index,
1116 column_index,
1117 ..
1118 } => {
1119 if is_cell_selected(&self.selection, *display_row_index, *column_index) {
1120 self.selection.clone()
1121 } else {
1122 Selection::Cell(*display_row_index, *column_index)
1123 }
1124 }
1125 ContextMenuTarget::RowHeader {
1126 display_row_index, ..
1127 } => {
1128 if is_row_selected(&self.selection, *display_row_index) {
1129 self.selection.clone()
1130 } else {
1131 Selection::Row(*display_row_index)
1132 }
1133 }
1134 ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. } => {
1135 self.selection.clone()
1136 }
1137 }
1138 }
1139
1140 pub(crate) fn build_context_menu_request(
1152 &self,
1153 target: ContextMenuTarget,
1154 selection: &Selection,
1155 ) -> ContextMenuRequest {
1156 let nrows = self.display_indices.len();
1157 let ncols = self.data.columns.len();
1158
1159 let (r1, c1, r2, c2) = match selection.normalized_bounds() {
1160 Some((r1, c1, r2, c2)) => {
1161 let r1 = r1.min(nrows.saturating_sub(1));
1162 let r2 = r2.min(nrows.saturating_sub(1));
1163 let c1 = c1.min(ncols.saturating_sub(1));
1164 let c2 = c2.min(ncols.saturating_sub(1));
1165 (r1, c1, r2, c2)
1166 }
1167 None => match &target {
1168 ContextMenuTarget::Cell {
1169 display_row_index,
1170 column_index,
1171 ..
1172 } => (
1173 *display_row_index,
1174 *column_index,
1175 *display_row_index,
1176 *column_index,
1177 ),
1178 ContextMenuTarget::RowHeader {
1179 display_row_index, ..
1180 } => (
1181 *display_row_index,
1182 0,
1183 *display_row_index,
1184 ncols.saturating_sub(1),
1185 ),
1186 ContextMenuTarget::ColumnHeader { column_index }
1187 | ContextMenuTarget::SortButton { column_index } => {
1188 (0, *column_index, nrows.saturating_sub(1), *column_index)
1189 }
1190 },
1191 };
1192
1193 let menu_selection = ContextMenuSelection {
1194 row_start: r1,
1195 row_end: r2,
1196 column_start: c1,
1197 column_end: c2,
1198 };
1199
1200 let column_oriented = matches!(
1205 target,
1206 ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. }
1207 ) || matches!(selection, Selection::Column(_));
1208
1209 ContextMenuRequest::new(
1210 target,
1211 Some(menu_selection),
1212 Arc::clone(&self.data_rows),
1213 Arc::clone(&self.display_indices),
1214 Arc::clone(&self.column_meta),
1215 column_oriented,
1216 )
1217 }
1218
1219 pub(crate) fn execute_custom_context_menu_action(
1223 &mut self,
1224 pending: PendingCustomContextMenuAction,
1225 cx: &mut App,
1226 ) {
1227 self.context_menu = None;
1228 self.filter_panel = None;
1229
1230 let Some(provider) = self.context_menu_provider.clone() else {
1231 return;
1232 };
1233
1234 provider.on_action(&pending.id, &pending.request, self, cx);
1235 }
1236
1237 pub(crate) fn convert_context_menu_items(items: Vec<ContextMenuItem>) -> Vec<MenuItem> {
1240 items
1241 .into_iter()
1242 .map(|item| match item {
1243 ContextMenuItem::BuiltIn(action) => MenuItem::Action(action),
1244 ContextMenuItem::Action { id, label } => MenuItem::Custom { id, label },
1245 ContextMenuItem::Separator => MenuItem::Separator,
1246 })
1247 .collect()
1248 }
1249
1250 pub fn execute_action(&mut self, action: MenuAction, col: usize, cx: &mut App) {
1251 match action {
1252 MenuAction::SelectColumn => {
1253 self.selection = Selection::Column(col);
1254 }
1255 MenuAction::CopyColumn => {
1256 let text = self.column_text(col);
1257 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1258 }
1259 MenuAction::CopyColumnWithHeaders => {
1260 let mut text = String::new();
1261 text.push_str(&self.data.columns[col].name);
1262 text.push('\n');
1263 text.push_str(&self.column_text(col));
1264 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1265 }
1266 MenuAction::SortAscending => {
1267 self.sort = Some((col, SortDirection::Ascending));
1268 self.recompute();
1269 }
1270 MenuAction::SortDescending => {
1271 self.sort = Some((col, SortDirection::Descending));
1272 self.recompute();
1273 }
1274 MenuAction::ClearSort => {
1275 self.sort = None;
1276 self.recompute();
1277 }
1278 MenuAction::FilterPrompt => {
1279 let anchor = self.context_menu.as_ref().map(|m| m.anchor);
1280 self.open_filter_panel(col, anchor);
1281 }
1282 MenuAction::ClearFilter => {
1283 if col < self.filters.len() {
1284 self.filters[col] = ColumnFilter::default();
1285 self.recompute();
1286 }
1287 }
1288 }
1289 self.context_menu = None;
1290 }
1291
1292 pub fn open_filter_panel(&mut self, col: usize, _anchor: Option<Point<Pixels>>) {
1303 if col >= self.data.columns.len() {
1304 return;
1305 }
1306 let sx = f32::from(self.scroll_handle.offset().x);
1307 let col_x = self.row_header_width
1308 + self.data.columns[..col]
1309 .iter()
1310 .map(|c| c.width)
1311 .sum::<f32>()
1312 - sx;
1313 let anchor = Point {
1314 x: px(col_x + self.data.columns[col].width * 0.5),
1315 y: px(0.0),
1316 };
1317 let kind = self.data.columns[col].kind;
1318 let existing = self.filters.get(col).cloned().unwrap_or_default();
1319
1320 let distinct = {
1322 let fmt = &self.resolved_formats[col];
1323 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1324 let mut pairs: Vec<(String, &CellValue)> = Vec::new();
1325 for row in &self.data.rows {
1326 let cell = &row[col];
1327 let (label, _) = format_cell(cell, fmt);
1328 if seen.insert(label.clone()) {
1329 pairs.push((label, cell));
1330 }
1331 }
1332 pairs.sort_by(|(_, a), (_, b)| compare_cells(a, b));
1333 pairs
1334 .into_iter()
1335 .map(|(label, _)| {
1336 let checked = match &existing.values {
1337 None => true,
1338 Some(set) => set.contains(&label),
1339 };
1340 FilterValueRow { label, checked }
1341 })
1342 .collect()
1343 };
1344
1345 let (op_index, operand_a, operand_b) = seed_operator(kind, &existing.predicate);
1346
1347 self.context_menu = None;
1348 self.filter_panel = Some(FilterPanel {
1349 col,
1350 anchor,
1351 kind,
1352 search: TextInput::default(),
1353 op_index,
1354 op_menu_open: false,
1355 operand_a: TextInput::new(operand_a),
1356 operand_b: TextInput::new(operand_b),
1357 focus: FilterInput::Search,
1358 auto_apply: true,
1359 distinct,
1360 });
1361 }
1362
1363 pub fn apply_filter_panel(&mut self) {
1366 let Some(panel) = &self.filter_panel else {
1367 return;
1368 };
1369 let col = panel.col;
1370 let filter = panel.to_filter();
1371 if col < self.filters.len() {
1372 self.filters[col] = filter;
1373 self.recompute();
1374 }
1375 }
1376
1377 pub fn maybe_auto_apply(&mut self) {
1379 if self.filter_panel.is_some() {
1380 self.apply_filter_panel();
1381 }
1382 }
1383
1384 pub fn clear_filter_panel(&mut self) {
1387 let mut target_col = None;
1388 if let Some(panel) = &mut self.filter_panel {
1389 panel.op_index = 0;
1390 panel.op_menu_open = false;
1391 panel.operand_a = TextInput::default();
1392 panel.operand_b = TextInput::default();
1393 panel.search = TextInput::default();
1394 for row in &mut panel.distinct {
1395 row.checked = true;
1396 }
1397 target_col = Some(panel.col);
1398 }
1399 if let Some(col) = target_col {
1400 if col < self.filters.len() {
1401 self.filters[col] = ColumnFilter::default();
1402 }
1403 }
1404 self.recompute();
1405 }
1406
1407 pub fn set_panel_sort(&mut self, direction: SortDirection) {
1410 if let Some(panel) = &self.filter_panel {
1411 let col = panel.col;
1412 self.sort = match self.sort {
1413 Some((c, d)) if c == col && d == direction => None,
1414 _ => Some((col, direction)),
1415 };
1416 self.recompute();
1417 }
1418 }
1419
1420 pub fn toggle_filter_value(&mut self, index: usize) {
1423 if let Some(panel) = &mut self.filter_panel {
1424 if let Some(row) = panel.distinct.get_mut(index) {
1425 row.checked = !row.checked;
1426 }
1427 }
1428 self.maybe_auto_apply();
1429 }
1430
1431 pub fn toggle_filter_select_all(&mut self) {
1436 if let Some(panel) = &mut self.filter_panel {
1437 let target = !panel.all_checked();
1438 for row in &mut panel.distinct {
1439 row.checked = target;
1440 }
1441 }
1442 self.maybe_auto_apply();
1443 }
1444
1445 pub fn set_filter_operator(&mut self, op_index: usize) {
1448 if let Some(panel) = &mut self.filter_panel {
1449 panel.op_index = op_index;
1450 panel.op_menu_open = false;
1451 if op_index != 0 {
1452 panel.focus = FilterInput::OperandA;
1453 }
1454 }
1455 self.maybe_auto_apply();
1456 }
1457
1458 pub fn toggle_filter_op_menu(&mut self) {
1460 if let Some(panel) = &mut self.filter_panel {
1461 panel.op_menu_open = !panel.op_menu_open;
1462 }
1463 }
1464
1465 pub fn set_filter_focus(&mut self, focus: FilterInput) {
1467 if let Some(panel) = &mut self.filter_panel {
1468 panel.focus = focus;
1469 }
1470 }
1471
1472 pub fn toggle_filter_auto_apply(&mut self) {
1474 if let Some(panel) = &mut self.filter_panel {
1475 panel.auto_apply = !panel.auto_apply;
1476 }
1477 self.maybe_auto_apply();
1478 }
1479
1480 fn column_text(&self, col: usize) -> String {
1481 let mut text = String::new();
1482 let fmt = &self.resolved_formats[col];
1483 for &row_idx in self.display_indices.iter() {
1484 let cell = &self.data.rows[row_idx][col];
1485 let (s, _) = format_cell(cell, fmt);
1486 text.push_str(&s);
1487 text.push('\n');
1488 }
1489 text
1490 }
1491
1492 fn clear_drag(&mut self) {
1493 self.is_dragging = false;
1494 self.drag_start = None;
1495 self.drag_start_hit = None;
1496 self.scroll_at_click = None;
1497 }
1498
1499 fn drag_world_corners(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1500 let start = self.drag_start?;
1501 let mouse = self.last_mouse_pos?;
1502 let click_scroll = self
1503 .scroll_at_click
1504 .unwrap_or_else(|| self.scroll_handle.offset());
1505 let scroll = self.scroll_handle.offset();
1506 let sx_click: f32 = click_scroll.x.into();
1507 let sy_click: f32 = click_scroll.y.into();
1508 let sx: f32 = scroll.x.into();
1509 let sy: f32 = scroll.y.into();
1510 let sx0: f32 = start.x.into();
1511 let sy0: f32 = start.y.into();
1512 let mx: f32 = mouse.x.into();
1513 let my: f32 = mouse.y.into();
1514 let start_world = Point {
1515 x: px(sx0 + sx_click),
1516 y: px(sy0 + sy_click),
1517 };
1518 let end_world = Point {
1519 x: px(mx + sx),
1520 y: px(my + sy),
1521 };
1522 Some((start_world, end_world))
1523 }
1524
1525 pub fn drag_screen_rect(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1526 if !self.is_dragging {
1527 return None;
1528 }
1529 let (start_world, end_world) = self.drag_world_corners()?;
1530 let scroll = self.scroll_handle.offset();
1531 let sx: f32 = scroll.x.into();
1532 let sy: f32 = scroll.y.into();
1533 let start_screen = Point {
1534 x: px(f32::from(start_world.x) - sx),
1535 y: px(f32::from(start_world.y) - sy),
1536 };
1537 let end_screen = Point {
1538 x: px(f32::from(end_world.x) - sx),
1539 y: px(f32::from(end_world.y) - sy),
1540 };
1541 Some((start_screen, end_screen))
1542 }
1543
1544 fn update_drag(&mut self) {
1545 let (start_world, end_world) = match self.drag_world_corners() {
1546 Some(c) => c,
1547 None => return,
1548 };
1549 if !self.is_dragging {
1550 let dx = f32::from(end_world.x) - f32::from(start_world.x);
1551 let dy = f32::from(end_world.y) - f32::from(start_world.y);
1552 if dx * dx + dy * dy <= 400.0 {
1553 return;
1554 }
1555 self.is_dragging = true;
1556 }
1557 let r1 = match self.drag_start_hit {
1558 Some(h) => h,
1559 None => return,
1560 };
1561 let r2 = self.hit_test_content(f32::from(end_world.x), f32::from(end_world.y), 0.0, 0.0);
1565 match (r1, r2) {
1566 (HitResult::Cell(r1c, c1), HitResult::Cell(r2c, c2)) => {
1567 self.selection =
1568 Selection::CellRange(r1c.min(r2c), c1.min(c2), r1c.max(r2c), c1.max(c2));
1569 }
1570 (HitResult::RowHeader(r1r), HitResult::RowHeader(r2r)) => {
1571 self.selection = Selection::RowRange(r1r.min(r2r), r1r.max(r2r));
1572 }
1573 _ => {}
1574 }
1575 }
1576
1577 fn update_drag_from_last(&mut self) {
1578 self.update_drag();
1579 }
1580
1581 pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, pressed_button: Option<MouseButton>) {
1582 if self.is_dragging && pressed_button != Some(MouseButton::Left) {
1583 self.handle_mouse_up();
1584 return;
1585 }
1586 if let Some(col) = self.resizing_col {
1587 if pressed_button != Some(MouseButton::Left) {
1588 self.resizing_col = None;
1589 return;
1590 }
1591 let new_w =
1592 (self.resize_start_width + (f32::from(pos.x) - self.resize_start_x)).max(40.0);
1593 self.data.columns[col].width = new_w;
1594 return;
1595 }
1596 if let Some(axis) = self.scrollbar_drag {
1597 if pressed_button != Some(MouseButton::Left) {
1598 self.scrollbar_drag = None;
1599 return;
1600 }
1601 match axis {
1602 ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
1603 ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
1604 }
1605 self.last_mouse_pos = Some(pos);
1606 return;
1607 }
1608 self.last_mouse_pos = Some(pos);
1609 if self.context_menu.is_some() {
1610 return;
1615 }
1616 self.hover_hit = Some(self.hit_test(pos));
1617 if self.drag_start.is_none() {
1618 return;
1619 }
1620 self.update_drag();
1621 }
1622
1623 pub fn handle_scroll_drag(&mut self) {
1624 if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
1625 self.update_drag();
1626 }
1627 }
1628
1629 pub fn handle_mouse_up(&mut self) {
1630 self.resizing_col = None;
1631 self.scrollbar_drag = None;
1632 self.clear_drag();
1633 }
1634
1635 pub fn apply_edge_scroll(&mut self) -> bool {
1636 apply_edge_scroll(self)
1637 }
1638
1639 pub fn select_all(&mut self) {
1640 let nrows = self.display_indices.len();
1641 let ncols = self.data.columns.len();
1642 if nrows > 0 && ncols > 0 {
1643 self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
1644 }
1645 }
1646
1647 pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
1648 let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
1649 return;
1650 };
1651 if self.display_indices.is_empty() || self.data.columns.is_empty() {
1652 return;
1653 }
1654 let last_row = self.display_indices.len() - 1;
1655 let last_col = self.data.columns.len() - 1;
1656 let r1 = raw_r1.min(last_row);
1657 let r2 = raw_r2.min(last_row);
1658 let c1 = raw_c1.min(last_col);
1659 let c2 = raw_c2.min(last_col);
1660 let mut text = String::new();
1661 if with_headers {
1662 for c in c1..=c2 {
1663 if c > c1 {
1664 text.push('\t');
1665 }
1666 text.push_str(&self.data.columns[c].name);
1667 }
1668 text.push('\n');
1669 }
1670 for dr in r1..=r2 {
1671 let row_idx = self.display_indices[dr];
1672 for c in c1..=c2 {
1673 if c > c1 {
1674 text.push('\t');
1675 }
1676 let cell = &self.data.rows[row_idx][c];
1677 let (s, _) = format_cell(cell, &self.resolved_formats[c]);
1678 text.push_str(&s);
1679 }
1680 text.push('\n');
1681 }
1682 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1683 }
1684
1685 pub fn page_up(&mut self) {
1686 let vh: f32 = self.bounds.size.height.into();
1687 let rows = ((vh - self.header_height) / self.row_height) as i32;
1688 self.move_selection(0, -rows);
1689 }
1690
1691 pub fn page_down(&mut self) {
1692 let vh: f32 = self.bounds.size.height.into();
1693 let rows = ((vh - self.header_height) / self.row_height) as i32;
1694 self.move_selection(0, rows);
1695 }
1696
1697 pub fn handle_key(&mut self, keystroke: &Keystroke) {
1698 if self.filter_panel.is_some() {
1699 match keystroke.key.as_str() {
1700 "escape" => {
1701 self.filter_panel = None;
1702 return;
1703 }
1704 "enter" => {
1705 self.apply_filter_panel();
1706 return;
1707 }
1708 _ => {}
1709 }
1710 let mut edited = false;
1711 if let Some(panel) = &mut self.filter_panel {
1712 let input = panel.active_input_mut();
1713 match keystroke.key.as_str() {
1714 "backspace" => {
1715 input.backspace();
1716 edited = true;
1717 }
1718 "left" => input.move_left(),
1719 "right" => input.move_right(),
1720 _ => {
1721 if let Some(ch) = keystroke_to_char(keystroke) {
1722 input.insert_char(ch);
1723 edited = true;
1724 }
1725 }
1726 }
1727 }
1728 if edited {
1731 self.maybe_auto_apply();
1732 }
1733 return;
1734 }
1735 if self.context_menu.is_some() {
1736 if keystroke.key.as_str() == "escape" {
1737 self.context_menu = None;
1738 }
1739 return;
1740 }
1741 let shift = keystroke.modifiers.shift;
1742 match keystroke.key.as_str() {
1743 "up" if shift => self.extend_selection(0, -1),
1744 "down" if shift => self.extend_selection(0, 1),
1745 "left" if shift => self.extend_selection(-1, 0),
1746 "right" if shift => self.extend_selection(1, 0),
1747 "up" => self.move_selection(0, -1),
1748 "down" => self.move_selection(0, 1),
1749 "left" => self.move_selection(-1, 0),
1750 "right" => self.move_selection(1, 0),
1751 "escape" => {
1752 self.selection = Selection::None;
1753 self.range_anchor = None;
1754 self.range_active = None;
1755 }
1756 _ => {}
1757 }
1758 }
1759
1760 fn move_selection(&mut self, dx: i32, dy: i32) {
1761 let nrows = self.display_indices.len() as i32;
1762 let ncols = self.data.columns.len() as i32;
1763 if nrows == 0 || ncols == 0 {
1764 return;
1765 }
1766 let last_row = nrows - 1;
1767 let last_col = ncols - 1;
1768 match self.selection {
1769 Selection::Cell(row, col) => {
1770 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1771 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1772 self.selection = Selection::Cell(nr, nc);
1773 self.range_anchor = Some((nr, nc));
1774 self.range_active = Some((nr, nc));
1775 }
1776 Selection::Row(row) if dy != 0 => {
1777 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1778 self.selection = Selection::Row(nr);
1779 }
1780 Selection::Column(col) if dx != 0 => {
1781 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1782 self.selection = Selection::Column(nc);
1783 }
1784 _ => {
1785 self.selection = Selection::Cell(0, 0);
1786 self.range_anchor = Some((0, 0));
1787 self.range_active = Some((0, 0));
1788 }
1789 }
1790 }
1791
1792 fn extend_selection(&mut self, dx: i32, dy: i32) {
1796 let nrows = self.display_indices.len() as i32;
1797 let ncols = self.data.columns.len() as i32;
1798 if nrows == 0 || ncols == 0 {
1799 return;
1800 }
1801 let last_row = nrows - 1;
1802 let last_col = ncols - 1;
1803
1804 if self.range_anchor.is_none() || self.range_active.is_none() {
1806 match self.selection {
1807 Selection::Cell(r, c) => {
1808 self.range_anchor = Some((r, c));
1809 self.range_active = Some((r, c));
1810 }
1811 Selection::CellRange(r1, c1, r2, c2) => {
1812 self.range_anchor = Some((r1, c1));
1813 self.range_active = Some((r2, c2));
1814 }
1815 _ => {
1816 self.range_anchor = Some((0, 0));
1817 self.range_active = Some((0, 0));
1818 self.selection = Selection::Cell(0, 0);
1819 }
1820 }
1821 }
1822
1823 let anchor = self.range_anchor.unwrap_or((0, 0));
1824 let active = self.range_active.unwrap_or(anchor);
1825 let nr = (active.0 as i32 + dy).clamp(0, last_row) as usize;
1826 let nc = (active.1 as i32 + dx).clamp(0, last_col) as usize;
1827 self.range_active = Some((nr, nc));
1828
1829 self.selection = if (nr, nc) == anchor {
1830 Selection::Cell(nr, nc)
1831 } else {
1832 Selection::CellRange(
1833 anchor.0.min(nr),
1834 anchor.1.min(nc),
1835 anchor.0.max(nr),
1836 anchor.1.max(nc),
1837 )
1838 };
1839 }
1840
1841 pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
1842 let bounds = self.bounds;
1843 let (sx, sy) = (
1844 f32::from(self.scroll_handle.offset().x),
1845 f32::from(self.scroll_handle.offset().y),
1846 );
1847 let bw: f32 = bounds.size.width.into();
1848 let bh: f32 = bounds.size.height.into();
1849 let (mx, my) = self.max_scroll();
1850 if let Some(menu) = &self.context_menu {
1851 let cw = self.char_width;
1852 let x_rel = f32::from(pos.x);
1855 let y_rel = f32::from(pos.y);
1856 if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
1857 return HitResult::ContextMenuItem(idx);
1858 }
1859 }
1860 if my > 0.0
1861 && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
1862 && f32::from(pos.y) >= self.header_height
1863 {
1864 return HitResult::VerticalScrollbar;
1865 }
1866 if mx > 0.0
1867 && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
1868 && f32::from(pos.x) >= self.row_header_width
1869 {
1870 return HitResult::HorizontalScrollbar;
1871 }
1872 let px = f32::from(pos.x);
1878 let py = f32::from(pos.y);
1879 if px < 0.0 || py < 0.0 || px > bw || py > bh {
1880 return HitResult::None;
1881 }
1882 self.hit_test_content(px, py, sx, sy)
1883 }
1884
1885 fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
1886 if y < self.header_height {
1887 if x < self.row_header_width {
1888 return HitResult::Corner;
1889 }
1890 let col_x = x - self.row_header_width + sx;
1891 let mut acc = 0.0;
1892 for (i, col) in self.data.columns.iter().enumerate() {
1893 let right = acc + col.width;
1894 if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
1895 return HitResult::ColumnBorder(i);
1896 }
1897 if col_x >= acc && col_x < right {
1898 if col_x >= right - 20.0 {
1899 return HitResult::SortButton(i);
1900 }
1901 return HitResult::ColumnHeader(i);
1902 }
1903 acc = right;
1904 }
1905 return HitResult::None;
1906 }
1907 if x < self.row_header_width {
1908 let row_y = y - self.header_height + sy;
1909 if row_y < 0.0 {
1910 return HitResult::None;
1911 }
1912 let row_idx = (row_y / self.row_height) as usize;
1913 if row_idx < self.display_indices.len() {
1914 return HitResult::RowHeader(row_idx);
1915 }
1916 return HitResult::None;
1917 }
1918 let col_x = x - self.row_header_width + sx;
1919 let row_y = y - self.header_height + sy;
1920 if row_y < 0.0 {
1921 return HitResult::None;
1922 }
1923 let row_idx = (row_y / self.row_height) as usize;
1924 if row_idx >= self.display_indices.len() {
1925 return HitResult::None;
1926 }
1927 let mut acc = 0.0;
1928 for (i, col) in self.data.columns.iter().enumerate() {
1929 if col_x >= acc && col_x < acc + col.width {
1930 return HitResult::Cell(row_idx, i);
1931 }
1932 acc += col.width;
1933 }
1934 HitResult::None
1935 }
1936
1937 #[must_use]
1938 pub fn wants_edge_scroll_tick(&self) -> bool {
1939 self.is_dragging
1940 }
1941}
1942
1943fn keystroke_to_char(k: &Keystroke) -> Option<char> {
1944 if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
1945 return None;
1946 }
1947 if let Some(key_char) = k.key_char.as_ref() {
1948 return key_char.chars().next();
1949 }
1950 if k.key.chars().count() == 1 {
1951 let c = k.key.chars().next()?;
1952 if k.modifiers.shift {
1953 Some(c.to_ascii_uppercase())
1954 } else {
1955 Some(c)
1956 }
1957 } else {
1958 None
1959 }
1960}
1961
1962#[cfg(test)]
1963#[allow(
1964 clippy::unwrap_used,
1965 clippy::expect_used,
1966 clippy::field_reassign_with_default
1967)]
1968mod tests {
1969 use super::*;
1970 use crate::data::{CellValue, Column, ColumnKind};
1971 use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
1972
1973 fn input_with(text: &str, cursor: usize) -> TextInput {
1974 let mut p = TextInput::new(text.to_owned());
1975 p.cursor_chars = cursor;
1976 p
1977 }
1978
1979 #[test]
1980 fn text_input_new_cursors_at_char_count_not_bytes() {
1981 let p = TextInput::new("hé🙂".into());
1983 assert_eq!(p.cursor_chars, 3);
1984 assert_eq!(p.value.len(), 7);
1985 }
1986
1987 #[test]
1988 fn text_input_insert_emoji_at_start_does_not_panic() {
1989 let mut p = input_with("ab", 0);
1990 p.insert_char('\u{1F600}');
1991 assert_eq!(p.value, "\u{1F600}ab");
1992 assert_eq!(p.cursor_chars, 1);
1993 }
1994
1995 #[test]
1996 fn text_input_insert_in_middle_keeps_cursor_at_char_position() {
1997 let mut p = input_with("helloworld", 5);
1998 p.insert_char(' ');
1999 assert_eq!(p.value, "hello world");
2000 assert_eq!(p.cursor_chars, 6);
2001 }
2002
2003 #[test]
2004 fn text_input_backspace_at_zero_is_noop() {
2005 let mut p = input_with("abc", 0);
2006 p.backspace();
2007 assert_eq!(p.value, "abc");
2008 assert_eq!(p.cursor_chars, 0);
2009 }
2010
2011 #[test]
2012 fn text_input_backspace_removes_one_char_value() {
2013 let mut p = input_with("héx", 2);
2015 p.backspace();
2016 assert_eq!(p.value, "hx");
2017 assert_eq!(p.cursor_chars, 1);
2018 }
2019
2020 #[test]
2021 fn text_input_clamp_cursor_pulls_back_past_end() {
2022 let mut p = input_with("abc", 99);
2023 p.clamp_cursor();
2024 assert_eq!(p.cursor_chars, 3);
2025 }
2026
2027 #[test]
2028 fn text_input_move_left_and_right_respect_bounds() {
2029 let mut p = input_with("ab", 2);
2030 p.move_right();
2031 assert_eq!(p.cursor_chars, 2);
2032 p.move_left();
2033 p.move_left();
2034 p.move_left();
2035 assert_eq!(p.cursor_chars, 0);
2036 }
2037
2038 #[test]
2039 fn edge_scroll_speed_stops_outside_band() {
2040 assert_eq!(edge_scroll_speed(120.0), 0.0);
2042 assert_eq!(edge_scroll_speed(90.01), 0.0);
2043 assert_eq!(edge_scroll_speed(90.0), 4.0);
2045 assert_eq!(edge_scroll_speed(60.0), 4.0);
2046 assert_eq!(edge_scroll_speed(59.99), 8.0);
2047 assert_eq!(edge_scroll_speed(30.0), 8.0);
2049 assert_eq!(edge_scroll_speed(29.99), 16.0);
2050 assert_eq!(edge_scroll_speed(0.0), 16.0);
2052 assert_eq!(edge_scroll_speed(29.99), 16.0);
2053 }
2054
2055 #[test]
2056 fn edge_scroll_speed_caps_negative_runaway() {
2057 assert_eq!(edge_scroll_speed(-100.0), 16.0);
2059 assert_eq!(edge_scroll_speed(-1000.0), 16.0);
2060 }
2061
2062 #[allow(clippy::expect_used, clippy::unwrap_used)]
2070 #[test]
2071 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2072 fn grid_state_behavior_under_application() {
2073 gpui::Application::new().run(|cx| {
2074 let focus = cx.focus_handle();
2075
2076 let mut state = GridState::new(
2078 GridData::new(
2079 vec![Column::new("n", ColumnKind::Integer, 100.0)],
2080 vec![vec![CellValue::Integer(1)]],
2081 )
2082 .expect("rectangular"),
2083 crate::config::GridConfig::default(),
2084 focus.clone(),
2085 );
2086 let _ = format_current_status(&state);
2087 assert_eq!(state.selection, Selection::None);
2088
2089 state.last_mouse_pos = Some(Point {
2091 x: px(120.0),
2092 y: px(80.0),
2093 });
2094 let s = format_current_status(&state);
2095 assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
2096
2097 let mut state = GridState::new(
2099 GridData::new(
2100 vec![Column::new("name", ColumnKind::Text, 100.0)],
2101 vec![
2102 vec![CellValue::Text("alpha".into())],
2103 vec![CellValue::Text("beeb".into())],
2104 vec![CellValue::Text("gamma".into())],
2105 ],
2106 )
2107 .expect("rectangular"),
2108 crate::config::GridConfig::default(),
2109 focus.clone(),
2110 );
2111 state.filters[0] = ColumnFilter {
2112 predicate: FilterPredicate::Text {
2113 op: TextOp::Contains,
2114 operand: "a".into(),
2115 },
2116 values: None,
2117 };
2118 state.toggle_sort(0);
2119 state.recompute();
2120 assert_eq!(state.display_indices.as_slice(), &[0, 2]);
2121 state.toggle_sort(0);
2122 state.recompute();
2123 assert_eq!(state.display_indices.as_slice(), &[2, 0]);
2124 state.filters[0] = ColumnFilter::default();
2125 state.toggle_sort(0);
2126 state.recompute();
2127 assert_eq!(state.display_indices.as_slice(), &[0, 1, 2]);
2128
2129 let mut state = GridState::new(
2131 GridData::new(
2132 vec![Column::new("v", ColumnKind::Integer, 80.0)],
2133 vec![vec![CellValue::Integer(1)]],
2134 )
2135 .expect("rectangular"),
2136 crate::config::GridConfig::default(),
2137 focus.clone(),
2138 );
2139 state.toggle_sort(0);
2140 assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
2141 state.toggle_sort(0);
2142 assert_eq!(state.sort, Some((0, SortDirection::Descending)));
2143 state.toggle_sort(0);
2144 assert_eq!(state.sort, None);
2145
2146 let mut state = GridState::new(
2148 GridData::new(
2149 vec![
2150 Column::new("a", ColumnKind::Integer, 80.0),
2151 Column::new("b", ColumnKind::Integer, 80.0),
2152 ],
2153 vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
2154 )
2155 .expect("rectangular"),
2156 crate::config::GridConfig::default(),
2157 focus.clone(),
2158 );
2159 state.select_all();
2160 assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
2161
2162 let mut state = GridState::new(
2164 GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
2165 .expect("rectangular"),
2166 crate::config::GridConfig::default(),
2167 focus.clone(),
2168 );
2169 state.select_all();
2170 assert_eq!(state.selection, Selection::None);
2171
2172 let mut state = GridState::new(
2174 GridData::new(
2175 vec![Column::new("v", ColumnKind::Decimal, 100.0)],
2176 vec![vec![CellValue::Decimal(1.234)]],
2177 )
2178 .expect("rectangular"),
2179 crate::config::GridConfig::default(),
2180 focus.clone(),
2181 );
2182 assert_eq!(state.resolved_formats[0].number.decimals, 2);
2183 let mut cfg = crate::config::GridConfig::default();
2184 cfg.column_overrides = vec![crate::config::ColumnOverride {
2185 number: Some(crate::config::NumberFormat {
2186 decimals: 6,
2187 ..Default::default()
2188 }),
2189 ..Default::default()
2190 }];
2191 state.set_config(cfg);
2192 assert_eq!(state.resolved_formats[0].number.decimals, 6);
2193
2194 let mut state = GridState::new(
2196 GridData::new(
2197 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2198 vec![vec![CellValue::Integer(1)]],
2199 )
2200 .expect("rectangular"),
2201 crate::config::GridConfig::default(),
2202 focus.clone(),
2203 );
2204 assert!(!state.wants_edge_scroll_tick());
2205 state.is_dragging = true;
2206 assert!(state.wants_edge_scroll_tick());
2207
2208 cx.quit();
2209 });
2210 }
2211
2212 #[allow(clippy::expect_used, clippy::unwrap_used)]
2213 #[test]
2214 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2215 fn context_menu_request_construction() {
2216 use crate::grid::context_menu::ContextMenuTarget;
2217
2218 gpui::Application::new().run(|cx| {
2219 let focus = cx.focus_handle();
2220
2221 let mut state = GridState::new(
2223 GridData::new(
2224 vec![
2225 Column::new("id", ColumnKind::Integer, 80.0),
2226 Column::new("name", ColumnKind::Text, 100.0),
2227 ],
2228 vec![
2229 vec![CellValue::Integer(1), CellValue::Text("alpha".into())],
2230 vec![CellValue::Integer(2), CellValue::Text("beta".into())],
2231 vec![CellValue::Integer(3), CellValue::Text("gamma".into())],
2232 ],
2233 )
2234 .expect("rectangular"),
2235 crate::config::GridConfig::default(),
2236 focus.clone(),
2237 );
2238 state.sort = Some((0, SortDirection::Descending));
2240 state.recompute();
2241 assert_eq!(state.display_indices.as_slice(), &[2, 1, 0]);
2242
2243 let target = ContextMenuTarget::Cell {
2245 display_row_index: 0,
2246 source_row_index: 2,
2247 column_index: 1,
2248 };
2249 let sel = Selection::Cell(0, 1);
2250 let req = state.build_context_menu_request(target, &sel);
2251 assert_eq!(req.target.column_index(), Some(1));
2252 let cells = req.selected_cells();
2253 assert_eq!(cells.len(), 1);
2254 assert_eq!(cells[0].source_row_index, 2);
2255 assert_eq!(cells[0].column_name, "name");
2256 assert_eq!(cells[0].value, CellValue::Text("gamma".into()));
2257 let rows = req.selected_rows();
2258 assert_eq!(rows.len(), 1);
2259 assert_eq!(rows[0].source_row_index, 2);
2260 assert_eq!(rows[0].value_by_name("id"), Some(&CellValue::Integer(3)));
2261
2262 let target = ContextMenuTarget::Cell {
2264 display_row_index: 0,
2265 source_row_index: 2,
2266 column_index: 0,
2267 };
2268 let sel = Selection::CellRange(0, 0, 1, 1);
2269 let req = state.build_context_menu_request(target, &sel);
2270 assert_eq!(req.selected_cell_count(), 4); let rows = req.selected_rows();
2272 assert_eq!(rows.len(), 2);
2273 assert_eq!(rows[0].source_row_index, 2);
2275 assert_eq!(rows[1].source_row_index, 1);
2276
2277 let target = ContextMenuTarget::RowHeader {
2279 display_row_index: 1,
2280 source_row_index: 1,
2281 };
2282 let sel = Selection::RowRange(0, 2);
2283 let req = state.build_context_menu_request(target, &sel);
2284 let rows = req.selected_rows();
2285 assert_eq!(rows.len(), 3);
2286 assert_eq!(rows[0].values.len(), 2);
2288 assert_eq!(req.selected_cell_count(), 6); let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2294 let sel = Selection::Column(0);
2295 let req = state.build_context_menu_request(target, &sel);
2296 assert!(req.is_column_oriented());
2297 assert_eq!(req.selected_row_count(), 0);
2298 assert!(req.selected_rows().is_empty());
2299 assert_eq!(req.selected_cells().len(), 3); let empty_state = GridState::new(
2303 GridData::new(vec![Column::new("x", ColumnKind::Integer, 80.0)], vec![])
2304 .expect("rectangular"),
2305 crate::config::GridConfig::default(),
2306 focus.clone(),
2307 );
2308 let target = ContextMenuTarget::Cell {
2309 display_row_index: 0,
2310 source_row_index: 0,
2311 column_index: 0,
2312 };
2313 let req = empty_state.build_context_menu_request(target, &Selection::None);
2314 assert!(req.selected_cells().is_empty());
2315 assert!(req.selected_rows().is_empty());
2316
2317 cx.quit();
2318 });
2319 }
2320
2321 #[allow(clippy::expect_used, clippy::unwrap_used)]
2322 #[test]
2323 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2324 fn effective_selection_for_context_target() {
2325 gpui::Application::new().run(|cx| {
2326 let focus = cx.focus_handle();
2327 let mut state = GridState::new(
2328 GridData::new(
2329 vec![
2330 Column::new("a", ColumnKind::Integer, 80.0),
2331 Column::new("b", ColumnKind::Integer, 80.0),
2332 ],
2333 vec![
2334 vec![CellValue::Integer(1), CellValue::Integer(2)],
2335 vec![CellValue::Integer(3), CellValue::Integer(4)],
2336 ],
2337 )
2338 .expect("rectangular"),
2339 crate::config::GridConfig::default(),
2340 focus,
2341 );
2342
2343 state.selection = Selection::Cell(0, 0);
2345 let target = ContextMenuTarget::Cell {
2346 display_row_index: 1,
2347 source_row_index: 1,
2348 column_index: 1,
2349 };
2350 let eff = state.effective_selection_for_context_target(&target);
2351 assert_eq!(eff, Selection::Cell(1, 1));
2352
2353 state.selection = Selection::CellRange(0, 0, 1, 1);
2355 let target = ContextMenuTarget::Cell {
2356 display_row_index: 1,
2357 source_row_index: 1,
2358 column_index: 1,
2359 };
2360 let eff = state.effective_selection_for_context_target(&target);
2361 assert_eq!(eff, Selection::CellRange(0, 0, 1, 1));
2362
2363 state.selection = Selection::Cell(0, 0);
2365 let target = ContextMenuTarget::RowHeader {
2366 display_row_index: 1,
2367 source_row_index: 1,
2368 };
2369 let eff = state.effective_selection_for_context_target(&target);
2370 assert_eq!(eff, Selection::Row(1));
2371
2372 state.selection = Selection::RowRange(0, 1);
2374 let target = ContextMenuTarget::RowHeader {
2375 display_row_index: 1,
2376 source_row_index: 1,
2377 };
2378 let eff = state.effective_selection_for_context_target(&target);
2379 assert_eq!(eff, Selection::RowRange(0, 1));
2380
2381 state.selection = Selection::Cell(1, 1);
2383 let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2384 let eff = state.effective_selection_for_context_target(&target);
2385 assert_eq!(eff, Selection::Cell(1, 1));
2386
2387 cx.quit();
2388 });
2389 }
2390
2391 #[allow(clippy::expect_used, clippy::unwrap_used)]
2392 #[test]
2393 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2394 fn context_menu_target_from_hit_maps_correctly() {
2395 gpui::Application::new().run(|cx| {
2396 let focus = cx.focus_handle();
2397 let state = GridState::new(
2398 GridData::new(
2399 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2400 vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(2)]],
2401 )
2402 .expect("rectangular"),
2403 crate::config::GridConfig::default(),
2404 focus,
2405 );
2406
2407 let t = state
2409 .context_menu_target_from_hit(HitResult::Cell(1, 0))
2410 .unwrap();
2411 assert_eq!(
2412 t,
2413 ContextMenuTarget::Cell {
2414 display_row_index: 1,
2415 source_row_index: 1,
2416 column_index: 0,
2417 }
2418 );
2419
2420 let t = state
2422 .context_menu_target_from_hit(HitResult::RowHeader(0))
2423 .unwrap();
2424 assert_eq!(
2425 t,
2426 ContextMenuTarget::RowHeader {
2427 display_row_index: 0,
2428 source_row_index: 0,
2429 }
2430 );
2431
2432 let t = state
2434 .context_menu_target_from_hit(HitResult::ColumnHeader(0))
2435 .unwrap();
2436 assert_eq!(t, ContextMenuTarget::ColumnHeader { column_index: 0 });
2437
2438 let t = state
2440 .context_menu_target_from_hit(HitResult::SortButton(0))
2441 .unwrap();
2442 assert_eq!(t, ContextMenuTarget::SortButton { column_index: 0 });
2443
2444 assert!(state
2446 .context_menu_target_from_hit(HitResult::VerticalScrollbar)
2447 .is_none());
2448 assert!(state
2449 .context_menu_target_from_hit(HitResult::None)
2450 .is_none());
2451
2452 cx.quit();
2453 });
2454 }
2455
2456 #[allow(clippy::expect_used, clippy::unwrap_used)]
2457 #[test]
2458 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2459 fn convert_context_menu_items_maps_variants() {
2460 use crate::grid::context_menu::ContextMenuItem;
2461
2462 let items = vec![
2463 ContextMenuItem::BuiltIn(MenuAction::SortAscending),
2464 ContextMenuItem::action("copy", "Copy value"),
2465 ContextMenuItem::separator(),
2466 ];
2467 let internal = GridState::convert_context_menu_items(items);
2468 assert!(matches!(
2469 internal[0],
2470 MenuItem::Action(MenuAction::SortAscending)
2471 ));
2472 assert!(
2473 matches!(&internal[1], MenuItem::Custom { id, label } if id == "copy" && label == "Copy value")
2474 );
2475 assert!(matches!(internal[2], MenuItem::Separator));
2476 }
2477
2478 #[allow(clippy::expect_used, clippy::unwrap_used)]
2479 #[test]
2480 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2481 fn execute_custom_context_menu_action_invokes_provider() {
2482 use crate::grid::context_menu::{
2483 ContextMenuProvider, ContextMenuProviderHandle, ContextMenuRequest,
2484 };
2485 use std::sync::{Arc, Mutex};
2486
2487 #[derive(Default)]
2488 struct TestProvider {
2489 last_action: Arc<Mutex<Option<String>>>,
2490 }
2491 impl ContextMenuProvider for TestProvider {
2492 fn menu_items(&self, _request: &ContextMenuRequest) -> Vec<ContextMenuItem> {
2493 vec![ContextMenuItem::action("test", "Test")]
2494 }
2495 fn on_action(
2496 &self,
2497 action_id: &str,
2498 _request: &ContextMenuRequest,
2499 _state: &mut GridState,
2500 _cx: &mut gpui::App,
2501 ) {
2502 *self.last_action.lock().unwrap() = Some(action_id.to_string());
2503 }
2504 }
2505
2506 gpui::Application::new().run(|cx| {
2507 let focus = cx.focus_handle();
2508 let mut state = GridState::new(
2509 GridData::new(
2510 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2511 vec![vec![CellValue::Integer(1)]],
2512 )
2513 .expect("rectangular"),
2514 crate::config::GridConfig::default(),
2515 focus,
2516 );
2517
2518 let last = Arc::new(Mutex::new(None));
2519 state.context_menu_provider = Some(ContextMenuProviderHandle::new(TestProvider {
2520 last_action: last.clone(),
2521 }));
2522
2523 let target = ContextMenuTarget::Cell {
2524 display_row_index: 0,
2525 source_row_index: 0,
2526 column_index: 0,
2527 };
2528 let request = state.build_context_menu_request(target, &Selection::Cell(0, 0));
2529 state.execute_custom_context_menu_action(
2530 PendingCustomContextMenuAction {
2531 id: "test".into(),
2532 request,
2533 },
2534 cx,
2535 );
2536 assert_eq!(*last.lock().unwrap(), Some("test".to_string()));
2537 assert!(state.context_menu.is_none());
2538
2539 cx.quit();
2540 });
2541 }
2542
2543 #[test]
2544 fn filter_panel_to_filter_with_all_checked_has_no_value_set() {
2545 let panel = FilterPanel {
2546 col: 0,
2547 anchor: Point {
2548 x: px(0.0),
2549 y: px(0.0),
2550 },
2551 kind: ColumnKind::Text,
2552 search: TextInput::default(),
2553 op_index: 0,
2554 op_menu_open: false,
2555 operand_a: TextInput::default(),
2556 operand_b: TextInput::default(),
2557 focus: FilterInput::Search,
2558 auto_apply: true,
2559 distinct: vec![
2560 FilterValueRow {
2561 label: "alpha".into(),
2562 checked: true,
2563 },
2564 FilterValueRow {
2565 label: "beta".into(),
2566 checked: true,
2567 },
2568 ],
2569 };
2570 let f = panel.to_filter();
2571 assert!(f.values.is_none(), "all checked => no value allow-list");
2572 assert!(
2573 !f.is_active(),
2574 "default predicate + all checked => inactive"
2575 );
2576 }
2577
2578 #[test]
2579 fn filter_panel_to_filter_with_unchecked_value_builds_allow_set() {
2580 let panel = FilterPanel {
2581 col: 0,
2582 anchor: Point {
2583 x: px(0.0),
2584 y: px(0.0),
2585 },
2586 kind: ColumnKind::Text,
2587 search: TextInput::default(),
2588 op_index: 0,
2589 op_menu_open: false,
2590 operand_a: TextInput::default(),
2591 operand_b: TextInput::default(),
2592 focus: FilterInput::Search,
2593 auto_apply: true,
2594 distinct: vec![
2595 FilterValueRow {
2596 label: "alpha".into(),
2597 checked: true,
2598 },
2599 FilterValueRow {
2600 label: "beta".into(),
2601 checked: false,
2602 },
2603 ],
2604 };
2605 let f = panel.to_filter();
2606 assert!(f.is_active(), "unchecked value => active filter");
2607 let set = f.values.expect("should have a value set");
2608 assert!(set.contains("alpha"));
2609 assert!(!set.contains("beta"));
2610 }
2611
2612 #[test]
2613 fn filter_panel_visible_indices_respects_search() {
2614 let panel = FilterPanel {
2615 col: 0,
2616 anchor: Point {
2617 x: px(0.0),
2618 y: px(0.0),
2619 },
2620 kind: ColumnKind::Text,
2621 search: TextInput::new("al".into()),
2622 op_index: 0,
2623 op_menu_open: false,
2624 operand_a: TextInput::default(),
2625 operand_b: TextInput::default(),
2626 focus: FilterInput::Search,
2627 auto_apply: true,
2628 distinct: vec![
2629 FilterValueRow {
2630 label: "alpha".into(),
2631 checked: true,
2632 },
2633 FilterValueRow {
2634 label: "beta".into(),
2635 checked: true,
2636 },
2637 FilterValueRow {
2638 label: "gamma".into(),
2639 checked: true,
2640 },
2641 ],
2642 };
2643 let vis = panel.visible_indices();
2644 assert_eq!(vis, vec![0], "search 'al' matches only alpha");
2645 }
2646
2647 #[test]
2648 fn filter_panel_all_checked_ignores_search() {
2649 let mut panel = FilterPanel {
2650 col: 0,
2651 anchor: Point {
2652 x: px(0.0),
2653 y: px(0.0),
2654 },
2655 kind: ColumnKind::Text,
2656 search: TextInput::new("al".into()),
2657 op_index: 0,
2658 op_menu_open: false,
2659 operand_a: TextInput::default(),
2660 operand_b: TextInput::default(),
2661 focus: FilterInput::Search,
2662 auto_apply: true,
2663 distinct: vec![
2664 FilterValueRow {
2665 label: "alpha".into(),
2666 checked: true,
2667 },
2668 FilterValueRow {
2669 label: "beta".into(),
2670 checked: false,
2671 },
2672 FilterValueRow {
2673 label: "gamma".into(),
2674 checked: true,
2675 },
2676 ],
2677 };
2678 assert!(
2681 !panel.all_checked(),
2682 "beta is unchecked, so not all values are checked (search is irrelevant)"
2683 );
2684
2685 panel.search = TextInput::new("zzz".into());
2687 for row in &mut panel.distinct {
2688 row.checked = true;
2689 }
2690 assert!(
2691 panel.all_checked(),
2692 "all values checked -> Select All stays checked regardless of empty search"
2693 );
2694 }
2695
2696 #[allow(clippy::expect_used, clippy::unwrap_used)]
2697 #[test]
2698 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2699 fn filter_panel_open_apply_clear_state_flow() {
2700 gpui::Application::new().run(|cx| {
2701 let focus = cx.focus_handle();
2702 let mut state = GridState::new(
2703 GridData::new(
2704 vec![Column::new("name", ColumnKind::Text, 100.0)],
2705 vec![
2706 vec![CellValue::Text("alpha".into())],
2707 vec![CellValue::Text("beta".into())],
2708 vec![CellValue::Text("gamma".into())],
2709 ],
2710 )
2711 .expect("rectangular"),
2712 crate::config::GridConfig::default(),
2713 focus,
2714 );
2715
2716 let anchor = Point {
2718 x: px(50.0),
2719 y: px(20.0),
2720 };
2721 state.open_filter_panel(0, Some(anchor));
2722 let panel = state.filter_panel.as_ref().expect("panel should be open");
2723 assert_eq!(panel.col, 0);
2724 assert_eq!(panel.anchor, anchor);
2725 assert_eq!(panel.distinct.len(), 3);
2726 assert!(
2727 panel.distinct.iter().all(|r| r.checked),
2728 "all checked by default"
2729 );
2730 assert!(panel.auto_apply, "auto_apply defaults to true");
2731 assert_eq!(panel.kind, ColumnKind::Text);
2732
2733 state.toggle_filter_value(1);
2735 state.apply_filter_panel();
2736 assert_eq!(
2737 state.display_indices.as_slice(),
2738 &[0, 2],
2739 "beta should be filtered out"
2740 );
2741
2742 state.clear_filter_panel();
2744 assert_eq!(
2745 state.display_indices.as_slice(),
2746 &[0, 1, 2],
2747 "all rows visible after clear"
2748 );
2749 assert!(
2750 state.filters[0] == ColumnFilter::default(),
2751 "filter reset to default"
2752 );
2753
2754 state.open_filter_panel(0, Some(anchor));
2756 let panel = state.filter_panel.as_mut().expect("panel open");
2757 panel.op_index = 1; panel.operand_a = TextInput::new("a".into());
2759 state.apply_filter_panel();
2760 assert_eq!(
2761 state.display_indices.as_slice(),
2762 &[0, 2],
2763 "contains 'a' matches alpha and gamma"
2764 );
2765
2766 state.clear_filter_panel();
2768 assert_eq!(state.display_indices.as_slice(), &[0, 1, 2]);
2769
2770 cx.quit();
2771 });
2772 }
2773}