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