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