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