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