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};
19
20use crate::grid::menu as menu_mod;
22#[allow(unused_imports)]
23pub(crate) use crate::grid::menu::{ContextMenu, MenuAction, MenuItem};
24use crate::grid::selection::{
25 is_cell_selected, is_row_selected, HitResult, ScrollbarAxis, Selection, SortDirection,
26};
27
28use crate::grid::context_menu::{
29 ColumnContext, ContextMenuItem, ContextMenuProviderHandle, ContextMenuRequest,
30 ContextMenuSelection, ContextMenuTarget, PendingCustomContextMenuAction, SelectedCellContext,
31 SelectedRowContext,
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 display_indices: Vec<usize>,
188 pub selection: Selection,
189 pub(crate) range_anchor: Option<(usize, usize)>,
193 pub(crate) range_active: Option<(usize, usize)>,
196 pub sort: Option<(usize, SortDirection)>,
197 pub filters: Vec<ColumnFilter>,
198 pub scroll_handle: ScrollHandle,
199 pub focus_handle: FocusHandle,
200 pub bounds: Bounds<Pixels>,
201 pub row_height: f32,
202 pub header_height: f32,
203 pub row_header_width: f32,
204 pub font_size: f32,
205 pub char_width: f32,
206 pub theme: GridTheme,
207 pub is_dragging: bool,
208 pub drag_start: Option<Point<Pixels>>,
209 pub drag_start_hit: Option<HitResult>,
210 pub scroll_at_click: Option<Point<Pixels>>,
211 pub last_mouse_pos: Option<Point<Pixels>>,
212 pub status_bar_height: f32,
213 pub debug_bar_enabled: bool,
218 pub click_pos: Option<Point<Pixels>>,
219 pub click_hit: Option<HitResult>,
220 pub hover_hit: Option<HitResult>,
221 pub resizing_col: Option<usize>,
222 pub resize_start_x: f32,
223 pub resize_start_width: f32,
224 pub context_menu: Option<ContextMenu>,
225 pub filter_panel: Option<FilterPanel>,
226 pub pending_action: Option<(MenuAction, usize)>,
227 pub(crate) pending_custom_context_menu_action: Option<PendingCustomContextMenuAction>,
228 pub(crate) context_menu_provider: Option<ContextMenuProviderHandle>,
229 pub scrollbar_drag: Option<ScrollbarAxis>,
230 pub scrollbar_drag_start_offset: f32,
231 pub scrollbar_drag_start_pos: f32,
232 pub(crate) window_viewport: Size<Pixels>,
236 pub(crate) edge_scroll_active: bool,
240}
241
242#[derive(Clone, Debug, Default)]
246pub struct TextInput {
247 pub value: String,
249 pub cursor_chars: usize,
251}
252
253impl TextInput {
254 fn new(value: String) -> Self {
255 let cursor_chars = value.chars().count();
256 Self {
257 value,
258 cursor_chars,
259 }
260 }
261
262 fn clamp_cursor(&mut self) {
263 let total = self.value.chars().count();
264 if self.cursor_chars > total {
265 self.cursor_chars = total;
266 }
267 }
268
269 fn insert_char(&mut self, ch: char) {
270 let byte_idx = byte_index_for_char(&self.value, self.cursor_chars);
271 self.value.insert(byte_idx, ch);
272 self.cursor_chars += 1;
273 }
274
275 fn backspace(&mut self) {
276 if self.cursor_chars == 0 {
277 return;
278 }
279 let end = byte_index_for_char(&self.value, self.cursor_chars);
280 let start = byte_index_for_char(&self.value, self.cursor_chars - 1);
281 self.value.replace_range(start..end, "");
282 self.cursor_chars -= 1;
283 }
284
285 fn move_left(&mut self) {
286 if self.cursor_chars > 0 {
287 self.cursor_chars -= 1;
288 }
289 }
290
291 fn move_right(&mut self) {
292 self.clamp_cursor();
293 if self.cursor_chars < self.value.chars().count() {
294 self.cursor_chars += 1;
295 }
296 }
297}
298
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub enum FilterInput {
302 Search,
304 OperandA,
306 OperandB,
308}
309
310#[derive(Clone, Debug)]
312pub struct FilterValueRow {
313 pub label: String,
315 pub checked: bool,
317}
318
319#[derive(Clone, Debug)]
326pub struct FilterPanel {
327 pub col: usize,
329 pub anchor: Point<Pixels>,
331 pub kind: ColumnKind,
333 pub search: TextInput,
335 pub op_index: usize,
338 pub op_menu_open: bool,
340 pub operand_a: TextInput,
342 pub operand_b: TextInput,
344 pub focus: FilterInput,
346 pub auto_apply: bool,
348 pub distinct: Vec<FilterValueRow>,
350}
351
352const TEXT_OP_LABELS: &[&str] = &[
356 "Choose One",
357 "contains",
358 "does not contain",
359 "begins with",
360 "ends with",
361 "is",
362 "is not",
363 "matches (regex)",
364];
365
366const NUMBER_OP_LABELS: &[&str] = &[
368 "Choose One",
369 "equal to",
370 "not equal to",
371 "greater than",
372 "greater than or equal to",
373 "less than",
374 "less than or equal to",
375 "between",
376 "not between",
377];
378
379impl FilterPanel {
380 #[must_use]
382 pub fn op_labels(&self) -> &'static [&'static str] {
383 if uses_number_ops(self.kind) {
384 NUMBER_OP_LABELS
385 } else {
386 TEXT_OP_LABELS
387 }
388 }
389
390 #[must_use]
392 pub fn current_op_label(&self) -> &'static str {
393 self.op_labels()
394 .get(self.op_index)
395 .copied()
396 .unwrap_or("Choose One")
397 }
398
399 #[must_use]
401 pub fn needs_operand(&self) -> bool {
402 self.op_index != 0
403 }
404
405 #[must_use]
407 pub fn needs_second_operand(&self) -> bool {
408 uses_number_ops(self.kind) && matches!(self.op_index, 7 | 8)
409 }
410
411 fn text_op_for_index(index: usize) -> Option<TextOp> {
412 match index {
413 1 => Some(TextOp::Contains),
414 2 => Some(TextOp::DoesNotContain),
415 3 => Some(TextOp::BeginsWith),
416 4 => Some(TextOp::EndsWith),
417 5 => Some(TextOp::Is),
418 6 => Some(TextOp::IsNot),
419 7 => Some(TextOp::Matches),
420 _ => None,
421 }
422 }
423
424 fn number_op_for_index(index: usize) -> Option<NumberOp> {
425 match index {
426 1 => Some(NumberOp::Eq),
427 2 => Some(NumberOp::Ne),
428 3 => Some(NumberOp::Gt),
429 4 => Some(NumberOp::Ge),
430 5 => Some(NumberOp::Lt),
431 6 => Some(NumberOp::Le),
432 7 => Some(NumberOp::Between),
433 8 => Some(NumberOp::NotBetween),
434 _ => None,
435 }
436 }
437
438 fn active_input_mut(&mut self) -> &mut TextInput {
439 match self.focus {
440 FilterInput::Search => &mut self.search,
441 FilterInput::OperandA => &mut self.operand_a,
442 FilterInput::OperandB => &mut self.operand_b,
443 }
444 }
445
446 #[must_use]
450 pub fn visible_indices(&self) -> Vec<usize> {
451 let needle = self.search.value.to_lowercase();
452 self.distinct
453 .iter()
454 .enumerate()
455 .filter(|(_, row)| needle.is_empty() || row.label.to_lowercase().contains(&needle))
456 .map(|(i, _)| i)
457 .collect()
458 }
459
460 #[must_use]
462 pub fn all_visible_checked(&self) -> bool {
463 let visible = self.visible_indices();
464 !visible.is_empty() && visible.iter().all(|&i| self.distinct[i].checked)
465 }
466
467 fn to_filter(&self) -> ColumnFilter {
470 let predicate = self.build_predicate();
471 let all_checked = self.distinct.iter().all(|r| r.checked);
472 let values = if all_checked {
473 None
474 } else {
475 Some(
476 self.distinct
477 .iter()
478 .filter(|r| r.checked)
479 .map(|r| r.label.clone())
480 .collect(),
481 )
482 };
483 ColumnFilter { predicate, values }
484 }
485
486 fn build_predicate(&self) -> FilterPredicate {
487 if self.op_index == 0 {
488 return FilterPredicate::None;
489 }
490 if uses_number_ops(self.kind) {
491 let Some(op) = Self::number_op_for_index(self.op_index) else {
492 return FilterPredicate::None;
493 };
494 let Some(a) = self.parse_number_operand(&self.operand_a.value) else {
495 return FilterPredicate::None;
496 };
497 let b = if self.needs_second_operand() {
498 self.parse_number_operand(&self.operand_b.value)
499 .unwrap_or(a)
500 } else {
501 a
502 };
503 FilterPredicate::Number { op, a, b }
504 } else {
505 let Some(op) = Self::text_op_for_index(self.op_index) else {
506 return FilterPredicate::None;
507 };
508 FilterPredicate::Text {
509 op,
510 operand: self.operand_a.value.clone(),
511 }
512 }
513 }
514
515 fn parse_number_operand(&self, s: &str) -> Option<f64> {
516 let t = s.trim();
517 if t.is_empty() {
518 return None;
519 }
520 if self.kind == ColumnKind::Date {
521 return parse_ymd_to_unix(t).map(|v| v as f64);
522 }
523 t.replace(',', "").parse::<f64>().ok()
525 }
526}
527
528fn byte_index_for_char(input: &str, char_idx: usize) -> usize {
529 input
530 .char_indices()
531 .nth(char_idx)
532 .map_or(input.len(), |(idx, _)| idx)
533}
534
535fn seed_operator(kind: ColumnKind, predicate: &FilterPredicate) -> (usize, String, String) {
538 match predicate {
539 FilterPredicate::None => (0, String::new(), String::new()),
540 FilterPredicate::Text { op, operand } => {
541 (text_op_index(*op), operand.clone(), String::new())
542 }
543 FilterPredicate::Number { op, a, b } => {
544 let b_str = if matches!(op, NumberOp::Between | NumberOp::NotBetween) {
545 fmt_number_operand(kind, *b)
546 } else {
547 String::new()
548 };
549 (number_op_index(*op), fmt_number_operand(kind, *a), b_str)
550 }
551 }
552}
553
554fn text_op_index(op: TextOp) -> usize {
555 match op {
556 TextOp::Contains => 1,
557 TextOp::DoesNotContain => 2,
558 TextOp::BeginsWith => 3,
559 TextOp::EndsWith => 4,
560 TextOp::Is => 5,
561 TextOp::IsNot => 6,
562 TextOp::Matches => 7,
563 }
564}
565
566fn number_op_index(op: NumberOp) -> usize {
567 match op {
568 NumberOp::Eq => 1,
569 NumberOp::Ne => 2,
570 NumberOp::Gt => 3,
571 NumberOp::Ge => 4,
572 NumberOp::Lt => 5,
573 NumberOp::Le => 6,
574 NumberOp::Between => 7,
575 NumberOp::NotBetween => 8,
576 }
577}
578
579fn fmt_number_operand(kind: ColumnKind, v: f64) -> String {
580 if kind == ColumnKind::Date {
581 let secs = v as i64;
582 let fmt = crate::config::DateFormat {
583 format: "%Y-%m-%d".into(),
584 ..Default::default()
585 };
586 crate::format::format_date_at(secs, secs, &fmt)
587 } else {
588 v.to_string()
590 }
591}
592
593impl GridState {
594 #[must_use]
595 pub fn new(data: GridData, config: GridConfig, focus_handle: FocusHandle) -> Self {
596 let resolved_formats = config.resolve_all(&data.columns);
597 let col_count = data.columns.len();
598 let display_indices = (0..data.rows.len()).collect();
599 Self {
600 data,
601 config,
602 resolved_formats,
603 display_indices,
604 selection: Selection::None,
605 range_anchor: None,
606 range_active: None,
607 sort: None,
608 filters: vec![ColumnFilter::default(); col_count],
609 scroll_handle: ScrollHandle::new(),
610 focus_handle,
611 bounds: Bounds::default(),
612 row_height: 24.0,
613 header_height: 32.0,
614 row_header_width: 50.0,
615 font_size: 14.0,
616 char_width: 7.6,
617 theme: GridTheme::default(),
618 is_dragging: false,
619 drag_start: None,
620 drag_start_hit: None,
621 scroll_at_click: None,
622 last_mouse_pos: None,
623 status_bar_height: 24.0,
624 debug_bar_enabled: false,
625 click_pos: None,
626 click_hit: None,
627 hover_hit: None,
628 resizing_col: None,
629 resize_start_x: 0.0,
630 resize_start_width: 0.0,
631 context_menu: None,
632 filter_panel: None,
633 pending_action: None,
634 pending_custom_context_menu_action: None,
635 context_menu_provider: None,
636 scrollbar_drag: None,
637 scrollbar_drag_start_offset: 0.0,
638 scrollbar_drag_start_pos: 0.0,
639 window_viewport: Size::default(),
640 edge_scroll_active: false,
641 }
642 }
643
644 pub fn set_config(&mut self, config: GridConfig) {
645 self.config = config;
646 self.rebuild_resolved_formats();
647 self.recompute();
648 }
649
650 pub fn set_debug_bar_enabled(&mut self, enabled: bool) {
654 self.debug_bar_enabled = enabled;
655 }
656
657 fn rebuild_resolved_formats(&mut self) {
658 self.resolved_formats = self.config.resolve_all(&self.data.columns);
659 }
660
661 pub fn recompute(&mut self) {
662 let mut indices: Vec<usize> = (0..self.data.rows.len())
663 .filter(|&row_idx| {
664 self.data.columns.iter().enumerate().all(|(col_idx, _col)| {
665 let filter = &self.filters[col_idx];
666 if !filter.is_active() {
667 return true;
668 }
669 let cell = &self.data.rows[row_idx][col_idx];
670 cell_passes_filter(cell, &self.resolved_formats[col_idx], filter)
671 })
672 })
673 .collect();
674
675 if let Some((sort_col, direction)) = self.sort {
676 indices.sort_by(|&a, &b| {
677 let cell_a = &self.data.rows[a][sort_col];
678 let cell_b = &self.data.rows[b][sort_col];
679 let ord = compare_cells(cell_a, cell_b);
680 match direction {
681 SortDirection::Ascending => ord,
682 SortDirection::Descending => ord.reverse(),
683 }
684 });
685 }
686 self.display_indices = indices;
687 }
688
689 fn content_size(&self) -> (f32, f32) {
690 let cw: f32 = self.data.columns.iter().map(|c| c.width).sum();
691 let ch = self.display_indices.len() as f32 * self.row_height;
692 (cw, ch)
693 }
694
695 pub(crate) fn max_scroll(&self) -> (f32, f32) {
696 let (cw, ch) = self.content_size();
697 let (rw, rh) = self.scrollbar_reserved();
698 let vw: f32 = self.bounds.size.width.into();
699 let vh: f32 = self.bounds.size.height.into();
700 let vw = vw - self.row_header_width - rw;
701 let vh = vh - self.header_height - rh;
702 ((cw - vw).max(0.0), (ch - vh).max(0.0))
703 }
704
705 fn scrollbar_reserved(&self) -> (f32, f32) {
706 let (cw, ch) = self.content_size();
707 let vw: f32 = self.bounds.size.width.into();
708 let vh: f32 = self.bounds.size.height.into();
709 let vw = vw - self.row_header_width;
710 let vh = vh - self.header_height;
711 let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
712 let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
713 (reserved_w, reserved_h)
714 }
715
716 fn vbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
717 let (_, ch) = self.content_size();
718 let (_, rh) = self.scrollbar_reserved();
719 let vh: f32 = self.bounds.size.height.into();
720 let vh = vh - self.header_height - rh;
721 if ch <= vh {
722 return None;
723 }
724 let sw: f32 = self.bounds.size.width.into();
727 let sh: f32 = self.bounds.size.height.into();
728 let track_x = sw - SCROLLBAR_SIZE;
729 let track_y = self.header_height;
730 let track_h = sh - self.header_height - rh;
731 let thumb_h = ((track_h * (vh / ch)).max(20.0)).min(track_h);
732 Some((track_x, track_y, SCROLLBAR_SIZE, track_h, thumb_h))
733 }
734
735 fn hbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
736 let (cw, _) = self.content_size();
737 let (rw, _) = self.scrollbar_reserved();
738 let vw: f32 = self.bounds.size.width.into();
739 let vw = vw - self.row_header_width - rw;
740 if cw <= vw {
741 return None;
742 }
743 let sw: f32 = self.bounds.size.width.into();
746 let sh: f32 = self.bounds.size.height.into();
747 let track_x = self.row_header_width;
748 let track_y = sh - SCROLLBAR_SIZE;
749 let track_w = sw - self.row_header_width - rw;
750 let thumb_w = ((track_w * (vw / cw)).max(20.0)).min(track_w);
751 Some((track_x, track_y, track_w, SCROLLBAR_SIZE, thumb_w))
752 }
753
754 pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
755 if let Some((_, track_y, _, track_h, thumb_h)) = self.vbar_geom() {
756 let (_, max_y) = self.max_scroll();
757 let range = (track_h - thumb_h).max(0.0);
758 let rel = (mouse_y - track_y - thumb_h * 0.5).clamp(0.0, range);
759 let frac = if range > 0.0 { rel / range } else { 0.0 };
760 let new_y = frac * max_y;
761 let x = self.scroll_handle.offset().x;
762 self.scroll_handle.set_offset(Point { x, y: px(new_y) });
763 }
764 }
765
766 pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
767 if let Some((track_x, _, track_w, _, thumb_w)) = self.hbar_geom() {
768 let (max_x, _) = self.max_scroll();
769 let range = (track_w - thumb_w).max(0.0);
770 let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
771 let frac = if range > 0.0 { rel / range } else { 0.0 };
772 let new_x = frac * max_x;
773 let y = self.scroll_handle.offset().y;
774 self.scroll_handle.set_offset(Point { x: px(new_x), y });
775 }
776 }
777
778 pub(crate) fn scroll_one_edge_tick(&mut self, dx: f32, dy: f32) {
779 let (mx, my) = self.max_scroll();
780 let s = self.scroll_handle.offset();
781 let new_x: f32 = (f32::from(s.x) + dx).clamp(0.0, mx);
782 let new_y: f32 = (f32::from(s.y) + dy).clamp(0.0, my);
783 self.scroll_handle.set_offset(Point {
784 x: px(new_x),
785 y: px(new_y),
786 });
787 }
788
789 pub fn toggle_sort(&mut self, col: usize) {
790 self.sort = match self.sort {
791 Some((c, SortDirection::Ascending)) if c == col => {
792 Some((col, SortDirection::Descending))
793 }
794 Some((c, SortDirection::Descending)) if c == col => None,
795 _ => Some((col, SortDirection::Ascending)),
796 };
797 self.recompute();
798 }
799
800 pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
801 let hit = self.hit_test(pos);
802 self.click_pos = Some(pos);
803 self.click_hit = Some(hit);
804 match hit {
805 HitResult::VerticalScrollbar => {
806 self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
807 self.scroll_to_vbar(f32::from(pos.y));
808 self.clear_drag();
809 }
810 HitResult::HorizontalScrollbar => {
811 self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
812 self.scroll_to_hbar(f32::from(pos.x));
813 self.clear_drag();
814 }
815 HitResult::ColumnBorder(col) => {
816 self.resizing_col = Some(col);
817 self.resize_start_x = f32::from(pos.x);
818 self.resize_start_width = self.data.columns[col].width;
819 self.clear_drag();
820 }
821 HitResult::ColumnHeader(col) => {
822 self.selection = Selection::Column(col);
823 self.clear_drag();
824 }
825 HitResult::SortButton(col) => {
826 self.toggle_sort(col);
829 self.clear_drag();
830 }
831 HitResult::ContextMenuItem(_) => {}
832 HitResult::RowHeader(row) => {
833 self.selection = if shift {
834 if let Selection::Row(prev) = self.selection {
835 let (s, e) = (prev, row);
836 Selection::RowRange(s.min(e), s.max(e))
837 } else {
838 Selection::Row(row)
839 }
840 } else {
841 Selection::Row(row)
842 };
843 self.start_drag(pos);
844 self.drag_start_hit = Some(HitResult::RowHeader(row));
845 }
846 HitResult::Cell(row, col) => {
847 self.selection = if shift {
848 let anchor = self
850 .range_anchor
851 .or(match self.selection {
852 Selection::Cell(pr, pc) => Some((pr, pc)),
853 _ => None,
854 })
855 .unwrap_or((row, col));
856 self.range_anchor = Some(anchor);
857 self.range_active = Some((row, col));
858 Selection::CellRange(
859 anchor.0.min(row),
860 anchor.1.min(col),
861 anchor.0.max(row),
862 anchor.1.max(col),
863 )
864 } else {
865 self.range_anchor = Some((row, col));
866 self.range_active = Some((row, col));
867 Selection::Cell(row, col)
868 };
869 self.start_drag(pos);
870 self.drag_start_hit = Some(HitResult::Cell(row, col));
871 }
872 HitResult::Corner | HitResult::None => {
873 self.selection = Selection::None;
874 self.range_anchor = None;
875 self.range_active = None;
876 self.context_menu = None;
877 self.filter_panel = None;
878 self.clear_drag();
879 }
880 }
881 }
882
883 fn start_drag(&mut self, pos: Point<Pixels>) {
884 self.is_dragging = false;
885 self.drag_start = Some(pos);
886 self.scroll_at_click = Some(self.scroll_handle.offset());
887 self.last_mouse_pos = Some(pos);
888 }
889
890 pub(crate) fn open_context_menu(&mut self, col: usize, anchor: Point<Pixels>) {
891 self.context_menu = Some(menu_mod::ContextMenu::standard(col, anchor));
892 self.filter_panel = None;
893 }
894
895 pub(crate) fn context_menu_target_from_hit(&self, hit: HitResult) -> Option<ContextMenuTarget> {
898 match hit {
899 HitResult::Cell(row, col) => {
900 let source_row = self.display_indices.get(row).copied().unwrap_or(row);
901 Some(ContextMenuTarget::Cell {
902 display_row_index: row,
903 source_row_index: source_row,
904 column_index: col,
905 })
906 }
907 HitResult::RowHeader(row) => {
908 let source_row = self.display_indices.get(row).copied().unwrap_or(row);
909 Some(ContextMenuTarget::RowHeader {
910 display_row_index: row,
911 source_row_index: source_row,
912 })
913 }
914 HitResult::ColumnHeader(col) => {
915 Some(ContextMenuTarget::ColumnHeader { column_index: col })
916 }
917 HitResult::SortButton(col) => Some(ContextMenuTarget::SortButton { column_index: col }),
918 _ => None,
919 }
920 }
921
922 pub(crate) fn effective_selection_for_context_target(
927 &self,
928 target: &ContextMenuTarget,
929 ) -> Selection {
930 match target {
931 ContextMenuTarget::Cell {
932 display_row_index,
933 column_index,
934 ..
935 } => {
936 if is_cell_selected(&self.selection, *display_row_index, *column_index) {
937 self.selection.clone()
938 } else {
939 Selection::Cell(*display_row_index, *column_index)
940 }
941 }
942 ContextMenuTarget::RowHeader {
943 display_row_index, ..
944 } => {
945 if is_row_selected(&self.selection, *display_row_index) {
946 self.selection.clone()
947 } else {
948 Selection::Row(*display_row_index)
949 }
950 }
951 ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. } => {
952 self.selection.clone()
953 }
954 }
955 }
956
957 pub(crate) fn build_context_menu_request(
961 &self,
962 target: ContextMenuTarget,
963 selection: &Selection,
964 ) -> ContextMenuRequest {
965 let nrows = self.display_indices.len();
966 let ncols = self.data.columns.len();
967
968 let (r1, c1, r2, c2) = match selection.normalized_bounds() {
969 Some((r1, c1, r2, c2)) => {
970 let r1 = r1.min(nrows.saturating_sub(1));
971 let r2 = r2.min(nrows.saturating_sub(1));
972 let c1 = c1.min(ncols.saturating_sub(1));
973 let c2 = c2.min(ncols.saturating_sub(1));
974 (r1, c1, r2, c2)
975 }
976 None => match &target {
977 ContextMenuTarget::Cell {
978 display_row_index,
979 column_index,
980 ..
981 } => (
982 *display_row_index,
983 *column_index,
984 *display_row_index,
985 *column_index,
986 ),
987 ContextMenuTarget::RowHeader {
988 display_row_index, ..
989 } => (
990 *display_row_index,
991 0,
992 *display_row_index,
993 ncols.saturating_sub(1),
994 ),
995 ContextMenuTarget::ColumnHeader { column_index }
996 | ContextMenuTarget::SortButton { column_index } => {
997 (0, *column_index, nrows.saturating_sub(1), *column_index)
998 }
999 },
1000 };
1001
1002 let menu_selection = ContextMenuSelection {
1003 row_start: r1,
1004 row_end: r2,
1005 column_start: c1,
1006 column_end: c2,
1007 };
1008
1009 let column_contexts: Vec<ColumnContext> = self
1010 .data
1011 .columns
1012 .iter()
1013 .enumerate()
1014 .map(|(i, c)| ColumnContext {
1015 index: i,
1016 name: c.name.clone(),
1017 kind: c.kind,
1018 })
1019 .collect();
1020
1021 let mut selected_cells = Vec::new();
1022 let mut selected_rows = Vec::new();
1023
1024 for dr in r1..=r2 {
1025 if nrows == 0 || dr >= nrows {
1026 break;
1027 }
1028 let Some(source_row) = self.display_indices.get(dr).copied() else {
1029 continue;
1030 };
1031 let Some(row_values) = self.data.rows.get(source_row) else {
1032 continue;
1033 };
1034
1035 selected_rows.push(SelectedRowContext {
1036 display_row_index: dr,
1037 source_row_index: source_row,
1038 values: row_values.clone(),
1039 columns: column_contexts.clone(),
1040 });
1041
1042 for c in c1..=c2 {
1043 if ncols == 0 || c >= ncols {
1044 break;
1045 }
1046 if let (Some(col), Some(value)) = (self.data.columns.get(c), row_values.get(c)) {
1047 selected_cells.push(SelectedCellContext {
1048 display_row_index: dr,
1049 source_row_index: source_row,
1050 column_index: c,
1051 column_name: col.name.clone(),
1052 value: value.clone(),
1053 });
1054 }
1055 }
1056 }
1057
1058 ContextMenuRequest {
1059 target,
1060 selection: Some(menu_selection),
1061 selected_cells,
1062 selected_rows,
1063 }
1064 }
1065
1066 pub(crate) fn execute_custom_context_menu_action(
1070 &mut self,
1071 pending: PendingCustomContextMenuAction,
1072 cx: &mut App,
1073 ) {
1074 self.context_menu = None;
1075 self.filter_panel = None;
1076
1077 let Some(provider) = self.context_menu_provider.clone() else {
1078 return;
1079 };
1080
1081 provider.on_action(&pending.id, &pending.request, self, cx);
1082 }
1083
1084 pub(crate) fn convert_context_menu_items(items: Vec<ContextMenuItem>) -> Vec<MenuItem> {
1087 items
1088 .into_iter()
1089 .map(|item| match item {
1090 ContextMenuItem::BuiltIn(action) => MenuItem::Action(action),
1091 ContextMenuItem::Action { id, label } => MenuItem::Custom { id, label },
1092 ContextMenuItem::Separator => MenuItem::Separator,
1093 })
1094 .collect()
1095 }
1096
1097 pub fn execute_action(&mut self, action: MenuAction, col: usize, cx: &mut App) {
1098 match action {
1099 MenuAction::SelectColumn => {
1100 self.selection = Selection::Column(col);
1101 }
1102 MenuAction::CopyColumn => {
1103 let text = self.column_text(col);
1104 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1105 }
1106 MenuAction::CopyColumnWithHeaders => {
1107 let mut text = String::new();
1108 text.push_str(&self.data.columns[col].name);
1109 text.push('\n');
1110 text.push_str(&self.column_text(col));
1111 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1112 }
1113 MenuAction::SortAscending => {
1114 self.sort = Some((col, SortDirection::Ascending));
1115 self.recompute();
1116 }
1117 MenuAction::SortDescending => {
1118 self.sort = Some((col, SortDirection::Descending));
1119 self.recompute();
1120 }
1121 MenuAction::ClearSort => {
1122 self.sort = None;
1123 self.recompute();
1124 }
1125 MenuAction::FilterPrompt => {
1126 let anchor = self.context_menu.as_ref().map(|m| m.anchor);
1127 self.open_filter_panel(col, anchor);
1128 }
1129 MenuAction::ClearFilter => {
1130 if col < self.filters.len() {
1131 self.filters[col] = ColumnFilter::default();
1132 self.recompute();
1133 }
1134 }
1135 }
1136 self.context_menu = None;
1137 }
1138
1139 pub fn open_filter_panel(&mut self, col: usize, anchor: Option<Point<Pixels>>) {
1150 if col >= self.data.columns.len() {
1151 return;
1152 }
1153 let anchor = anchor.unwrap_or(self.last_mouse_pos.unwrap_or(Point {
1154 x: px(0.0),
1155 y: px(0.0),
1156 }));
1157 let kind = self.data.columns[col].kind;
1158 let existing = self.filters.get(col).cloned().unwrap_or_default();
1159
1160 let distinct = {
1162 let fmt = &self.resolved_formats[col];
1163 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1164 let mut pairs: Vec<(String, &CellValue)> = Vec::new();
1165 for row in &self.data.rows {
1166 let cell = &row[col];
1167 let (label, _) = format_cell(cell, fmt);
1168 if seen.insert(label.clone()) {
1169 pairs.push((label, cell));
1170 }
1171 }
1172 pairs.sort_by(|(_, a), (_, b)| compare_cells(a, b));
1173 pairs
1174 .into_iter()
1175 .map(|(label, _)| {
1176 let checked = match &existing.values {
1177 None => true,
1178 Some(set) => set.contains(&label),
1179 };
1180 FilterValueRow { label, checked }
1181 })
1182 .collect()
1183 };
1184
1185 let (op_index, operand_a, operand_b) = seed_operator(kind, &existing.predicate);
1186
1187 self.context_menu = None;
1188 self.filter_panel = Some(FilterPanel {
1189 col,
1190 anchor,
1191 kind,
1192 search: TextInput::default(),
1193 op_index,
1194 op_menu_open: false,
1195 operand_a: TextInput::new(operand_a),
1196 operand_b: TextInput::new(operand_b),
1197 focus: FilterInput::Search,
1198 auto_apply: true,
1199 distinct,
1200 });
1201 }
1202
1203 pub fn apply_filter_panel(&mut self) {
1206 let Some(panel) = &self.filter_panel else {
1207 return;
1208 };
1209 let col = panel.col;
1210 let filter = panel.to_filter();
1211 if col < self.filters.len() {
1212 self.filters[col] = filter;
1213 self.recompute();
1214 }
1215 }
1216
1217 pub fn maybe_auto_apply(&mut self) {
1219 if self.filter_panel.is_some() {
1220 self.apply_filter_panel();
1221 }
1222 }
1223
1224 pub fn clear_filter_panel(&mut self) {
1227 let mut target_col = None;
1228 if let Some(panel) = &mut self.filter_panel {
1229 panel.op_index = 0;
1230 panel.op_menu_open = false;
1231 panel.operand_a = TextInput::default();
1232 panel.operand_b = TextInput::default();
1233 panel.search = TextInput::default();
1234 for row in &mut panel.distinct {
1235 row.checked = true;
1236 }
1237 target_col = Some(panel.col);
1238 }
1239 if let Some(col) = target_col {
1240 if col < self.filters.len() {
1241 self.filters[col] = ColumnFilter::default();
1242 }
1243 }
1244 self.recompute();
1245 }
1246
1247 pub fn set_panel_sort(&mut self, direction: SortDirection) {
1250 if let Some(panel) = &self.filter_panel {
1251 let col = panel.col;
1252 self.sort = match self.sort {
1253 Some((c, d)) if c == col && d == direction => None,
1254 _ => Some((col, direction)),
1255 };
1256 self.recompute();
1257 }
1258 }
1259
1260 pub fn toggle_filter_value(&mut self, index: usize) {
1263 if let Some(panel) = &mut self.filter_panel {
1264 if let Some(row) = panel.distinct.get_mut(index) {
1265 row.checked = !row.checked;
1266 }
1267 }
1268 self.maybe_auto_apply();
1269 }
1270
1271 pub fn toggle_filter_select_all(&mut self) {
1274 if let Some(panel) = &mut self.filter_panel {
1275 let target = !panel.all_visible_checked();
1276 for i in panel.visible_indices() {
1277 if let Some(row) = panel.distinct.get_mut(i) {
1278 row.checked = target;
1279 }
1280 }
1281 }
1282 self.maybe_auto_apply();
1283 }
1284
1285 pub fn set_filter_operator(&mut self, op_index: usize) {
1288 if let Some(panel) = &mut self.filter_panel {
1289 panel.op_index = op_index;
1290 panel.op_menu_open = false;
1291 if op_index != 0 {
1292 panel.focus = FilterInput::OperandA;
1293 }
1294 }
1295 self.maybe_auto_apply();
1296 }
1297
1298 pub fn toggle_filter_op_menu(&mut self) {
1300 if let Some(panel) = &mut self.filter_panel {
1301 panel.op_menu_open = !panel.op_menu_open;
1302 }
1303 }
1304
1305 pub fn set_filter_focus(&mut self, focus: FilterInput) {
1307 if let Some(panel) = &mut self.filter_panel {
1308 panel.focus = focus;
1309 }
1310 }
1311
1312 pub fn toggle_filter_auto_apply(&mut self) {
1314 if let Some(panel) = &mut self.filter_panel {
1315 panel.auto_apply = !panel.auto_apply;
1316 }
1317 self.maybe_auto_apply();
1318 }
1319
1320 fn column_text(&self, col: usize) -> String {
1321 let mut text = String::new();
1322 let fmt = &self.resolved_formats[col];
1323 for &row_idx in &self.display_indices {
1324 let cell = &self.data.rows[row_idx][col];
1325 let (s, _) = format_cell(cell, fmt);
1326 text.push_str(&s);
1327 text.push('\n');
1328 }
1329 text
1330 }
1331
1332 fn clear_drag(&mut self) {
1333 self.is_dragging = false;
1334 self.drag_start = None;
1335 self.drag_start_hit = None;
1336 self.scroll_at_click = None;
1337 }
1338
1339 fn drag_world_corners(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1340 let start = self.drag_start?;
1341 let mouse = self.last_mouse_pos?;
1342 let click_scroll = self
1343 .scroll_at_click
1344 .unwrap_or_else(|| self.scroll_handle.offset());
1345 let scroll = self.scroll_handle.offset();
1346 let sx_click: f32 = click_scroll.x.into();
1347 let sy_click: f32 = click_scroll.y.into();
1348 let sx: f32 = scroll.x.into();
1349 let sy: f32 = scroll.y.into();
1350 let sx0: f32 = start.x.into();
1351 let sy0: f32 = start.y.into();
1352 let mx: f32 = mouse.x.into();
1353 let my: f32 = mouse.y.into();
1354 let start_world = Point {
1355 x: px(sx0 + sx_click),
1356 y: px(sy0 + sy_click),
1357 };
1358 let end_world = Point {
1359 x: px(mx + sx),
1360 y: px(my + sy),
1361 };
1362 Some((start_world, end_world))
1363 }
1364
1365 pub fn drag_screen_rect(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1366 if !self.is_dragging {
1367 return None;
1368 }
1369 let (start_world, end_world) = self.drag_world_corners()?;
1370 let scroll = self.scroll_handle.offset();
1371 let sx: f32 = scroll.x.into();
1372 let sy: f32 = scroll.y.into();
1373 let start_screen = Point {
1374 x: px(f32::from(start_world.x) - sx),
1375 y: px(f32::from(start_world.y) - sy),
1376 };
1377 let end_screen = Point {
1378 x: px(f32::from(end_world.x) - sx),
1379 y: px(f32::from(end_world.y) - sy),
1380 };
1381 Some((start_screen, end_screen))
1382 }
1383
1384 fn update_drag(&mut self) {
1385 let (start_world, end_world) = match self.drag_world_corners() {
1386 Some(c) => c,
1387 None => return,
1388 };
1389 if !self.is_dragging {
1390 let dx = f32::from(end_world.x) - f32::from(start_world.x);
1391 let dy = f32::from(end_world.y) - f32::from(start_world.y);
1392 if dx * dx + dy * dy <= 400.0 {
1393 return;
1394 }
1395 self.is_dragging = true;
1396 }
1397 let r1 = match self.drag_start_hit {
1398 Some(h) => h,
1399 None => return,
1400 };
1401 let r2 = self.hit_test_content(f32::from(end_world.x), f32::from(end_world.y), 0.0, 0.0);
1405 match (r1, r2) {
1406 (HitResult::Cell(r1c, c1), HitResult::Cell(r2c, c2)) => {
1407 self.selection =
1408 Selection::CellRange(r1c.min(r2c), c1.min(c2), r1c.max(r2c), c1.max(c2));
1409 }
1410 (HitResult::RowHeader(r1r), HitResult::RowHeader(r2r)) => {
1411 self.selection = Selection::RowRange(r1r.min(r2r), r1r.max(r2r));
1412 }
1413 _ => {}
1414 }
1415 }
1416
1417 fn update_drag_from_last(&mut self) {
1418 self.update_drag();
1419 }
1420
1421 pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, pressed_button: Option<MouseButton>) {
1422 if self.is_dragging && pressed_button != Some(MouseButton::Left) {
1423 self.handle_mouse_up();
1424 return;
1425 }
1426 if let Some(col) = self.resizing_col {
1427 if pressed_button != Some(MouseButton::Left) {
1428 self.resizing_col = None;
1429 return;
1430 }
1431 let new_w =
1432 (self.resize_start_width + (f32::from(pos.x) - self.resize_start_x)).max(40.0);
1433 self.data.columns[col].width = new_w;
1434 return;
1435 }
1436 if let Some(axis) = self.scrollbar_drag {
1437 if pressed_button != Some(MouseButton::Left) {
1438 self.scrollbar_drag = None;
1439 return;
1440 }
1441 match axis {
1442 ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
1443 ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
1444 }
1445 self.last_mouse_pos = Some(pos);
1446 return;
1447 }
1448 self.last_mouse_pos = Some(pos);
1449 if self.context_menu.is_some() {
1450 return;
1455 }
1456 self.hover_hit = Some(self.hit_test(pos));
1457 if self.drag_start.is_none() {
1458 return;
1459 }
1460 self.update_drag();
1461 }
1462
1463 pub fn handle_scroll_drag(&mut self) {
1464 if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
1465 self.update_drag();
1466 }
1467 }
1468
1469 pub fn handle_mouse_up(&mut self) {
1470 self.resizing_col = None;
1471 self.scrollbar_drag = None;
1472 self.clear_drag();
1473 }
1474
1475 pub fn apply_edge_scroll(&mut self) -> bool {
1476 apply_edge_scroll(self)
1477 }
1478
1479 pub fn select_all(&mut self) {
1480 let nrows = self.display_indices.len();
1481 let ncols = self.data.columns.len();
1482 if nrows > 0 && ncols > 0 {
1483 self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
1484 }
1485 }
1486
1487 pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
1488 let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
1489 return;
1490 };
1491 if self.display_indices.is_empty() || self.data.columns.is_empty() {
1492 return;
1493 }
1494 let last_row = self.display_indices.len() - 1;
1495 let last_col = self.data.columns.len() - 1;
1496 let r1 = raw_r1.min(last_row);
1497 let r2 = raw_r2.min(last_row);
1498 let c1 = raw_c1.min(last_col);
1499 let c2 = raw_c2.min(last_col);
1500 let mut text = String::new();
1501 if with_headers {
1502 for c in c1..=c2 {
1503 if c > c1 {
1504 text.push('\t');
1505 }
1506 text.push_str(&self.data.columns[c].name);
1507 }
1508 text.push('\n');
1509 }
1510 for dr in r1..=r2 {
1511 let row_idx = self.display_indices[dr];
1512 for c in c1..=c2 {
1513 if c > c1 {
1514 text.push('\t');
1515 }
1516 let cell = &self.data.rows[row_idx][c];
1517 let (s, _) = format_cell(cell, &self.resolved_formats[c]);
1518 text.push_str(&s);
1519 }
1520 text.push('\n');
1521 }
1522 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1523 }
1524
1525 pub fn page_up(&mut self) {
1526 let vh: f32 = self.bounds.size.height.into();
1527 let rows = ((vh - self.header_height) / self.row_height) as i32;
1528 self.move_selection(0, -rows);
1529 }
1530
1531 pub fn page_down(&mut self) {
1532 let vh: f32 = self.bounds.size.height.into();
1533 let rows = ((vh - self.header_height) / self.row_height) as i32;
1534 self.move_selection(0, rows);
1535 }
1536
1537 pub fn handle_key(&mut self, keystroke: &Keystroke) {
1538 if self.filter_panel.is_some() {
1539 match keystroke.key.as_str() {
1540 "escape" => {
1541 self.filter_panel = None;
1542 return;
1543 }
1544 "enter" => {
1545 self.apply_filter_panel();
1546 return;
1547 }
1548 _ => {}
1549 }
1550 let mut edited = false;
1551 if let Some(panel) = &mut self.filter_panel {
1552 let input = panel.active_input_mut();
1553 match keystroke.key.as_str() {
1554 "backspace" => {
1555 input.backspace();
1556 edited = true;
1557 }
1558 "left" => input.move_left(),
1559 "right" => input.move_right(),
1560 _ => {
1561 if let Some(ch) = keystroke_to_char(keystroke) {
1562 input.insert_char(ch);
1563 edited = true;
1564 }
1565 }
1566 }
1567 }
1568 if edited {
1571 self.maybe_auto_apply();
1572 }
1573 return;
1574 }
1575 if self.context_menu.is_some() {
1576 if keystroke.key.as_str() == "escape" {
1577 self.context_menu = None;
1578 }
1579 return;
1580 }
1581 let shift = keystroke.modifiers.shift;
1582 match keystroke.key.as_str() {
1583 "up" if shift => self.extend_selection(0, -1),
1584 "down" if shift => self.extend_selection(0, 1),
1585 "left" if shift => self.extend_selection(-1, 0),
1586 "right" if shift => self.extend_selection(1, 0),
1587 "up" => self.move_selection(0, -1),
1588 "down" => self.move_selection(0, 1),
1589 "left" => self.move_selection(-1, 0),
1590 "right" => self.move_selection(1, 0),
1591 "escape" => {
1592 self.selection = Selection::None;
1593 self.range_anchor = None;
1594 self.range_active = None;
1595 }
1596 _ => {}
1597 }
1598 }
1599
1600 fn move_selection(&mut self, dx: i32, dy: i32) {
1601 let nrows = self.display_indices.len() as i32;
1602 let ncols = self.data.columns.len() as i32;
1603 if nrows == 0 || ncols == 0 {
1604 return;
1605 }
1606 let last_row = nrows - 1;
1607 let last_col = ncols - 1;
1608 match self.selection {
1609 Selection::Cell(row, col) => {
1610 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1611 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1612 self.selection = Selection::Cell(nr, nc);
1613 self.range_anchor = Some((nr, nc));
1614 self.range_active = Some((nr, nc));
1615 }
1616 Selection::Row(row) if dy != 0 => {
1617 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1618 self.selection = Selection::Row(nr);
1619 }
1620 Selection::Column(col) if dx != 0 => {
1621 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1622 self.selection = Selection::Column(nc);
1623 }
1624 _ => {
1625 self.selection = Selection::Cell(0, 0);
1626 self.range_anchor = Some((0, 0));
1627 self.range_active = Some((0, 0));
1628 }
1629 }
1630 }
1631
1632 fn extend_selection(&mut self, dx: i32, dy: i32) {
1636 let nrows = self.display_indices.len() as i32;
1637 let ncols = self.data.columns.len() as i32;
1638 if nrows == 0 || ncols == 0 {
1639 return;
1640 }
1641 let last_row = nrows - 1;
1642 let last_col = ncols - 1;
1643
1644 if self.range_anchor.is_none() || self.range_active.is_none() {
1646 match self.selection {
1647 Selection::Cell(r, c) => {
1648 self.range_anchor = Some((r, c));
1649 self.range_active = Some((r, c));
1650 }
1651 Selection::CellRange(r1, c1, r2, c2) => {
1652 self.range_anchor = Some((r1, c1));
1653 self.range_active = Some((r2, c2));
1654 }
1655 _ => {
1656 self.range_anchor = Some((0, 0));
1657 self.range_active = Some((0, 0));
1658 self.selection = Selection::Cell(0, 0);
1659 }
1660 }
1661 }
1662
1663 let anchor = self.range_anchor.unwrap_or((0, 0));
1664 let active = self.range_active.unwrap_or(anchor);
1665 let nr = (active.0 as i32 + dy).clamp(0, last_row) as usize;
1666 let nc = (active.1 as i32 + dx).clamp(0, last_col) as usize;
1667 self.range_active = Some((nr, nc));
1668
1669 self.selection = if (nr, nc) == anchor {
1670 Selection::Cell(nr, nc)
1671 } else {
1672 Selection::CellRange(
1673 anchor.0.min(nr),
1674 anchor.1.min(nc),
1675 anchor.0.max(nr),
1676 anchor.1.max(nc),
1677 )
1678 };
1679 }
1680
1681 pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
1682 let bounds = self.bounds;
1683 let (sx, sy) = (
1684 f32::from(self.scroll_handle.offset().x),
1685 f32::from(self.scroll_handle.offset().y),
1686 );
1687 let bw: f32 = bounds.size.width.into();
1688 let bh: f32 = bounds.size.height.into();
1689 let (mx, my) = self.max_scroll();
1690 if let Some(menu) = &self.context_menu {
1691 let cw = self.char_width;
1692 let x_rel = f32::from(pos.x);
1695 let y_rel = f32::from(pos.y);
1696 if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
1697 return HitResult::ContextMenuItem(idx);
1698 }
1699 }
1700 if my > 0.0
1701 && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
1702 && f32::from(pos.y) >= self.header_height
1703 {
1704 return HitResult::VerticalScrollbar;
1705 }
1706 if mx > 0.0
1707 && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
1708 && f32::from(pos.x) >= self.row_header_width
1709 {
1710 return HitResult::HorizontalScrollbar;
1711 }
1712 let px = f32::from(pos.x);
1718 let py = f32::from(pos.y);
1719 if px < 0.0 || py < 0.0 || px > bw || py > bh {
1720 return HitResult::None;
1721 }
1722 self.hit_test_content(px, py, sx, sy)
1723 }
1724
1725 fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
1726 if y < self.header_height {
1727 if x < self.row_header_width {
1728 return HitResult::Corner;
1729 }
1730 let col_x = x - self.row_header_width + sx;
1731 let mut acc = 0.0;
1732 for (i, col) in self.data.columns.iter().enumerate() {
1733 let right = acc + col.width;
1734 if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
1735 return HitResult::ColumnBorder(i);
1736 }
1737 if col_x >= acc && col_x < right {
1738 if col_x >= right - 20.0 {
1739 return HitResult::SortButton(i);
1740 }
1741 return HitResult::ColumnHeader(i);
1742 }
1743 acc = right;
1744 }
1745 return HitResult::None;
1746 }
1747 if x < self.row_header_width {
1748 let row_y = y - self.header_height + sy;
1749 if row_y < 0.0 {
1750 return HitResult::None;
1751 }
1752 let row_idx = (row_y / self.row_height) as usize;
1753 if row_idx < self.display_indices.len() {
1754 return HitResult::RowHeader(row_idx);
1755 }
1756 return HitResult::None;
1757 }
1758 let col_x = x - self.row_header_width + sx;
1759 let row_y = y - self.header_height + sy;
1760 if row_y < 0.0 {
1761 return HitResult::None;
1762 }
1763 let row_idx = (row_y / self.row_height) as usize;
1764 if row_idx >= self.display_indices.len() {
1765 return HitResult::None;
1766 }
1767 let mut acc = 0.0;
1768 for (i, col) in self.data.columns.iter().enumerate() {
1769 if col_x >= acc && col_x < acc + col.width {
1770 return HitResult::Cell(row_idx, i);
1771 }
1772 acc += col.width;
1773 }
1774 HitResult::None
1775 }
1776
1777 #[must_use]
1778 pub fn wants_edge_scroll_tick(&self) -> bool {
1779 self.is_dragging
1780 }
1781}
1782
1783fn keystroke_to_char(k: &Keystroke) -> Option<char> {
1784 if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
1785 return None;
1786 }
1787 if let Some(key_char) = k.key_char.as_ref() {
1788 return key_char.chars().next();
1789 }
1790 if k.key.chars().count() == 1 {
1791 let c = k.key.chars().next()?;
1792 if k.modifiers.shift {
1793 Some(c.to_ascii_uppercase())
1794 } else {
1795 Some(c)
1796 }
1797 } else {
1798 None
1799 }
1800}
1801
1802#[cfg(test)]
1803#[allow(
1804 clippy::unwrap_used,
1805 clippy::expect_used,
1806 clippy::field_reassign_with_default
1807)]
1808mod tests {
1809 use super::*;
1810 use crate::data::{CellValue, Column, ColumnKind};
1811 use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
1812
1813 fn input_with(text: &str, cursor: usize) -> TextInput {
1814 let mut p = TextInput::new(text.to_owned());
1815 p.cursor_chars = cursor;
1816 p
1817 }
1818
1819 #[test]
1820 fn text_input_new_cursors_at_char_count_not_bytes() {
1821 let p = TextInput::new("hé🙂".into());
1823 assert_eq!(p.cursor_chars, 3);
1824 assert_eq!(p.value.len(), 7);
1825 }
1826
1827 #[test]
1828 fn text_input_insert_emoji_at_start_does_not_panic() {
1829 let mut p = input_with("ab", 0);
1830 p.insert_char('\u{1F600}');
1831 assert_eq!(p.value, "\u{1F600}ab");
1832 assert_eq!(p.cursor_chars, 1);
1833 }
1834
1835 #[test]
1836 fn text_input_insert_in_middle_keeps_cursor_at_char_position() {
1837 let mut p = input_with("helloworld", 5);
1838 p.insert_char(' ');
1839 assert_eq!(p.value, "hello world");
1840 assert_eq!(p.cursor_chars, 6);
1841 }
1842
1843 #[test]
1844 fn text_input_backspace_at_zero_is_noop() {
1845 let mut p = input_with("abc", 0);
1846 p.backspace();
1847 assert_eq!(p.value, "abc");
1848 assert_eq!(p.cursor_chars, 0);
1849 }
1850
1851 #[test]
1852 fn text_input_backspace_removes_one_char_value() {
1853 let mut p = input_with("héx", 2);
1855 p.backspace();
1856 assert_eq!(p.value, "hx");
1857 assert_eq!(p.cursor_chars, 1);
1858 }
1859
1860 #[test]
1861 fn text_input_clamp_cursor_pulls_back_past_end() {
1862 let mut p = input_with("abc", 99);
1863 p.clamp_cursor();
1864 assert_eq!(p.cursor_chars, 3);
1865 }
1866
1867 #[test]
1868 fn text_input_move_left_and_right_respect_bounds() {
1869 let mut p = input_with("ab", 2);
1870 p.move_right();
1871 assert_eq!(p.cursor_chars, 2);
1872 p.move_left();
1873 p.move_left();
1874 p.move_left();
1875 assert_eq!(p.cursor_chars, 0);
1876 }
1877
1878 #[test]
1879 fn edge_scroll_speed_stops_outside_band() {
1880 assert_eq!(edge_scroll_speed(120.0), 0.0);
1882 assert_eq!(edge_scroll_speed(90.01), 0.0);
1883 assert_eq!(edge_scroll_speed(90.0), 4.0);
1885 assert_eq!(edge_scroll_speed(60.0), 4.0);
1886 assert_eq!(edge_scroll_speed(59.99), 8.0);
1887 assert_eq!(edge_scroll_speed(30.0), 8.0);
1889 assert_eq!(edge_scroll_speed(29.99), 16.0);
1890 assert_eq!(edge_scroll_speed(0.0), 16.0);
1892 assert_eq!(edge_scroll_speed(29.99), 16.0);
1893 }
1894
1895 #[test]
1896 fn edge_scroll_speed_caps_negative_runaway() {
1897 assert_eq!(edge_scroll_speed(-100.0), 16.0);
1899 assert_eq!(edge_scroll_speed(-1000.0), 16.0);
1900 }
1901
1902 #[allow(clippy::expect_used, clippy::unwrap_used)]
1910 #[test]
1911 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1912 fn grid_state_behavior_under_application() {
1913 gpui::Application::new().run(|cx| {
1914 let focus = cx.focus_handle();
1915
1916 let mut state = GridState::new(
1918 GridData::new(
1919 vec![Column::new("n", ColumnKind::Integer, 100.0)],
1920 vec![vec![CellValue::Integer(1)]],
1921 )
1922 .expect("rectangular"),
1923 crate::config::GridConfig::default(),
1924 focus.clone(),
1925 );
1926 let _ = format_current_status(&state);
1927 assert_eq!(state.selection, Selection::None);
1928
1929 state.last_mouse_pos = Some(Point {
1931 x: px(120.0),
1932 y: px(80.0),
1933 });
1934 let s = format_current_status(&state);
1935 assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
1936
1937 let mut state = GridState::new(
1939 GridData::new(
1940 vec![Column::new("name", ColumnKind::Text, 100.0)],
1941 vec![
1942 vec![CellValue::Text("alpha".into())],
1943 vec![CellValue::Text("beeb".into())],
1944 vec![CellValue::Text("gamma".into())],
1945 ],
1946 )
1947 .expect("rectangular"),
1948 crate::config::GridConfig::default(),
1949 focus.clone(),
1950 );
1951 state.filters[0] = ColumnFilter {
1952 predicate: FilterPredicate::Text {
1953 op: TextOp::Contains,
1954 operand: "a".into(),
1955 },
1956 values: None,
1957 };
1958 state.toggle_sort(0);
1959 state.recompute();
1960 assert_eq!(state.display_indices, vec![0, 2]);
1961 state.toggle_sort(0);
1962 state.recompute();
1963 assert_eq!(state.display_indices, vec![2, 0]);
1964 state.filters[0] = ColumnFilter::default();
1965 state.toggle_sort(0);
1966 state.recompute();
1967 assert_eq!(state.display_indices, vec![0, 1, 2]);
1968
1969 let mut state = GridState::new(
1971 GridData::new(
1972 vec![Column::new("v", ColumnKind::Integer, 80.0)],
1973 vec![vec![CellValue::Integer(1)]],
1974 )
1975 .expect("rectangular"),
1976 crate::config::GridConfig::default(),
1977 focus.clone(),
1978 );
1979 state.toggle_sort(0);
1980 assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
1981 state.toggle_sort(0);
1982 assert_eq!(state.sort, Some((0, SortDirection::Descending)));
1983 state.toggle_sort(0);
1984 assert_eq!(state.sort, None);
1985
1986 let mut state = GridState::new(
1988 GridData::new(
1989 vec![
1990 Column::new("a", ColumnKind::Integer, 80.0),
1991 Column::new("b", ColumnKind::Integer, 80.0),
1992 ],
1993 vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
1994 )
1995 .expect("rectangular"),
1996 crate::config::GridConfig::default(),
1997 focus.clone(),
1998 );
1999 state.select_all();
2000 assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
2001
2002 let mut state = GridState::new(
2004 GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
2005 .expect("rectangular"),
2006 crate::config::GridConfig::default(),
2007 focus.clone(),
2008 );
2009 state.select_all();
2010 assert_eq!(state.selection, Selection::None);
2011
2012 let mut state = GridState::new(
2014 GridData::new(
2015 vec![Column::new("v", ColumnKind::Decimal, 100.0)],
2016 vec![vec![CellValue::Decimal(1.234)]],
2017 )
2018 .expect("rectangular"),
2019 crate::config::GridConfig::default(),
2020 focus.clone(),
2021 );
2022 assert_eq!(state.resolved_formats[0].number.decimals, 2);
2023 let mut cfg = crate::config::GridConfig::default();
2024 cfg.column_overrides = vec![crate::config::ColumnOverride {
2025 number: Some(crate::config::NumberFormat {
2026 decimals: 6,
2027 ..Default::default()
2028 }),
2029 ..Default::default()
2030 }];
2031 state.set_config(cfg);
2032 assert_eq!(state.resolved_formats[0].number.decimals, 6);
2033
2034 let mut state = GridState::new(
2036 GridData::new(
2037 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2038 vec![vec![CellValue::Integer(1)]],
2039 )
2040 .expect("rectangular"),
2041 crate::config::GridConfig::default(),
2042 focus.clone(),
2043 );
2044 assert!(!state.wants_edge_scroll_tick());
2045 state.is_dragging = true;
2046 assert!(state.wants_edge_scroll_tick());
2047
2048 cx.quit();
2049 });
2050 }
2051
2052 #[allow(clippy::expect_used, clippy::unwrap_used)]
2053 #[test]
2054 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2055 fn context_menu_request_construction() {
2056 use crate::grid::context_menu::ContextMenuTarget;
2057
2058 gpui::Application::new().run(|cx| {
2059 let focus = cx.focus_handle();
2060
2061 let mut state = GridState::new(
2063 GridData::new(
2064 vec![
2065 Column::new("id", ColumnKind::Integer, 80.0),
2066 Column::new("name", ColumnKind::Text, 100.0),
2067 ],
2068 vec![
2069 vec![CellValue::Integer(1), CellValue::Text("alpha".into())],
2070 vec![CellValue::Integer(2), CellValue::Text("beta".into())],
2071 vec![CellValue::Integer(3), CellValue::Text("gamma".into())],
2072 ],
2073 )
2074 .expect("rectangular"),
2075 crate::config::GridConfig::default(),
2076 focus.clone(),
2077 );
2078 state.sort = Some((0, SortDirection::Descending));
2080 state.recompute();
2081 assert_eq!(state.display_indices, vec![2, 1, 0]);
2082
2083 let target = ContextMenuTarget::Cell {
2085 display_row_index: 0,
2086 source_row_index: 2,
2087 column_index: 1,
2088 };
2089 let sel = Selection::Cell(0, 1);
2090 let req = state.build_context_menu_request(target, &sel);
2091 assert_eq!(req.target.column_index(), Some(1));
2092 assert_eq!(req.selected_cells.len(), 1);
2093 assert_eq!(req.selected_cells[0].source_row_index, 2);
2094 assert_eq!(req.selected_cells[0].column_name, "name");
2095 assert_eq!(req.selected_cells[0].value, CellValue::Text("gamma".into()));
2096 assert_eq!(req.selected_rows.len(), 1);
2097 assert_eq!(req.selected_rows[0].source_row_index, 2);
2098 assert_eq!(
2099 req.selected_rows[0].value_by_name("id"),
2100 Some(&CellValue::Integer(3))
2101 );
2102
2103 let target = ContextMenuTarget::Cell {
2105 display_row_index: 0,
2106 source_row_index: 2,
2107 column_index: 0,
2108 };
2109 let sel = Selection::CellRange(0, 0, 1, 1);
2110 let req = state.build_context_menu_request(target, &sel);
2111 assert_eq!(req.selected_cells.len(), 4); assert_eq!(req.selected_rows.len(), 2);
2113 assert_eq!(req.selected_rows[0].source_row_index, 2);
2115 assert_eq!(req.selected_rows[1].source_row_index, 1);
2116
2117 let target = ContextMenuTarget::RowHeader {
2119 display_row_index: 1,
2120 source_row_index: 1,
2121 };
2122 let sel = Selection::RowRange(0, 2);
2123 let req = state.build_context_menu_request(target, &sel);
2124 assert_eq!(req.selected_rows.len(), 3);
2125 assert_eq!(req.selected_rows[0].values.len(), 2);
2127 assert_eq!(req.selected_cells.len(), 6); let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2131 let sel = Selection::Column(0);
2132 let req = state.build_context_menu_request(target, &sel);
2133 assert_eq!(req.selected_rows.len(), 3);
2134 assert_eq!(req.selected_cells.len(), 3); let empty_state = GridState::new(
2138 GridData::new(vec![Column::new("x", ColumnKind::Integer, 80.0)], vec![])
2139 .expect("rectangular"),
2140 crate::config::GridConfig::default(),
2141 focus.clone(),
2142 );
2143 let target = ContextMenuTarget::Cell {
2144 display_row_index: 0,
2145 source_row_index: 0,
2146 column_index: 0,
2147 };
2148 let req = empty_state.build_context_menu_request(target, &Selection::None);
2149 assert!(req.selected_cells.is_empty());
2150 assert!(req.selected_rows.is_empty());
2151
2152 cx.quit();
2153 });
2154 }
2155
2156 #[allow(clippy::expect_used, clippy::unwrap_used)]
2157 #[test]
2158 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2159 fn effective_selection_for_context_target() {
2160 gpui::Application::new().run(|cx| {
2161 let focus = cx.focus_handle();
2162 let mut state = GridState::new(
2163 GridData::new(
2164 vec![
2165 Column::new("a", ColumnKind::Integer, 80.0),
2166 Column::new("b", ColumnKind::Integer, 80.0),
2167 ],
2168 vec![
2169 vec![CellValue::Integer(1), CellValue::Integer(2)],
2170 vec![CellValue::Integer(3), CellValue::Integer(4)],
2171 ],
2172 )
2173 .expect("rectangular"),
2174 crate::config::GridConfig::default(),
2175 focus,
2176 );
2177
2178 state.selection = Selection::Cell(0, 0);
2180 let target = ContextMenuTarget::Cell {
2181 display_row_index: 1,
2182 source_row_index: 1,
2183 column_index: 1,
2184 };
2185 let eff = state.effective_selection_for_context_target(&target);
2186 assert_eq!(eff, Selection::Cell(1, 1));
2187
2188 state.selection = Selection::CellRange(0, 0, 1, 1);
2190 let target = ContextMenuTarget::Cell {
2191 display_row_index: 1,
2192 source_row_index: 1,
2193 column_index: 1,
2194 };
2195 let eff = state.effective_selection_for_context_target(&target);
2196 assert_eq!(eff, Selection::CellRange(0, 0, 1, 1));
2197
2198 state.selection = Selection::Cell(0, 0);
2200 let target = ContextMenuTarget::RowHeader {
2201 display_row_index: 1,
2202 source_row_index: 1,
2203 };
2204 let eff = state.effective_selection_for_context_target(&target);
2205 assert_eq!(eff, Selection::Row(1));
2206
2207 state.selection = Selection::RowRange(0, 1);
2209 let target = ContextMenuTarget::RowHeader {
2210 display_row_index: 1,
2211 source_row_index: 1,
2212 };
2213 let eff = state.effective_selection_for_context_target(&target);
2214 assert_eq!(eff, Selection::RowRange(0, 1));
2215
2216 state.selection = Selection::Cell(1, 1);
2218 let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2219 let eff = state.effective_selection_for_context_target(&target);
2220 assert_eq!(eff, Selection::Cell(1, 1));
2221
2222 cx.quit();
2223 });
2224 }
2225
2226 #[allow(clippy::expect_used, clippy::unwrap_used)]
2227 #[test]
2228 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2229 fn context_menu_target_from_hit_maps_correctly() {
2230 gpui::Application::new().run(|cx| {
2231 let focus = cx.focus_handle();
2232 let state = GridState::new(
2233 GridData::new(
2234 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2235 vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(2)]],
2236 )
2237 .expect("rectangular"),
2238 crate::config::GridConfig::default(),
2239 focus,
2240 );
2241
2242 let t = state
2244 .context_menu_target_from_hit(HitResult::Cell(1, 0))
2245 .unwrap();
2246 assert_eq!(
2247 t,
2248 ContextMenuTarget::Cell {
2249 display_row_index: 1,
2250 source_row_index: 1,
2251 column_index: 0,
2252 }
2253 );
2254
2255 let t = state
2257 .context_menu_target_from_hit(HitResult::RowHeader(0))
2258 .unwrap();
2259 assert_eq!(
2260 t,
2261 ContextMenuTarget::RowHeader {
2262 display_row_index: 0,
2263 source_row_index: 0,
2264 }
2265 );
2266
2267 let t = state
2269 .context_menu_target_from_hit(HitResult::ColumnHeader(0))
2270 .unwrap();
2271 assert_eq!(t, ContextMenuTarget::ColumnHeader { column_index: 0 });
2272
2273 let t = state
2275 .context_menu_target_from_hit(HitResult::SortButton(0))
2276 .unwrap();
2277 assert_eq!(t, ContextMenuTarget::SortButton { column_index: 0 });
2278
2279 assert!(state
2281 .context_menu_target_from_hit(HitResult::VerticalScrollbar)
2282 .is_none());
2283 assert!(state
2284 .context_menu_target_from_hit(HitResult::None)
2285 .is_none());
2286
2287 cx.quit();
2288 });
2289 }
2290
2291 #[allow(clippy::expect_used, clippy::unwrap_used)]
2292 #[test]
2293 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2294 fn convert_context_menu_items_maps_variants() {
2295 use crate::grid::context_menu::ContextMenuItem;
2296
2297 let items = vec![
2298 ContextMenuItem::BuiltIn(MenuAction::SortAscending),
2299 ContextMenuItem::action("copy", "Copy value"),
2300 ContextMenuItem::separator(),
2301 ];
2302 let internal = GridState::convert_context_menu_items(items);
2303 assert!(matches!(
2304 internal[0],
2305 MenuItem::Action(MenuAction::SortAscending)
2306 ));
2307 assert!(
2308 matches!(&internal[1], MenuItem::Custom { id, label } if id == "copy" && label == "Copy value")
2309 );
2310 assert!(matches!(internal[2], MenuItem::Separator));
2311 }
2312
2313 #[allow(clippy::expect_used, clippy::unwrap_used)]
2314 #[test]
2315 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2316 fn execute_custom_context_menu_action_invokes_provider() {
2317 use crate::grid::context_menu::{
2318 ContextMenuProvider, ContextMenuProviderHandle, ContextMenuRequest,
2319 };
2320 use std::sync::{Arc, Mutex};
2321
2322 #[derive(Default)]
2323 struct TestProvider {
2324 last_action: Arc<Mutex<Option<String>>>,
2325 }
2326 impl ContextMenuProvider for TestProvider {
2327 fn menu_items(&self, _request: &ContextMenuRequest) -> Vec<ContextMenuItem> {
2328 vec![ContextMenuItem::action("test", "Test")]
2329 }
2330 fn on_action(
2331 &self,
2332 action_id: &str,
2333 _request: &ContextMenuRequest,
2334 _state: &mut GridState,
2335 _cx: &mut gpui::App,
2336 ) {
2337 *self.last_action.lock().unwrap() = Some(action_id.to_string());
2338 }
2339 }
2340
2341 gpui::Application::new().run(|cx| {
2342 let focus = cx.focus_handle();
2343 let mut state = GridState::new(
2344 GridData::new(
2345 vec![Column::new("a", ColumnKind::Integer, 80.0)],
2346 vec![vec![CellValue::Integer(1)]],
2347 )
2348 .expect("rectangular"),
2349 crate::config::GridConfig::default(),
2350 focus,
2351 );
2352
2353 let last = Arc::new(Mutex::new(None));
2354 state.context_menu_provider = Some(ContextMenuProviderHandle::new(TestProvider {
2355 last_action: last.clone(),
2356 }));
2357
2358 let target = ContextMenuTarget::Cell {
2359 display_row_index: 0,
2360 source_row_index: 0,
2361 column_index: 0,
2362 };
2363 let request = state.build_context_menu_request(target, &Selection::Cell(0, 0));
2364 state.execute_custom_context_menu_action(
2365 PendingCustomContextMenuAction {
2366 id: "test".into(),
2367 request,
2368 },
2369 cx,
2370 );
2371 assert_eq!(*last.lock().unwrap(), Some("test".to_string()));
2372 assert!(state.context_menu.is_none());
2373
2374 cx.quit();
2375 });
2376 }
2377
2378 #[test]
2379 fn filter_panel_to_filter_with_all_checked_has_no_value_set() {
2380 let panel = FilterPanel {
2381 col: 0,
2382 anchor: Point {
2383 x: px(0.0),
2384 y: px(0.0),
2385 },
2386 kind: ColumnKind::Text,
2387 search: TextInput::default(),
2388 op_index: 0,
2389 op_menu_open: false,
2390 operand_a: TextInput::default(),
2391 operand_b: TextInput::default(),
2392 focus: FilterInput::Search,
2393 auto_apply: true,
2394 distinct: vec![
2395 FilterValueRow {
2396 label: "alpha".into(),
2397 checked: true,
2398 },
2399 FilterValueRow {
2400 label: "beta".into(),
2401 checked: true,
2402 },
2403 ],
2404 };
2405 let f = panel.to_filter();
2406 assert!(f.values.is_none(), "all checked => no value allow-list");
2407 assert!(
2408 !f.is_active(),
2409 "default predicate + all checked => inactive"
2410 );
2411 }
2412
2413 #[test]
2414 fn filter_panel_to_filter_with_unchecked_value_builds_allow_set() {
2415 let panel = FilterPanel {
2416 col: 0,
2417 anchor: Point {
2418 x: px(0.0),
2419 y: px(0.0),
2420 },
2421 kind: ColumnKind::Text,
2422 search: TextInput::default(),
2423 op_index: 0,
2424 op_menu_open: false,
2425 operand_a: TextInput::default(),
2426 operand_b: TextInput::default(),
2427 focus: FilterInput::Search,
2428 auto_apply: true,
2429 distinct: vec![
2430 FilterValueRow {
2431 label: "alpha".into(),
2432 checked: true,
2433 },
2434 FilterValueRow {
2435 label: "beta".into(),
2436 checked: false,
2437 },
2438 ],
2439 };
2440 let f = panel.to_filter();
2441 assert!(f.is_active(), "unchecked value => active filter");
2442 let set = f.values.expect("should have a value set");
2443 assert!(set.contains("alpha"));
2444 assert!(!set.contains("beta"));
2445 }
2446
2447 #[test]
2448 fn filter_panel_visible_indices_respects_search() {
2449 let panel = FilterPanel {
2450 col: 0,
2451 anchor: Point {
2452 x: px(0.0),
2453 y: px(0.0),
2454 },
2455 kind: ColumnKind::Text,
2456 search: TextInput::new("al".into()),
2457 op_index: 0,
2458 op_menu_open: false,
2459 operand_a: TextInput::default(),
2460 operand_b: TextInput::default(),
2461 focus: FilterInput::Search,
2462 auto_apply: true,
2463 distinct: vec![
2464 FilterValueRow {
2465 label: "alpha".into(),
2466 checked: true,
2467 },
2468 FilterValueRow {
2469 label: "beta".into(),
2470 checked: true,
2471 },
2472 FilterValueRow {
2473 label: "gamma".into(),
2474 checked: true,
2475 },
2476 ],
2477 };
2478 let vis = panel.visible_indices();
2479 assert_eq!(vis, vec![0], "search 'al' matches only alpha");
2480 }
2481
2482 #[test]
2483 fn filter_panel_all_visible_checked_reflects_search() {
2484 let mut panel = FilterPanel {
2485 col: 0,
2486 anchor: Point {
2487 x: px(0.0),
2488 y: px(0.0),
2489 },
2490 kind: ColumnKind::Text,
2491 search: TextInput::new("al".into()),
2492 op_index: 0,
2493 op_menu_open: false,
2494 operand_a: TextInput::default(),
2495 operand_b: TextInput::default(),
2496 focus: FilterInput::Search,
2497 auto_apply: true,
2498 distinct: vec![
2499 FilterValueRow {
2500 label: "alpha".into(),
2501 checked: true,
2502 },
2503 FilterValueRow {
2504 label: "beta".into(),
2505 checked: false,
2506 },
2507 FilterValueRow {
2508 label: "gamma".into(),
2509 checked: true,
2510 },
2511 ],
2512 };
2513 assert!(
2514 panel.all_visible_checked(),
2515 "alpha+gamma checked, beta hidden by search"
2516 );
2517
2518 panel.distinct[0].checked = false;
2520 assert!(!panel.all_visible_checked());
2521 }
2522
2523 #[allow(clippy::expect_used, clippy::unwrap_used)]
2524 #[test]
2525 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2526 fn filter_panel_open_apply_clear_state_flow() {
2527 gpui::Application::new().run(|cx| {
2528 let focus = cx.focus_handle();
2529 let mut state = GridState::new(
2530 GridData::new(
2531 vec![Column::new("name", ColumnKind::Text, 100.0)],
2532 vec![
2533 vec![CellValue::Text("alpha".into())],
2534 vec![CellValue::Text("beta".into())],
2535 vec![CellValue::Text("gamma".into())],
2536 ],
2537 )
2538 .expect("rectangular"),
2539 crate::config::GridConfig::default(),
2540 focus,
2541 );
2542
2543 let anchor = Point {
2545 x: px(50.0),
2546 y: px(20.0),
2547 };
2548 state.open_filter_panel(0, Some(anchor));
2549 let panel = state.filter_panel.as_ref().expect("panel should be open");
2550 assert_eq!(panel.col, 0);
2551 assert_eq!(panel.anchor, anchor);
2552 assert_eq!(panel.distinct.len(), 3);
2553 assert!(
2554 panel.distinct.iter().all(|r| r.checked),
2555 "all checked by default"
2556 );
2557 assert!(panel.auto_apply, "auto_apply defaults to true");
2558 assert_eq!(panel.kind, ColumnKind::Text);
2559
2560 state.toggle_filter_value(1);
2562 state.apply_filter_panel();
2563 assert_eq!(
2564 state.display_indices,
2565 vec![0, 2],
2566 "beta should be filtered out"
2567 );
2568
2569 state.clear_filter_panel();
2571 assert_eq!(
2572 state.display_indices,
2573 vec![0, 1, 2],
2574 "all rows visible after clear"
2575 );
2576 assert!(
2577 state.filters[0] == ColumnFilter::default(),
2578 "filter reset to default"
2579 );
2580
2581 state.open_filter_panel(0, Some(anchor));
2583 let panel = state.filter_panel.as_mut().expect("panel open");
2584 panel.op_index = 1; panel.operand_a = TextInput::new("a".into());
2586 state.apply_filter_panel();
2587 assert_eq!(
2588 state.display_indices,
2589 vec![0, 2],
2590 "contains 'a' matches alpha and gamma"
2591 );
2592
2593 state.clear_filter_panel();
2595 assert_eq!(state.display_indices, vec![0, 1, 2]);
2596
2597 cx.quit();
2598 });
2599 }
2600}