1use crate::compare_cells;
6use crate::data::{CellValue, GridData};
7use crate::format::{cell_matches_filter, format_cell};
8use crate::grid::state::state_inner::apply_edge_scroll;
9use crate::grid::theme::GridTheme;
10
11use crate::config::{GridConfig, ResolvedColumnFormat};
12use gpui::{
13 px, App, Bounds, FocusHandle, Keystroke, MouseButton, Pixels, Point, ScrollHandle, Size,
14};
15
16use crate::grid::menu as menu_mod;
18#[allow(unused_imports)]
19pub(crate) use crate::grid::menu::{ContextMenu, MenuAction, MenuItem};
20use crate::grid::selection::{
21 is_cell_selected, is_row_selected, HitResult, ScrollbarAxis, Selection, SortDirection,
22};
23
24use crate::grid::context_menu::{
25 ColumnContext, ContextMenuItem, ContextMenuProviderHandle, ContextMenuRequest,
26 ContextMenuSelection, ContextMenuTarget, PendingCustomContextMenuAction, SelectedCellContext,
27 SelectedRowContext,
28};
29
30pub mod state_inner {
34 use super::{
35 format_cell, CellValue, GridState, HitResult, Pixels, Point, ResolvedColumnFormat,
36 };
37 pub use crate::grid::selection::screen_to_content;
38 pub use crate::grid::selection::to_grid_relative;
39 use std::fmt::Write as _;
40
41 const REALLY_FAST: f32 = 16.0;
55 pub fn edge_scroll_speed(dist_from_edge: f32) -> f32 {
56 if dist_from_edge > 90.0 {
57 return 0.0;
58 }
59 if dist_from_edge < 0.0 {
60 return REALLY_FAST;
63 }
64 if dist_from_edge < 30.0 {
65 REALLY_FAST
66 } else if dist_from_edge < 60.0 {
67 8.0
68 } else {
69 4.0
70 }
71 }
72
73 pub fn apply_edge_scroll(state: &mut GridState) -> bool {
74 if !state.is_dragging {
75 return false;
76 }
77 let Some(pos) = state.last_mouse_pos else {
78 return false;
79 };
80 let bounds = state.bounds;
81 let vw: f32 = bounds.size.width.into();
90 let vh: f32 = bounds.size.height.into();
91 let px: f32 = pos.x.into();
92 let py: f32 = pos.y.into();
93 let right_dist = vw - px;
94 let left_dist = px - state.row_header_width;
95 let bottom_dist = vh - py;
96 let top_dist = py - state.header_height;
97 let mut dx = 0.0_f32;
98 let mut dy = 0.0_f32;
99 if right_dist < 90.0 && right_dist <= left_dist {
100 dx = edge_scroll_speed(right_dist);
101 } else if left_dist < 90.0 {
102 dx = -edge_scroll_speed(left_dist);
103 }
104 if bottom_dist < 90.0 && bottom_dist <= top_dist {
105 dy = edge_scroll_speed(bottom_dist);
106 } else if top_dist < 90.0 {
107 dy = -edge_scroll_speed(top_dist);
108 }
109 if dx == 0.0 && dy == 0.0 {
110 return false;
111 }
112 state.scroll_one_edge_tick(dx, dy);
113 if state.drag_start.is_some() {
114 state.update_drag_from_last();
115 }
116 true
117 }
118
119 #[must_use]
120 pub fn format_current_status(state: &GridState) -> String {
121 let scroll = state.scroll_handle.offset();
122 let (click_col, click_row) = col_row_from_hit(state.click_hit);
123 let (hover_col, hover_row) = col_row_from_hit(state.hover_hit);
124 let mut out = String::new();
125 let _ = write!(
126 out,
127 "Click: {} Scroll@Click: {} Cell: {} | Cur: {} Scroll: {} Over: {}",
128 fmt_point(state.click_pos),
129 fmt_point(state.scroll_at_click),
130 fmt_cr(click_col, click_row),
131 fmt_point(state.last_mouse_pos),
132 fmt_point(Some(scroll)),
133 fmt_cr(hover_col, hover_row),
134 );
135 out
136 }
137
138 fn col_row_from_hit(hit: Option<HitResult>) -> (Option<usize>, Option<usize>) {
139 match hit {
140 Some(HitResult::Cell(r, c)) => (Some(c), Some(r)),
141 Some(HitResult::RowHeader(r)) => (None, Some(r)),
142 Some(HitResult::ColumnHeader(c)) | Some(HitResult::SortButton(c)) => (Some(c), None),
143 _ => (None, None),
144 }
145 }
146
147 fn fmt_point(p: Option<Point<Pixels>>) -> String {
148 match p {
149 Some(p) => format!("({:.0}, {:.0})", f32::from(p.x), f32::from(p.y)),
150 None => "—".into(),
151 }
152 }
153
154 fn fmt_cr(c: Option<usize>, r: Option<usize>) -> String {
155 match (c, r) {
156 (Some(c), Some(r)) => format!("(col {c}, row {r})"),
157 (Some(c), None) => format!("(col {c})"),
158 (None, Some(r)) => format!("(row {r})"),
159 (None, None) => "—".into(),
160 }
161 }
162
163 #[must_use]
164 pub fn cell_text(cell: &CellValue, fmt: &ResolvedColumnFormat) -> String {
165 format_cell(cell, fmt).0
166 }
167}
168
169pub const SCROLLBAR_SIZE: f32 = 20.0;
171pub const EDGE_SCROLL_TICK_MS: u64 = 16;
173
174#[derive(Debug)]
176pub struct GridState {
177 pub data: GridData,
178 pub config: GridConfig,
179 pub resolved_formats: Vec<ResolvedColumnFormat>,
183 pub display_indices: Vec<usize>,
184 pub selection: Selection,
185 pub(crate) range_anchor: Option<(usize, usize)>,
189 pub(crate) range_active: Option<(usize, usize)>,
192 pub sort: Option<(usize, SortDirection)>,
193 pub filters: Vec<String>,
194 pub scroll_handle: ScrollHandle,
195 pub focus_handle: FocusHandle,
196 pub bounds: Bounds<Pixels>,
197 pub row_height: f32,
198 pub header_height: f32,
199 pub row_header_width: f32,
200 pub font_size: f32,
201 pub char_width: f32,
202 pub theme: GridTheme,
203 pub is_dragging: bool,
204 pub drag_start: Option<Point<Pixels>>,
205 pub drag_start_hit: Option<HitResult>,
206 pub scroll_at_click: Option<Point<Pixels>>,
207 pub last_mouse_pos: Option<Point<Pixels>>,
208 pub status_bar_height: f32,
209 pub debug_bar_enabled: bool,
214 pub click_pos: Option<Point<Pixels>>,
215 pub click_hit: Option<HitResult>,
216 pub hover_hit: Option<HitResult>,
217 pub resizing_col: Option<usize>,
218 pub resize_start_x: f32,
219 pub resize_start_width: f32,
220 pub context_menu: Option<ContextMenu>,
221 pub filter_prompt: Option<FilterPrompt>,
222 pub pending_action: Option<(MenuAction, usize)>,
223 pub(crate) pending_custom_context_menu_action: Option<PendingCustomContextMenuAction>,
224 pub(crate) context_menu_provider: Option<ContextMenuProviderHandle>,
225 pub scrollbar_drag: Option<ScrollbarAxis>,
226 pub scrollbar_drag_start_offset: f32,
227 pub scrollbar_drag_start_pos: f32,
228 pub(crate) window_viewport: Size<Pixels>,
232 pub(crate) edge_scroll_active: bool,
236}
237
238#[derive(Clone, Debug)]
241pub struct FilterPrompt {
242 pub col: usize,
243 pub anchor: Point<Pixels>,
244 pub input: String,
245 pub cursor_chars: usize,
246}
247
248impl FilterPrompt {
249 fn new(col: usize, anchor: Point<Pixels>, input: String) -> Self {
250 let cursor_chars = input.chars().count();
251 Self {
252 col,
253 anchor,
254 input,
255 cursor_chars,
256 }
257 }
258
259 fn clamp_cursor(&mut self) {
260 let total = self.input.chars().count();
261 if self.cursor_chars > total {
262 self.cursor_chars = total;
263 }
264 }
265
266 fn insert_char(&mut self, ch: char) {
267 let byte_idx = byte_index_for_char(&self.input, self.cursor_chars);
268 self.input.insert(byte_idx, ch);
269 self.cursor_chars += 1;
270 }
271
272 fn backspace(&mut self) {
273 if self.cursor_chars == 0 {
274 return;
275 }
276 let end = byte_index_for_char(&self.input, self.cursor_chars);
277 let start = byte_index_for_char(&self.input, self.cursor_chars - 1);
278 self.input.replace_range(start..end, "");
279 self.cursor_chars -= 1;
280 }
281}
282
283fn byte_index_for_char(input: &str, char_idx: usize) -> usize {
284 input
285 .char_indices()
286 .nth(char_idx)
287 .map_or(input.len(), |(idx, _)| idx)
288}
289
290impl GridState {
291 #[must_use]
292 pub fn new(data: GridData, config: GridConfig, focus_handle: FocusHandle) -> Self {
293 let resolved_formats = config.resolve_all(&data.columns);
294 let col_count = data.columns.len();
295 let display_indices = (0..data.rows.len()).collect();
296 Self {
297 data,
298 config,
299 resolved_formats,
300 display_indices,
301 selection: Selection::None,
302 range_anchor: None,
303 range_active: None,
304 sort: None,
305 filters: vec![String::new(); col_count],
306 scroll_handle: ScrollHandle::new(),
307 focus_handle,
308 bounds: Bounds::default(),
309 row_height: 24.0,
310 header_height: 32.0,
311 row_header_width: 50.0,
312 font_size: 14.0,
313 char_width: 7.6,
314 theme: GridTheme::default(),
315 is_dragging: false,
316 drag_start: None,
317 drag_start_hit: None,
318 scroll_at_click: None,
319 last_mouse_pos: None,
320 status_bar_height: 24.0,
321 debug_bar_enabled: false,
322 click_pos: None,
323 click_hit: None,
324 hover_hit: None,
325 resizing_col: None,
326 resize_start_x: 0.0,
327 resize_start_width: 0.0,
328 context_menu: None,
329 filter_prompt: None,
330 pending_action: None,
331 pending_custom_context_menu_action: None,
332 context_menu_provider: None,
333 scrollbar_drag: None,
334 scrollbar_drag_start_offset: 0.0,
335 scrollbar_drag_start_pos: 0.0,
336 window_viewport: Size::default(),
337 edge_scroll_active: false,
338 }
339 }
340
341 pub fn set_config(&mut self, config: GridConfig) {
342 self.config = config;
343 self.rebuild_resolved_formats();
344 self.recompute();
345 }
346
347 pub fn set_debug_bar_enabled(&mut self, enabled: bool) {
351 self.debug_bar_enabled = enabled;
352 }
353
354 fn rebuild_resolved_formats(&mut self) {
355 self.resolved_formats = self.config.resolve_all(&self.data.columns);
356 }
357
358 pub fn recompute(&mut self) {
359 let mut indices: Vec<usize> = (0..self.data.rows.len())
360 .filter(|&row_idx| {
361 self.data.columns.iter().enumerate().all(|(col_idx, _col)| {
362 let filter = &self.filters[col_idx];
363 if filter.is_empty() {
364 return true;
365 }
366 let cell = &self.data.rows[row_idx][col_idx];
367 cell_matches_filter(cell, &self.resolved_formats[col_idx], filter)
368 })
369 })
370 .collect();
371
372 if let Some((sort_col, direction)) = self.sort {
373 indices.sort_by(|&a, &b| {
374 let cell_a = &self.data.rows[a][sort_col];
375 let cell_b = &self.data.rows[b][sort_col];
376 let ord = compare_cells(cell_a, cell_b);
377 match direction {
378 SortDirection::Ascending => ord,
379 SortDirection::Descending => ord.reverse(),
380 }
381 });
382 }
383 self.display_indices = indices;
384 }
385
386 fn content_size(&self) -> (f32, f32) {
387 let cw: f32 = self.data.columns.iter().map(|c| c.width).sum();
388 let ch = self.display_indices.len() as f32 * self.row_height;
389 (cw, ch)
390 }
391
392 pub(crate) fn max_scroll(&self) -> (f32, f32) {
393 let (cw, ch) = self.content_size();
394 let (rw, rh) = self.scrollbar_reserved();
395 let vw: f32 = self.bounds.size.width.into();
396 let vh: f32 = self.bounds.size.height.into();
397 let vw = vw - self.row_header_width - rw;
398 let vh = vh - self.header_height - rh;
399 ((cw - vw).max(0.0), (ch - vh).max(0.0))
400 }
401
402 fn scrollbar_reserved(&self) -> (f32, f32) {
403 let (cw, ch) = self.content_size();
404 let vw: f32 = self.bounds.size.width.into();
405 let vh: f32 = self.bounds.size.height.into();
406 let vw = vw - self.row_header_width;
407 let vh = vh - self.header_height;
408 let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
409 let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
410 (reserved_w, reserved_h)
411 }
412
413 fn vbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
414 let (_, ch) = self.content_size();
415 let (_, rh) = self.scrollbar_reserved();
416 let vh: f32 = self.bounds.size.height.into();
417 let vh = vh - self.header_height - rh;
418 if ch <= vh {
419 return None;
420 }
421 let sw: f32 = self.bounds.size.width.into();
424 let sh: f32 = self.bounds.size.height.into();
425 let track_x = sw - SCROLLBAR_SIZE;
426 let track_y = self.header_height;
427 let track_h = sh - self.header_height - rh;
428 let thumb_h = ((track_h * (vh / ch)).max(20.0)).min(track_h);
429 Some((track_x, track_y, SCROLLBAR_SIZE, track_h, thumb_h))
430 }
431
432 fn hbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
433 let (cw, _) = self.content_size();
434 let (rw, _) = self.scrollbar_reserved();
435 let vw: f32 = self.bounds.size.width.into();
436 let vw = vw - self.row_header_width - rw;
437 if cw <= vw {
438 return None;
439 }
440 let sw: f32 = self.bounds.size.width.into();
443 let sh: f32 = self.bounds.size.height.into();
444 let track_x = self.row_header_width;
445 let track_y = sh - SCROLLBAR_SIZE;
446 let track_w = sw - self.row_header_width - rw;
447 let thumb_w = ((track_w * (vw / cw)).max(20.0)).min(track_w);
448 Some((track_x, track_y, track_w, SCROLLBAR_SIZE, thumb_w))
449 }
450
451 pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
452 if let Some((_, track_y, _, track_h, thumb_h)) = self.vbar_geom() {
453 let (_, max_y) = self.max_scroll();
454 let range = (track_h - thumb_h).max(0.0);
455 let rel = (mouse_y - track_y - thumb_h * 0.5).clamp(0.0, range);
456 let frac = if range > 0.0 { rel / range } else { 0.0 };
457 let new_y = frac * max_y;
458 let x = self.scroll_handle.offset().x;
459 self.scroll_handle.set_offset(Point { x, y: px(new_y) });
460 }
461 }
462
463 pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
464 if let Some((track_x, _, track_w, _, thumb_w)) = self.hbar_geom() {
465 let (max_x, _) = self.max_scroll();
466 let range = (track_w - thumb_w).max(0.0);
467 let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
468 let frac = if range > 0.0 { rel / range } else { 0.0 };
469 let new_x = frac * max_x;
470 let y = self.scroll_handle.offset().y;
471 self.scroll_handle.set_offset(Point { x: px(new_x), y });
472 }
473 }
474
475 pub(crate) fn scroll_one_edge_tick(&mut self, dx: f32, dy: f32) {
476 let (mx, my) = self.max_scroll();
477 let s = self.scroll_handle.offset();
478 let new_x: f32 = (f32::from(s.x) + dx).clamp(0.0, mx);
479 let new_y: f32 = (f32::from(s.y) + dy).clamp(0.0, my);
480 self.scroll_handle.set_offset(Point {
481 x: px(new_x),
482 y: px(new_y),
483 });
484 }
485
486 pub fn toggle_sort(&mut self, col: usize) {
487 self.sort = match self.sort {
488 Some((c, SortDirection::Ascending)) if c == col => {
489 Some((col, SortDirection::Descending))
490 }
491 Some((c, SortDirection::Descending)) if c == col => None,
492 _ => Some((col, SortDirection::Ascending)),
493 };
494 self.recompute();
495 }
496
497 pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
498 let hit = self.hit_test(pos);
499 self.click_pos = Some(pos);
500 self.click_hit = Some(hit);
501 match hit {
502 HitResult::VerticalScrollbar => {
503 self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
504 self.scroll_to_vbar(f32::from(pos.y));
505 self.clear_drag();
506 }
507 HitResult::HorizontalScrollbar => {
508 self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
509 self.scroll_to_hbar(f32::from(pos.x));
510 self.clear_drag();
511 }
512 HitResult::ColumnBorder(col) => {
513 self.resizing_col = Some(col);
514 self.resize_start_x = f32::from(pos.x);
515 self.resize_start_width = self.data.columns[col].width;
516 self.clear_drag();
517 }
518 HitResult::ColumnHeader(col) => {
519 self.selection = Selection::Column(col);
520 self.clear_drag();
521 }
522 HitResult::SortButton(col) => {
523 self.toggle_sort(col);
526 self.clear_drag();
527 }
528 HitResult::ContextMenuItem(_) => {}
529 HitResult::RowHeader(row) => {
530 self.selection = if shift {
531 if let Selection::Row(prev) = self.selection {
532 let (s, e) = (prev, row);
533 Selection::RowRange(s.min(e), s.max(e))
534 } else {
535 Selection::Row(row)
536 }
537 } else {
538 Selection::Row(row)
539 };
540 self.start_drag(pos);
541 self.drag_start_hit = Some(HitResult::RowHeader(row));
542 }
543 HitResult::Cell(row, col) => {
544 self.selection = if shift {
545 let anchor = self
547 .range_anchor
548 .or(match self.selection {
549 Selection::Cell(pr, pc) => Some((pr, pc)),
550 _ => None,
551 })
552 .unwrap_or((row, col));
553 self.range_anchor = Some(anchor);
554 self.range_active = Some((row, col));
555 Selection::CellRange(
556 anchor.0.min(row),
557 anchor.1.min(col),
558 anchor.0.max(row),
559 anchor.1.max(col),
560 )
561 } else {
562 self.range_anchor = Some((row, col));
563 self.range_active = Some((row, col));
564 Selection::Cell(row, col)
565 };
566 self.start_drag(pos);
567 self.drag_start_hit = Some(HitResult::Cell(row, col));
568 }
569 HitResult::Corner | HitResult::None => {
570 self.selection = Selection::None;
571 self.range_anchor = None;
572 self.range_active = None;
573 self.context_menu = None;
574 self.filter_prompt = None;
575 self.clear_drag();
576 }
577 }
578 }
579
580 fn start_drag(&mut self, pos: Point<Pixels>) {
581 self.is_dragging = false;
582 self.drag_start = Some(pos);
583 self.scroll_at_click = Some(self.scroll_handle.offset());
584 self.last_mouse_pos = Some(pos);
585 }
586
587 pub(crate) fn open_context_menu(&mut self, col: usize, anchor: Point<Pixels>) {
588 self.context_menu = Some(menu_mod::ContextMenu::standard(col, anchor));
589 self.filter_prompt = None;
590 }
591
592 pub(crate) fn context_menu_target_from_hit(&self, hit: HitResult) -> Option<ContextMenuTarget> {
595 match hit {
596 HitResult::Cell(row, col) => {
597 let source_row = self.display_indices.get(row).copied().unwrap_or(row);
598 Some(ContextMenuTarget::Cell {
599 display_row_index: row,
600 source_row_index: source_row,
601 column_index: col,
602 })
603 }
604 HitResult::RowHeader(row) => {
605 let source_row = self.display_indices.get(row).copied().unwrap_or(row);
606 Some(ContextMenuTarget::RowHeader {
607 display_row_index: row,
608 source_row_index: source_row,
609 })
610 }
611 HitResult::ColumnHeader(col) => {
612 Some(ContextMenuTarget::ColumnHeader { column_index: col })
613 }
614 HitResult::SortButton(col) => Some(ContextMenuTarget::SortButton { column_index: col }),
615 _ => None,
616 }
617 }
618
619 pub(crate) fn effective_selection_for_context_target(
624 &self,
625 target: &ContextMenuTarget,
626 ) -> Selection {
627 match target {
628 ContextMenuTarget::Cell {
629 display_row_index,
630 column_index,
631 ..
632 } => {
633 if is_cell_selected(&self.selection, *display_row_index, *column_index) {
634 self.selection.clone()
635 } else {
636 Selection::Cell(*display_row_index, *column_index)
637 }
638 }
639 ContextMenuTarget::RowHeader {
640 display_row_index, ..
641 } => {
642 if is_row_selected(&self.selection, *display_row_index) {
643 self.selection.clone()
644 } else {
645 Selection::Row(*display_row_index)
646 }
647 }
648 ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. } => {
649 self.selection.clone()
650 }
651 }
652 }
653
654 pub(crate) fn build_context_menu_request(
658 &self,
659 target: ContextMenuTarget,
660 selection: &Selection,
661 ) -> ContextMenuRequest {
662 let nrows = self.display_indices.len();
663 let ncols = self.data.columns.len();
664
665 let (r1, c1, r2, c2) = match selection.normalized_bounds() {
666 Some((r1, c1, r2, c2)) => {
667 let r1 = r1.min(nrows.saturating_sub(1));
668 let r2 = r2.min(nrows.saturating_sub(1));
669 let c1 = c1.min(ncols.saturating_sub(1));
670 let c2 = c2.min(ncols.saturating_sub(1));
671 (r1, c1, r2, c2)
672 }
673 None => match &target {
674 ContextMenuTarget::Cell {
675 display_row_index,
676 column_index,
677 ..
678 } => (
679 *display_row_index,
680 *column_index,
681 *display_row_index,
682 *column_index,
683 ),
684 ContextMenuTarget::RowHeader {
685 display_row_index, ..
686 } => (
687 *display_row_index,
688 0,
689 *display_row_index,
690 ncols.saturating_sub(1),
691 ),
692 ContextMenuTarget::ColumnHeader { column_index }
693 | ContextMenuTarget::SortButton { column_index } => {
694 (0, *column_index, nrows.saturating_sub(1), *column_index)
695 }
696 },
697 };
698
699 let menu_selection = ContextMenuSelection {
700 row_start: r1,
701 row_end: r2,
702 column_start: c1,
703 column_end: c2,
704 };
705
706 let column_contexts: Vec<ColumnContext> = self
707 .data
708 .columns
709 .iter()
710 .enumerate()
711 .map(|(i, c)| ColumnContext {
712 index: i,
713 name: c.name.clone(),
714 kind: c.kind,
715 })
716 .collect();
717
718 let mut selected_cells = Vec::new();
719 let mut selected_rows = Vec::new();
720
721 for dr in r1..=r2 {
722 if nrows == 0 || dr >= nrows {
723 break;
724 }
725 let Some(source_row) = self.display_indices.get(dr).copied() else {
726 continue;
727 };
728 let Some(row_values) = self.data.rows.get(source_row) else {
729 continue;
730 };
731
732 selected_rows.push(SelectedRowContext {
733 display_row_index: dr,
734 source_row_index: source_row,
735 values: row_values.clone(),
736 columns: column_contexts.clone(),
737 });
738
739 for c in c1..=c2 {
740 if ncols == 0 || c >= ncols {
741 break;
742 }
743 if let (Some(col), Some(value)) = (self.data.columns.get(c), row_values.get(c)) {
744 selected_cells.push(SelectedCellContext {
745 display_row_index: dr,
746 source_row_index: source_row,
747 column_index: c,
748 column_name: col.name.clone(),
749 value: value.clone(),
750 });
751 }
752 }
753 }
754
755 ContextMenuRequest {
756 target,
757 selection: Some(menu_selection),
758 selected_cells,
759 selected_rows,
760 }
761 }
762
763 pub(crate) fn execute_custom_context_menu_action(
767 &mut self,
768 pending: PendingCustomContextMenuAction,
769 cx: &mut App,
770 ) {
771 self.context_menu = None;
772 self.filter_prompt = None;
773
774 let Some(provider) = self.context_menu_provider.clone() else {
775 return;
776 };
777
778 provider.on_action(&pending.id, &pending.request, self, cx);
779 }
780
781 pub(crate) fn convert_context_menu_items(items: Vec<ContextMenuItem>) -> Vec<MenuItem> {
784 items
785 .into_iter()
786 .map(|item| match item {
787 ContextMenuItem::BuiltIn(action) => MenuItem::Action(action),
788 ContextMenuItem::Action { id, label } => MenuItem::Custom { id, label },
789 ContextMenuItem::Separator => MenuItem::Separator,
790 })
791 .collect()
792 }
793
794 pub fn execute_action(&mut self, action: MenuAction, col: usize, cx: &mut App) {
795 match action {
796 MenuAction::SelectColumn => {
797 self.selection = Selection::Column(col);
798 }
799 MenuAction::CopyColumn => {
800 let text = self.column_text(col);
801 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
802 }
803 MenuAction::CopyColumnWithHeaders => {
804 let mut text = String::new();
805 text.push_str(&self.data.columns[col].name);
806 text.push('\n');
807 text.push_str(&self.column_text(col));
808 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
809 }
810 MenuAction::SortAscending => {
811 self.sort = Some((col, SortDirection::Ascending));
812 self.recompute();
813 }
814 MenuAction::SortDescending => {
815 self.sort = Some((col, SortDirection::Descending));
816 self.recompute();
817 }
818 MenuAction::ClearSort => {
819 self.sort = None;
820 self.recompute();
821 }
822 MenuAction::FilterPrompt => {
823 let anchor = self.last_mouse_pos.unwrap_or(Point {
824 x: px(0.0),
825 y: px(0.0),
826 });
827 let existing = self.filters.get(col).cloned().unwrap_or_default();
828 self.filter_prompt = Some(FilterPrompt::new(col, anchor, existing));
829 }
830 MenuAction::ClearFilter => {
831 if col < self.filters.len() {
832 self.filters[col].clear();
833 self.recompute();
834 }
835 }
836 }
837 self.context_menu = None;
838 }
839
840 fn column_text(&self, col: usize) -> String {
841 let mut text = String::new();
842 let fmt = &self.resolved_formats[col];
843 for &row_idx in &self.display_indices {
844 let cell = &self.data.rows[row_idx][col];
845 let (s, _) = format_cell(cell, fmt);
846 text.push_str(&s);
847 text.push('\n');
848 }
849 text
850 }
851
852 fn clear_drag(&mut self) {
853 self.is_dragging = false;
854 self.drag_start = None;
855 self.drag_start_hit = None;
856 self.scroll_at_click = None;
857 }
858
859 fn drag_world_corners(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
860 let start = self.drag_start?;
861 let mouse = self.last_mouse_pos?;
862 let click_scroll = self
863 .scroll_at_click
864 .unwrap_or_else(|| self.scroll_handle.offset());
865 let scroll = self.scroll_handle.offset();
866 let sx_click: f32 = click_scroll.x.into();
867 let sy_click: f32 = click_scroll.y.into();
868 let sx: f32 = scroll.x.into();
869 let sy: f32 = scroll.y.into();
870 let sx0: f32 = start.x.into();
871 let sy0: f32 = start.y.into();
872 let mx: f32 = mouse.x.into();
873 let my: f32 = mouse.y.into();
874 let start_world = Point {
875 x: px(sx0 + sx_click),
876 y: px(sy0 + sy_click),
877 };
878 let end_world = Point {
879 x: px(mx + sx),
880 y: px(my + sy),
881 };
882 Some((start_world, end_world))
883 }
884
885 pub fn drag_screen_rect(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
886 if !self.is_dragging {
887 return None;
888 }
889 let (start_world, end_world) = self.drag_world_corners()?;
890 let scroll = self.scroll_handle.offset();
891 let sx: f32 = scroll.x.into();
892 let sy: f32 = scroll.y.into();
893 let start_screen = Point {
894 x: px(f32::from(start_world.x) - sx),
895 y: px(f32::from(start_world.y) - sy),
896 };
897 let end_screen = Point {
898 x: px(f32::from(end_world.x) - sx),
899 y: px(f32::from(end_world.y) - sy),
900 };
901 Some((start_screen, end_screen))
902 }
903
904 fn update_drag(&mut self) {
905 let (start_world, end_world) = match self.drag_world_corners() {
906 Some(c) => c,
907 None => return,
908 };
909 if !self.is_dragging {
910 let dx = f32::from(end_world.x) - f32::from(start_world.x);
911 let dy = f32::from(end_world.y) - f32::from(start_world.y);
912 if dx * dx + dy * dy <= 400.0 {
913 return;
914 }
915 self.is_dragging = true;
916 }
917 let r1 = match self.drag_start_hit {
918 Some(h) => h,
919 None => return,
920 };
921 let r2 = self.hit_test_content(f32::from(end_world.x), f32::from(end_world.y), 0.0, 0.0);
925 match (r1, r2) {
926 (HitResult::Cell(r1c, c1), HitResult::Cell(r2c, c2)) => {
927 self.selection =
928 Selection::CellRange(r1c.min(r2c), c1.min(c2), r1c.max(r2c), c1.max(c2));
929 }
930 (HitResult::RowHeader(r1r), HitResult::RowHeader(r2r)) => {
931 self.selection = Selection::RowRange(r1r.min(r2r), r1r.max(r2r));
932 }
933 _ => {}
934 }
935 }
936
937 fn update_drag_from_last(&mut self) {
938 self.update_drag();
939 }
940
941 pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, pressed_button: Option<MouseButton>) {
942 if self.is_dragging && pressed_button != Some(MouseButton::Left) {
943 self.handle_mouse_up();
944 return;
945 }
946 if let Some(col) = self.resizing_col {
947 if pressed_button != Some(MouseButton::Left) {
948 self.resizing_col = None;
949 return;
950 }
951 let new_w =
952 (self.resize_start_width + (f32::from(pos.x) - self.resize_start_x)).max(40.0);
953 self.data.columns[col].width = new_w;
954 return;
955 }
956 if let Some(axis) = self.scrollbar_drag {
957 if pressed_button != Some(MouseButton::Left) {
958 self.scrollbar_drag = None;
959 return;
960 }
961 match axis {
962 ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
963 ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
964 }
965 self.last_mouse_pos = Some(pos);
966 return;
967 }
968 self.last_mouse_pos = Some(pos);
969 if self.context_menu.is_some() {
970 return;
975 }
976 self.hover_hit = Some(self.hit_test(pos));
977 if self.drag_start.is_none() {
978 return;
979 }
980 self.update_drag();
981 }
982
983 pub fn handle_scroll_drag(&mut self) {
984 if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
985 self.update_drag();
986 }
987 }
988
989 pub fn handle_mouse_up(&mut self) {
990 self.resizing_col = None;
991 self.scrollbar_drag = None;
992 self.clear_drag();
993 }
994
995 pub fn apply_edge_scroll(&mut self) -> bool {
996 apply_edge_scroll(self)
997 }
998
999 pub fn select_all(&mut self) {
1000 let nrows = self.display_indices.len();
1001 let ncols = self.data.columns.len();
1002 if nrows > 0 && ncols > 0 {
1003 self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
1004 }
1005 }
1006
1007 pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
1008 let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
1009 return;
1010 };
1011 if self.display_indices.is_empty() || self.data.columns.is_empty() {
1012 return;
1013 }
1014 let last_row = self.display_indices.len() - 1;
1015 let last_col = self.data.columns.len() - 1;
1016 let r1 = raw_r1.min(last_row);
1017 let r2 = raw_r2.min(last_row);
1018 let c1 = raw_c1.min(last_col);
1019 let c2 = raw_c2.min(last_col);
1020 let mut text = String::new();
1021 if with_headers {
1022 for c in c1..=c2 {
1023 if c > c1 {
1024 text.push('\t');
1025 }
1026 text.push_str(&self.data.columns[c].name);
1027 }
1028 text.push('\n');
1029 }
1030 for dr in r1..=r2 {
1031 let row_idx = self.display_indices[dr];
1032 for c in c1..=c2 {
1033 if c > c1 {
1034 text.push('\t');
1035 }
1036 let cell = &self.data.rows[row_idx][c];
1037 let (s, _) = format_cell(cell, &self.resolved_formats[c]);
1038 text.push_str(&s);
1039 }
1040 text.push('\n');
1041 }
1042 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1043 }
1044
1045 pub fn page_up(&mut self) {
1046 let vh: f32 = self.bounds.size.height.into();
1047 let rows = ((vh - self.header_height) / self.row_height) as i32;
1048 self.move_selection(0, -rows);
1049 }
1050
1051 pub fn page_down(&mut self) {
1052 let vh: f32 = self.bounds.size.height.into();
1053 let rows = ((vh - self.header_height) / self.row_height) as i32;
1054 self.move_selection(0, rows);
1055 }
1056
1057 pub fn handle_key(&mut self, keystroke: &Keystroke) {
1058 if let Some(prompt) = &mut self.filter_prompt {
1059 match keystroke.key.as_str() {
1060 "escape" => self.filter_prompt = None,
1061 "enter" => {
1062 let col = prompt.col;
1063 self.filters[col] = prompt.input.clone();
1064 self.filter_prompt = None;
1065 self.recompute();
1066 }
1067 "backspace" => prompt.backspace(),
1068 "left" => {
1069 if prompt.cursor_chars > 0 {
1070 prompt.cursor_chars -= 1;
1071 }
1072 }
1073 "right" => {
1074 prompt.clamp_cursor();
1075 if prompt.cursor_chars < prompt.input.chars().count() {
1076 prompt.cursor_chars += 1;
1077 }
1078 }
1079 _ => {
1080 if let Some(ch) = keystroke_to_char(keystroke) {
1081 prompt.insert_char(ch);
1082 }
1083 }
1084 }
1085 return;
1086 }
1087 if self.context_menu.is_some() {
1088 if keystroke.key.as_str() == "escape" {
1089 self.context_menu = None;
1090 }
1091 return;
1092 }
1093 let shift = keystroke.modifiers.shift;
1094 match keystroke.key.as_str() {
1095 "up" if shift => self.extend_selection(0, -1),
1096 "down" if shift => self.extend_selection(0, 1),
1097 "left" if shift => self.extend_selection(-1, 0),
1098 "right" if shift => self.extend_selection(1, 0),
1099 "up" => self.move_selection(0, -1),
1100 "down" => self.move_selection(0, 1),
1101 "left" => self.move_selection(-1, 0),
1102 "right" => self.move_selection(1, 0),
1103 "escape" => {
1104 self.selection = Selection::None;
1105 self.range_anchor = None;
1106 self.range_active = None;
1107 }
1108 _ => {}
1109 }
1110 }
1111
1112 fn move_selection(&mut self, dx: i32, dy: i32) {
1113 let nrows = self.display_indices.len() as i32;
1114 let ncols = self.data.columns.len() as i32;
1115 if nrows == 0 || ncols == 0 {
1116 return;
1117 }
1118 let last_row = nrows - 1;
1119 let last_col = ncols - 1;
1120 match self.selection {
1121 Selection::Cell(row, col) => {
1122 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1123 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1124 self.selection = Selection::Cell(nr, nc);
1125 self.range_anchor = Some((nr, nc));
1126 self.range_active = Some((nr, nc));
1127 }
1128 Selection::Row(row) if dy != 0 => {
1129 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1130 self.selection = Selection::Row(nr);
1131 }
1132 Selection::Column(col) if dx != 0 => {
1133 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1134 self.selection = Selection::Column(nc);
1135 }
1136 _ => {
1137 self.selection = Selection::Cell(0, 0);
1138 self.range_anchor = Some((0, 0));
1139 self.range_active = Some((0, 0));
1140 }
1141 }
1142 }
1143
1144 fn extend_selection(&mut self, dx: i32, dy: i32) {
1148 let nrows = self.display_indices.len() as i32;
1149 let ncols = self.data.columns.len() as i32;
1150 if nrows == 0 || ncols == 0 {
1151 return;
1152 }
1153 let last_row = nrows - 1;
1154 let last_col = ncols - 1;
1155
1156 if self.range_anchor.is_none() || self.range_active.is_none() {
1158 match self.selection {
1159 Selection::Cell(r, c) => {
1160 self.range_anchor = Some((r, c));
1161 self.range_active = Some((r, c));
1162 }
1163 Selection::CellRange(r1, c1, r2, c2) => {
1164 self.range_anchor = Some((r1, c1));
1165 self.range_active = Some((r2, c2));
1166 }
1167 _ => {
1168 self.range_anchor = Some((0, 0));
1169 self.range_active = Some((0, 0));
1170 self.selection = Selection::Cell(0, 0);
1171 }
1172 }
1173 }
1174
1175 let anchor = self.range_anchor.unwrap_or((0, 0));
1176 let active = self.range_active.unwrap_or(anchor);
1177 let nr = (active.0 as i32 + dy).clamp(0, last_row) as usize;
1178 let nc = (active.1 as i32 + dx).clamp(0, last_col) as usize;
1179 self.range_active = Some((nr, nc));
1180
1181 self.selection = if (nr, nc) == anchor {
1182 Selection::Cell(nr, nc)
1183 } else {
1184 Selection::CellRange(
1185 anchor.0.min(nr),
1186 anchor.1.min(nc),
1187 anchor.0.max(nr),
1188 anchor.1.max(nc),
1189 )
1190 };
1191 }
1192
1193 pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
1194 let bounds = self.bounds;
1195 let (sx, sy) = (
1196 f32::from(self.scroll_handle.offset().x),
1197 f32::from(self.scroll_handle.offset().y),
1198 );
1199 let bw: f32 = bounds.size.width.into();
1200 let bh: f32 = bounds.size.height.into();
1201 let (mx, my) = self.max_scroll();
1202 if let Some(menu) = &self.context_menu {
1203 let cw = self.char_width;
1204 let x_rel = f32::from(pos.x);
1207 let y_rel = f32::from(pos.y);
1208 if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
1209 return HitResult::ContextMenuItem(idx);
1210 }
1211 }
1212 if my > 0.0
1213 && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
1214 && f32::from(pos.y) >= self.header_height
1215 {
1216 return HitResult::VerticalScrollbar;
1217 }
1218 if mx > 0.0
1219 && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
1220 && f32::from(pos.x) >= self.row_header_width
1221 {
1222 return HitResult::HorizontalScrollbar;
1223 }
1224 let px = f32::from(pos.x);
1230 let py = f32::from(pos.y);
1231 if px < 0.0 || py < 0.0 || px > bw || py > bh {
1232 return HitResult::None;
1233 }
1234 self.hit_test_content(px, py, sx, sy)
1235 }
1236
1237 fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
1238 if y < self.header_height {
1239 if x < self.row_header_width {
1240 return HitResult::Corner;
1241 }
1242 let col_x = x - self.row_header_width + sx;
1243 let mut acc = 0.0;
1244 for (i, col) in self.data.columns.iter().enumerate() {
1245 let right = acc + col.width;
1246 if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
1247 return HitResult::ColumnBorder(i);
1248 }
1249 if col_x >= acc && col_x < right {
1250 if col_x >= right - 20.0 {
1251 return HitResult::SortButton(i);
1252 }
1253 return HitResult::ColumnHeader(i);
1254 }
1255 acc = right;
1256 }
1257 return HitResult::None;
1258 }
1259 if x < self.row_header_width {
1260 let row_y = y - self.header_height + sy;
1261 if row_y < 0.0 {
1262 return HitResult::None;
1263 }
1264 let row_idx = (row_y / self.row_height) as usize;
1265 if row_idx < self.display_indices.len() {
1266 return HitResult::RowHeader(row_idx);
1267 }
1268 return HitResult::None;
1269 }
1270 let col_x = x - self.row_header_width + sx;
1271 let row_y = y - self.header_height + sy;
1272 if row_y < 0.0 {
1273 return HitResult::None;
1274 }
1275 let row_idx = (row_y / self.row_height) as usize;
1276 if row_idx >= self.display_indices.len() {
1277 return HitResult::None;
1278 }
1279 let mut acc = 0.0;
1280 for (i, col) in self.data.columns.iter().enumerate() {
1281 if col_x >= acc && col_x < acc + col.width {
1282 return HitResult::Cell(row_idx, i);
1283 }
1284 acc += col.width;
1285 }
1286 HitResult::None
1287 }
1288
1289 #[must_use]
1290 pub fn wants_edge_scroll_tick(&self) -> bool {
1291 self.is_dragging
1292 }
1293}
1294
1295fn keystroke_to_char(k: &Keystroke) -> Option<char> {
1296 if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
1297 return None;
1298 }
1299 if let Some(key_char) = k.key_char.as_ref() {
1300 return key_char.chars().next();
1301 }
1302 if k.key.chars().count() == 1 {
1303 let c = k.key.chars().next()?;
1304 if k.modifiers.shift {
1305 Some(c.to_ascii_uppercase())
1306 } else {
1307 Some(c)
1308 }
1309 } else {
1310 None
1311 }
1312}
1313
1314#[cfg(test)]
1315#[allow(
1316 clippy::unwrap_used,
1317 clippy::expect_used,
1318 clippy::field_reassign_with_default
1319)]
1320mod tests {
1321 use super::*;
1322 use crate::data::{CellValue, Column, ColumnKind};
1323 use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
1324
1325 fn anchor() -> Point<Pixels> {
1326 Point {
1327 x: px(0.0),
1328 y: px(0.0),
1329 }
1330 }
1331
1332 fn prompt_with(text: &str, cursor: usize) -> FilterPrompt {
1333 let mut p = FilterPrompt::new(0, anchor(), text.to_owned());
1334 p.cursor_chars = cursor;
1335 p
1336 }
1337
1338 #[test]
1339 fn filter_prompt_new_cursors_at_char_count_not_bytes() {
1340 let p = FilterPrompt::new(0, anchor(), "hé🙂".into());
1342 assert_eq!(p.cursor_chars, 3);
1343 assert_eq!(p.input.len(), 7);
1344 }
1345
1346 #[test]
1347 fn filter_prompt_insert_emoji_at_start_does_not_panic() {
1348 let mut p = prompt_with("ab", 0);
1349 p.insert_char('\u{1F600}');
1350 assert_eq!(p.input, "\u{1F600}ab");
1351 assert_eq!(p.cursor_chars, 1);
1352 }
1353
1354 #[test]
1355 fn filter_prompt_insert_in_middle_keeps_cursor_at_char_position() {
1356 let mut p = prompt_with("helloworld", 5);
1357 p.insert_char(' ');
1358 assert_eq!(p.input, "hello world");
1359 assert_eq!(p.cursor_chars, 6);
1360 }
1361
1362 #[test]
1363 fn filter_prompt_backspace_at_zero_is_noop() {
1364 let mut p = prompt_with("abc", 0);
1365 p.backspace();
1366 assert_eq!(p.input, "abc");
1367 assert_eq!(p.cursor_chars, 0);
1368 }
1369
1370 #[test]
1371 fn filter_prompt_backspace_removes_one_char_value() {
1372 let mut p = prompt_with("héx", 2);
1374 p.backspace();
1375 assert_eq!(p.input, "hx");
1376 assert_eq!(p.cursor_chars, 1);
1377 }
1378
1379 #[test]
1380 fn filter_prompt_clamp_cursor_pulls_back_past_end() {
1381 let mut p = prompt_with("abc", 99);
1382 p.clamp_cursor();
1383 assert_eq!(p.cursor_chars, 3);
1384 }
1385
1386 #[test]
1387 fn edge_scroll_speed_stops_outside_band() {
1388 assert_eq!(edge_scroll_speed(120.0), 0.0);
1390 assert_eq!(edge_scroll_speed(90.01), 0.0);
1391 assert_eq!(edge_scroll_speed(90.0), 4.0);
1393 assert_eq!(edge_scroll_speed(60.0), 4.0);
1394 assert_eq!(edge_scroll_speed(59.99), 8.0);
1395 assert_eq!(edge_scroll_speed(30.0), 8.0);
1397 assert_eq!(edge_scroll_speed(29.99), 16.0);
1398 assert_eq!(edge_scroll_speed(0.0), 16.0);
1400 assert_eq!(edge_scroll_speed(29.99), 16.0);
1401 }
1402
1403 #[test]
1404 fn edge_scroll_speed_caps_negative_runaway() {
1405 assert_eq!(edge_scroll_speed(-100.0), 16.0);
1407 assert_eq!(edge_scroll_speed(-1000.0), 16.0);
1408 }
1409
1410 #[allow(clippy::expect_used, clippy::unwrap_used)]
1418 #[test]
1419 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1420 fn grid_state_behavior_under_application() {
1421 gpui::Application::new().run(|cx| {
1422 let focus = cx.focus_handle();
1423
1424 let mut state = GridState::new(
1426 GridData::new(
1427 vec![Column::new("n", ColumnKind::Integer, 100.0)],
1428 vec![vec![CellValue::Integer(1)]],
1429 )
1430 .expect("rectangular"),
1431 crate::config::GridConfig::default(),
1432 focus.clone(),
1433 );
1434 let _ = format_current_status(&state);
1435 assert_eq!(state.selection, Selection::None);
1436
1437 state.last_mouse_pos = Some(Point {
1439 x: px(120.0),
1440 y: px(80.0),
1441 });
1442 let s = format_current_status(&state);
1443 assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
1444
1445 let mut state = GridState::new(
1447 GridData::new(
1448 vec![Column::new("name", ColumnKind::Text, 100.0)],
1449 vec![
1450 vec![CellValue::Text("alpha".into())],
1451 vec![CellValue::Text("beta".into())],
1452 vec![CellValue::Text("gamma".into())],
1453 ],
1454 )
1455 .expect("rectangular"),
1456 crate::config::GridConfig::default(),
1457 focus.clone(),
1458 );
1459 state.filters[0] = "a".into();
1460 state.toggle_sort(0);
1461 state.recompute();
1462 assert_eq!(state.display_indices, vec![0, 2]);
1463 state.toggle_sort(0);
1464 state.recompute();
1465 assert_eq!(state.display_indices, vec![2, 0]);
1466 state.filters[0].clear();
1467 state.toggle_sort(0);
1468 state.recompute();
1469 assert_eq!(state.display_indices, vec![0, 1, 2]);
1470
1471 let mut state = GridState::new(
1473 GridData::new(
1474 vec![Column::new("v", ColumnKind::Integer, 80.0)],
1475 vec![vec![CellValue::Integer(1)]],
1476 )
1477 .expect("rectangular"),
1478 crate::config::GridConfig::default(),
1479 focus.clone(),
1480 );
1481 state.toggle_sort(0);
1482 assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
1483 state.toggle_sort(0);
1484 assert_eq!(state.sort, Some((0, SortDirection::Descending)));
1485 state.toggle_sort(0);
1486 assert_eq!(state.sort, None);
1487
1488 let mut state = GridState::new(
1490 GridData::new(
1491 vec![
1492 Column::new("a", ColumnKind::Integer, 80.0),
1493 Column::new("b", ColumnKind::Integer, 80.0),
1494 ],
1495 vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
1496 )
1497 .expect("rectangular"),
1498 crate::config::GridConfig::default(),
1499 focus.clone(),
1500 );
1501 state.select_all();
1502 assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
1503
1504 let mut state = GridState::new(
1506 GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
1507 .expect("rectangular"),
1508 crate::config::GridConfig::default(),
1509 focus.clone(),
1510 );
1511 state.select_all();
1512 assert_eq!(state.selection, Selection::None);
1513
1514 let mut state = GridState::new(
1516 GridData::new(
1517 vec![Column::new("v", ColumnKind::Decimal, 100.0)],
1518 vec![vec![CellValue::Decimal(1.234)]],
1519 )
1520 .expect("rectangular"),
1521 crate::config::GridConfig::default(),
1522 focus.clone(),
1523 );
1524 assert_eq!(state.resolved_formats[0].number.decimals, 2);
1525 let mut cfg = crate::config::GridConfig::default();
1526 cfg.column_overrides = vec![crate::config::ColumnOverride {
1527 number: Some(crate::config::NumberFormat {
1528 decimals: 6,
1529 ..Default::default()
1530 }),
1531 ..Default::default()
1532 }];
1533 state.set_config(cfg);
1534 assert_eq!(state.resolved_formats[0].number.decimals, 6);
1535
1536 let mut state = GridState::new(
1538 GridData::new(
1539 vec![Column::new("a", ColumnKind::Integer, 80.0)],
1540 vec![vec![CellValue::Integer(1)]],
1541 )
1542 .expect("rectangular"),
1543 crate::config::GridConfig::default(),
1544 focus.clone(),
1545 );
1546 assert!(!state.wants_edge_scroll_tick());
1547 state.is_dragging = true;
1548 assert!(state.wants_edge_scroll_tick());
1549
1550 cx.quit();
1551 });
1552 }
1553
1554 #[allow(clippy::expect_used, clippy::unwrap_used)]
1555 #[test]
1556 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1557 fn context_menu_request_construction() {
1558 use crate::grid::context_menu::ContextMenuTarget;
1559
1560 gpui::Application::new().run(|cx| {
1561 let focus = cx.focus_handle();
1562
1563 let mut state = GridState::new(
1565 GridData::new(
1566 vec![
1567 Column::new("id", ColumnKind::Integer, 80.0),
1568 Column::new("name", ColumnKind::Text, 100.0),
1569 ],
1570 vec![
1571 vec![CellValue::Integer(1), CellValue::Text("alpha".into())],
1572 vec![CellValue::Integer(2), CellValue::Text("beta".into())],
1573 vec![CellValue::Integer(3), CellValue::Text("gamma".into())],
1574 ],
1575 )
1576 .expect("rectangular"),
1577 crate::config::GridConfig::default(),
1578 focus.clone(),
1579 );
1580 state.sort = Some((0, SortDirection::Descending));
1582 state.recompute();
1583 assert_eq!(state.display_indices, vec![2, 1, 0]);
1584
1585 let target = ContextMenuTarget::Cell {
1587 display_row_index: 0,
1588 source_row_index: 2,
1589 column_index: 1,
1590 };
1591 let sel = Selection::Cell(0, 1);
1592 let req = state.build_context_menu_request(target, &sel);
1593 assert_eq!(req.target.column_index(), Some(1));
1594 assert_eq!(req.selected_cells.len(), 1);
1595 assert_eq!(req.selected_cells[0].source_row_index, 2);
1596 assert_eq!(req.selected_cells[0].column_name, "name");
1597 assert_eq!(req.selected_cells[0].value, CellValue::Text("gamma".into()));
1598 assert_eq!(req.selected_rows.len(), 1);
1599 assert_eq!(req.selected_rows[0].source_row_index, 2);
1600 assert_eq!(
1601 req.selected_rows[0].value_by_name("id"),
1602 Some(&CellValue::Integer(3))
1603 );
1604
1605 let target = ContextMenuTarget::Cell {
1607 display_row_index: 0,
1608 source_row_index: 2,
1609 column_index: 0,
1610 };
1611 let sel = Selection::CellRange(0, 0, 1, 1);
1612 let req = state.build_context_menu_request(target, &sel);
1613 assert_eq!(req.selected_cells.len(), 4); assert_eq!(req.selected_rows.len(), 2);
1615 assert_eq!(req.selected_rows[0].source_row_index, 2);
1617 assert_eq!(req.selected_rows[1].source_row_index, 1);
1618
1619 let target = ContextMenuTarget::RowHeader {
1621 display_row_index: 1,
1622 source_row_index: 1,
1623 };
1624 let sel = Selection::RowRange(0, 2);
1625 let req = state.build_context_menu_request(target, &sel);
1626 assert_eq!(req.selected_rows.len(), 3);
1627 assert_eq!(req.selected_rows[0].values.len(), 2);
1629 assert_eq!(req.selected_cells.len(), 6); let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
1633 let sel = Selection::Column(0);
1634 let req = state.build_context_menu_request(target, &sel);
1635 assert_eq!(req.selected_rows.len(), 3);
1636 assert_eq!(req.selected_cells.len(), 3); let empty_state = GridState::new(
1640 GridData::new(vec![Column::new("x", ColumnKind::Integer, 80.0)], vec![])
1641 .expect("rectangular"),
1642 crate::config::GridConfig::default(),
1643 focus.clone(),
1644 );
1645 let target = ContextMenuTarget::Cell {
1646 display_row_index: 0,
1647 source_row_index: 0,
1648 column_index: 0,
1649 };
1650 let req = empty_state.build_context_menu_request(target, &Selection::None);
1651 assert!(req.selected_cells.is_empty());
1652 assert!(req.selected_rows.is_empty());
1653
1654 cx.quit();
1655 });
1656 }
1657
1658 #[allow(clippy::expect_used, clippy::unwrap_used)]
1659 #[test]
1660 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1661 fn effective_selection_for_context_target() {
1662 gpui::Application::new().run(|cx| {
1663 let focus = cx.focus_handle();
1664 let mut state = GridState::new(
1665 GridData::new(
1666 vec![
1667 Column::new("a", ColumnKind::Integer, 80.0),
1668 Column::new("b", ColumnKind::Integer, 80.0),
1669 ],
1670 vec![
1671 vec![CellValue::Integer(1), CellValue::Integer(2)],
1672 vec![CellValue::Integer(3), CellValue::Integer(4)],
1673 ],
1674 )
1675 .expect("rectangular"),
1676 crate::config::GridConfig::default(),
1677 focus,
1678 );
1679
1680 state.selection = Selection::Cell(0, 0);
1682 let target = ContextMenuTarget::Cell {
1683 display_row_index: 1,
1684 source_row_index: 1,
1685 column_index: 1,
1686 };
1687 let eff = state.effective_selection_for_context_target(&target);
1688 assert_eq!(eff, Selection::Cell(1, 1));
1689
1690 state.selection = Selection::CellRange(0, 0, 1, 1);
1692 let target = ContextMenuTarget::Cell {
1693 display_row_index: 1,
1694 source_row_index: 1,
1695 column_index: 1,
1696 };
1697 let eff = state.effective_selection_for_context_target(&target);
1698 assert_eq!(eff, Selection::CellRange(0, 0, 1, 1));
1699
1700 state.selection = Selection::Cell(0, 0);
1702 let target = ContextMenuTarget::RowHeader {
1703 display_row_index: 1,
1704 source_row_index: 1,
1705 };
1706 let eff = state.effective_selection_for_context_target(&target);
1707 assert_eq!(eff, Selection::Row(1));
1708
1709 state.selection = Selection::RowRange(0, 1);
1711 let target = ContextMenuTarget::RowHeader {
1712 display_row_index: 1,
1713 source_row_index: 1,
1714 };
1715 let eff = state.effective_selection_for_context_target(&target);
1716 assert_eq!(eff, Selection::RowRange(0, 1));
1717
1718 state.selection = Selection::Cell(1, 1);
1720 let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
1721 let eff = state.effective_selection_for_context_target(&target);
1722 assert_eq!(eff, Selection::Cell(1, 1));
1723
1724 cx.quit();
1725 });
1726 }
1727
1728 #[allow(clippy::expect_used, clippy::unwrap_used)]
1729 #[test]
1730 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1731 fn context_menu_target_from_hit_maps_correctly() {
1732 gpui::Application::new().run(|cx| {
1733 let focus = cx.focus_handle();
1734 let state = GridState::new(
1735 GridData::new(
1736 vec![Column::new("a", ColumnKind::Integer, 80.0)],
1737 vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(2)]],
1738 )
1739 .expect("rectangular"),
1740 crate::config::GridConfig::default(),
1741 focus,
1742 );
1743
1744 let t = state
1746 .context_menu_target_from_hit(HitResult::Cell(1, 0))
1747 .unwrap();
1748 assert_eq!(
1749 t,
1750 ContextMenuTarget::Cell {
1751 display_row_index: 1,
1752 source_row_index: 1,
1753 column_index: 0,
1754 }
1755 );
1756
1757 let t = state
1759 .context_menu_target_from_hit(HitResult::RowHeader(0))
1760 .unwrap();
1761 assert_eq!(
1762 t,
1763 ContextMenuTarget::RowHeader {
1764 display_row_index: 0,
1765 source_row_index: 0,
1766 }
1767 );
1768
1769 let t = state
1771 .context_menu_target_from_hit(HitResult::ColumnHeader(0))
1772 .unwrap();
1773 assert_eq!(t, ContextMenuTarget::ColumnHeader { column_index: 0 });
1774
1775 let t = state
1777 .context_menu_target_from_hit(HitResult::SortButton(0))
1778 .unwrap();
1779 assert_eq!(t, ContextMenuTarget::SortButton { column_index: 0 });
1780
1781 assert!(state
1783 .context_menu_target_from_hit(HitResult::VerticalScrollbar)
1784 .is_none());
1785 assert!(state
1786 .context_menu_target_from_hit(HitResult::None)
1787 .is_none());
1788
1789 cx.quit();
1790 });
1791 }
1792
1793 #[allow(clippy::expect_used, clippy::unwrap_used)]
1794 #[test]
1795 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1796 fn convert_context_menu_items_maps_variants() {
1797 use crate::grid::context_menu::ContextMenuItem;
1798
1799 let items = vec![
1800 ContextMenuItem::BuiltIn(MenuAction::SortAscending),
1801 ContextMenuItem::action("copy", "Copy value"),
1802 ContextMenuItem::separator(),
1803 ];
1804 let internal = GridState::convert_context_menu_items(items);
1805 assert!(matches!(
1806 internal[0],
1807 MenuItem::Action(MenuAction::SortAscending)
1808 ));
1809 assert!(
1810 matches!(&internal[1], MenuItem::Custom { id, label } if id == "copy" && label == "Copy value")
1811 );
1812 assert!(matches!(internal[2], MenuItem::Separator));
1813 }
1814
1815 #[allow(clippy::expect_used, clippy::unwrap_used)]
1816 #[test]
1817 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1818 fn execute_custom_context_menu_action_invokes_provider() {
1819 use crate::grid::context_menu::{
1820 ContextMenuProvider, ContextMenuProviderHandle, ContextMenuRequest,
1821 };
1822 use std::sync::{Arc, Mutex};
1823
1824 #[derive(Default)]
1825 struct TestProvider {
1826 last_action: Arc<Mutex<Option<String>>>,
1827 }
1828 impl ContextMenuProvider for TestProvider {
1829 fn menu_items(&self, _request: &ContextMenuRequest) -> Vec<ContextMenuItem> {
1830 vec![ContextMenuItem::action("test", "Test")]
1831 }
1832 fn on_action(
1833 &self,
1834 action_id: &str,
1835 _request: &ContextMenuRequest,
1836 _state: &mut GridState,
1837 _cx: &mut gpui::App,
1838 ) {
1839 *self.last_action.lock().unwrap() = Some(action_id.to_string());
1840 }
1841 }
1842
1843 gpui::Application::new().run(|cx| {
1844 let focus = cx.focus_handle();
1845 let mut state = GridState::new(
1846 GridData::new(
1847 vec![Column::new("a", ColumnKind::Integer, 80.0)],
1848 vec![vec![CellValue::Integer(1)]],
1849 )
1850 .expect("rectangular"),
1851 crate::config::GridConfig::default(),
1852 focus,
1853 );
1854
1855 let last = Arc::new(Mutex::new(None));
1856 state.context_menu_provider = Some(ContextMenuProviderHandle::new(TestProvider {
1857 last_action: last.clone(),
1858 }));
1859
1860 let target = ContextMenuTarget::Cell {
1861 display_row_index: 0,
1862 source_row_index: 0,
1863 column_index: 0,
1864 };
1865 let request = state.build_context_menu_request(target, &Selection::Cell(0, 0));
1866 state.execute_custom_context_menu_action(
1867 PendingCustomContextMenuAction {
1868 id: "test".into(),
1869 request,
1870 },
1871 cx,
1872 );
1873 assert_eq!(*last.lock().unwrap(), Some("test".to_string()));
1874 assert!(state.context_menu.is_none());
1875
1876 cx.quit();
1877 });
1878 }
1879}