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