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