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