Skip to main content

sqlly_datatable/grid/
state.rs

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