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 self.context_menu.is_some() {
956            // A menu is open. Hover highlighting is driven by the deferred
957            // overlay's per-item `on_mouse_move` handlers (widget.rs), which
958            // work even when the pointer is outside the grid's layout bounds.
959            // Don't run grid hit-testing or drag logic underneath the menu.
960            return;
961        }
962        self.hover_hit = Some(self.hit_test(pos));
963        if self.drag_start.is_none() {
964            return;
965        }
966        self.update_drag();
967    }
968
969    pub fn handle_scroll_drag(&mut self) {
970        if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
971            self.update_drag();
972        }
973    }
974
975    pub fn handle_mouse_up(&mut self) {
976        self.resizing_col = None;
977        self.scrollbar_drag = None;
978        self.clear_drag();
979    }
980
981    pub fn apply_edge_scroll(&mut self) -> bool {
982        apply_edge_scroll(self)
983    }
984
985    pub fn select_all(&mut self) {
986        let nrows = self.display_indices.len();
987        let ncols = self.data.columns.len();
988        if nrows > 0 && ncols > 0 {
989            self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
990        }
991    }
992
993    pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
994        let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
995            return;
996        };
997        if self.display_indices.is_empty() || self.data.columns.is_empty() {
998            return;
999        }
1000        let last_row = self.display_indices.len() - 1;
1001        let last_col = self.data.columns.len() - 1;
1002        let r1 = raw_r1.min(last_row);
1003        let r2 = raw_r2.min(last_row);
1004        let c1 = raw_c1.min(last_col);
1005        let c2 = raw_c2.min(last_col);
1006        let mut text = String::new();
1007        if with_headers {
1008            for c in c1..=c2 {
1009                if c > c1 {
1010                    text.push('\t');
1011                }
1012                text.push_str(&self.data.columns[c].name);
1013            }
1014            text.push('\n');
1015        }
1016        for dr in r1..=r2 {
1017            let row_idx = self.display_indices[dr];
1018            for c in c1..=c2 {
1019                if c > c1 {
1020                    text.push('\t');
1021                }
1022                let cell = &self.data.rows[row_idx][c];
1023                let (s, _) = format_cell(cell, &self.resolved_formats[c]);
1024                text.push_str(&s);
1025            }
1026            text.push('\n');
1027        }
1028        cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1029    }
1030
1031    pub fn page_up(&mut self) {
1032        let vh: f32 = self.bounds.size.height.into();
1033        let rows = ((vh - self.header_height) / self.row_height) as i32;
1034        self.move_selection(0, -rows);
1035    }
1036
1037    pub fn page_down(&mut self) {
1038        let vh: f32 = self.bounds.size.height.into();
1039        let rows = ((vh - self.header_height) / self.row_height) as i32;
1040        self.move_selection(0, rows);
1041    }
1042
1043    pub fn handle_key(&mut self, keystroke: &Keystroke) {
1044        if let Some(prompt) = &mut self.filter_prompt {
1045            match keystroke.key.as_str() {
1046                "escape" => self.filter_prompt = None,
1047                "enter" => {
1048                    let col = prompt.col;
1049                    self.filters[col] = prompt.input.clone();
1050                    self.filter_prompt = None;
1051                    self.recompute();
1052                }
1053                "backspace" => prompt.backspace(),
1054                "left" => {
1055                    if prompt.cursor_chars > 0 {
1056                        prompt.cursor_chars -= 1;
1057                    }
1058                }
1059                "right" => {
1060                    prompt.clamp_cursor();
1061                    if prompt.cursor_chars < prompt.input.chars().count() {
1062                        prompt.cursor_chars += 1;
1063                    }
1064                }
1065                _ => {
1066                    if let Some(ch) = keystroke_to_char(keystroke) {
1067                        prompt.insert_char(ch);
1068                    }
1069                }
1070            }
1071            return;
1072        }
1073        if self.context_menu.is_some() {
1074            if keystroke.key.as_str() == "escape" {
1075                self.context_menu = None;
1076            }
1077            return;
1078        }
1079        let shift = keystroke.modifiers.shift;
1080        match keystroke.key.as_str() {
1081            "up" if shift => self.extend_selection(0, -1),
1082            "down" if shift => self.extend_selection(0, 1),
1083            "left" if shift => self.extend_selection(-1, 0),
1084            "right" if shift => self.extend_selection(1, 0),
1085            "up" => self.move_selection(0, -1),
1086            "down" => self.move_selection(0, 1),
1087            "left" => self.move_selection(-1, 0),
1088            "right" => self.move_selection(1, 0),
1089            "escape" => {
1090                self.selection = Selection::None;
1091                self.range_anchor = None;
1092                self.range_active = None;
1093            }
1094            _ => {}
1095        }
1096    }
1097
1098    fn move_selection(&mut self, dx: i32, dy: i32) {
1099        let nrows = self.display_indices.len() as i32;
1100        let ncols = self.data.columns.len() as i32;
1101        if nrows == 0 || ncols == 0 {
1102            return;
1103        }
1104        let last_row = nrows - 1;
1105        let last_col = ncols - 1;
1106        match self.selection {
1107            Selection::Cell(row, col) => {
1108                let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1109                let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1110                self.selection = Selection::Cell(nr, nc);
1111                self.range_anchor = Some((nr, nc));
1112                self.range_active = Some((nr, nc));
1113            }
1114            Selection::Row(row) if dy != 0 => {
1115                let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1116                self.selection = Selection::Row(nr);
1117            }
1118            Selection::Column(col) if dx != 0 => {
1119                let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1120                self.selection = Selection::Column(nc);
1121            }
1122            _ => {
1123                self.selection = Selection::Cell(0, 0);
1124                self.range_anchor = Some((0, 0));
1125                self.range_active = Some((0, 0));
1126            }
1127        }
1128    }
1129
1130    /// Extend a rectangular cell selection by moving the active corner while
1131    /// holding the anchor corner fixed (shift+arrow). Mirrors the Swift grid's
1132    /// anchor/extent range model. Row and column selections are left unchanged.
1133    fn extend_selection(&mut self, dx: i32, dy: i32) {
1134        let nrows = self.display_indices.len() as i32;
1135        let ncols = self.data.columns.len() as i32;
1136        if nrows == 0 || ncols == 0 {
1137            return;
1138        }
1139        let last_row = nrows - 1;
1140        let last_col = ncols - 1;
1141
1142        // Seed anchor/active from the current selection when not already set.
1143        if self.range_anchor.is_none() || self.range_active.is_none() {
1144            match self.selection {
1145                Selection::Cell(r, c) => {
1146                    self.range_anchor = Some((r, c));
1147                    self.range_active = Some((r, c));
1148                }
1149                Selection::CellRange(r1, c1, r2, c2) => {
1150                    self.range_anchor = Some((r1, c1));
1151                    self.range_active = Some((r2, c2));
1152                }
1153                _ => {
1154                    self.range_anchor = Some((0, 0));
1155                    self.range_active = Some((0, 0));
1156                    self.selection = Selection::Cell(0, 0);
1157                }
1158            }
1159        }
1160
1161        let anchor = self.range_anchor.unwrap_or((0, 0));
1162        let active = self.range_active.unwrap_or(anchor);
1163        let nr = (active.0 as i32 + dy).clamp(0, last_row) as usize;
1164        let nc = (active.1 as i32 + dx).clamp(0, last_col) as usize;
1165        self.range_active = Some((nr, nc));
1166
1167        self.selection = if (nr, nc) == anchor {
1168            Selection::Cell(nr, nc)
1169        } else {
1170            Selection::CellRange(
1171                anchor.0.min(nr),
1172                anchor.1.min(nc),
1173                anchor.0.max(nr),
1174                anchor.1.max(nc),
1175            )
1176        };
1177    }
1178
1179    pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
1180        let bounds = self.bounds;
1181        let (sx, sy) = (
1182            f32::from(self.scroll_handle.offset().x),
1183            f32::from(self.scroll_handle.offset().y),
1184        );
1185        let bw: f32 = bounds.size.width.into();
1186        let bh: f32 = bounds.size.height.into();
1187        let (mx, my) = self.max_scroll();
1188        if let Some(menu) = &self.context_menu {
1189            let cw = self.char_width;
1190            // `pos` is grid-relative and the menu anchor is stored
1191            // grid-relative, so compare directly — no origin, no scroll.
1192            let x_rel = f32::from(pos.x);
1193            let y_rel = f32::from(pos.y);
1194            if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
1195                return HitResult::ContextMenuItem(idx);
1196            }
1197        }
1198        if my > 0.0
1199            && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
1200            && f32::from(pos.y) >= self.header_height
1201        {
1202            return HitResult::VerticalScrollbar;
1203        }
1204        if mx > 0.0
1205            && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
1206            && f32::from(pos.x) >= self.row_header_width
1207        {
1208            return HitResult::HorizontalScrollbar;
1209        }
1210        // `pos` is grid-relative. `hit_test_content` folds the scroll offset in
1211        // itself for each scrolling region, so pass `pos` directly — NOT
1212        // content-space coordinates, which would double-apply the offset and
1213        // also break the fixed header-region checks (`y < header_height`,
1214        // `x < row_header_width`) that are evaluated in grid-relative space.
1215        let px = f32::from(pos.x);
1216        let py = f32::from(pos.y);
1217        if px < 0.0 || py < 0.0 || px > bw || py > bh {
1218            return HitResult::None;
1219        }
1220        self.hit_test_content(px, py, sx, sy)
1221    }
1222
1223    fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
1224        if y < self.header_height {
1225            if x < self.row_header_width {
1226                return HitResult::Corner;
1227            }
1228            let col_x = x - self.row_header_width + sx;
1229            let mut acc = 0.0;
1230            for (i, col) in self.data.columns.iter().enumerate() {
1231                let right = acc + col.width;
1232                if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
1233                    return HitResult::ColumnBorder(i);
1234                }
1235                if col_x >= acc && col_x < right {
1236                    if col_x >= right - 20.0 {
1237                        return HitResult::SortButton(i);
1238                    }
1239                    return HitResult::ColumnHeader(i);
1240                }
1241                acc = right;
1242            }
1243            return HitResult::None;
1244        }
1245        if x < self.row_header_width {
1246            let row_y = y - self.header_height + sy;
1247            if row_y < 0.0 {
1248                return HitResult::None;
1249            }
1250            let row_idx = (row_y / self.row_height) as usize;
1251            if row_idx < self.display_indices.len() {
1252                return HitResult::RowHeader(row_idx);
1253            }
1254            return HitResult::None;
1255        }
1256        let col_x = x - self.row_header_width + sx;
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::None;
1264        }
1265        let mut acc = 0.0;
1266        for (i, col) in self.data.columns.iter().enumerate() {
1267            if col_x >= acc && col_x < acc + col.width {
1268                return HitResult::Cell(row_idx, i);
1269            }
1270            acc += col.width;
1271        }
1272        HitResult::None
1273    }
1274
1275    #[must_use]
1276    pub fn wants_edge_scroll_tick(&self) -> bool {
1277        self.is_dragging
1278    }
1279}
1280
1281fn keystroke_to_char(k: &Keystroke) -> Option<char> {
1282    if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
1283        return None;
1284    }
1285    if let Some(key_char) = k.key_char.as_ref() {
1286        return key_char.chars().next();
1287    }
1288    if k.key.chars().count() == 1 {
1289        let c = k.key.chars().next()?;
1290        if k.modifiers.shift {
1291            Some(c.to_ascii_uppercase())
1292        } else {
1293            Some(c)
1294        }
1295    } else {
1296        None
1297    }
1298}
1299
1300#[cfg(test)]
1301#[allow(
1302    clippy::unwrap_used,
1303    clippy::expect_used,
1304    clippy::field_reassign_with_default
1305)]
1306mod tests {
1307    use super::*;
1308    use crate::data::{CellValue, Column, ColumnKind};
1309    use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
1310
1311    fn anchor() -> Point<Pixels> {
1312        Point {
1313            x: px(0.0),
1314            y: px(0.0),
1315        }
1316    }
1317
1318    fn prompt_with(text: &str, cursor: usize) -> FilterPrompt {
1319        let mut p = FilterPrompt::new(0, anchor(), text.to_owned());
1320        p.cursor_chars = cursor;
1321        p
1322    }
1323
1324    #[test]
1325    fn filter_prompt_new_cursors_at_char_count_not_bytes() {
1326        // "hé🙂" is 3 chars but 7 bytes (h=1, é=2, 🙂=4).
1327        let p = FilterPrompt::new(0, anchor(), "hé🙂".into());
1328        assert_eq!(p.cursor_chars, 3);
1329        assert_eq!(p.input.len(), 7);
1330    }
1331
1332    #[test]
1333    fn filter_prompt_insert_emoji_at_start_does_not_panic() {
1334        let mut p = prompt_with("ab", 0);
1335        p.insert_char('\u{1F600}');
1336        assert_eq!(p.input, "\u{1F600}ab");
1337        assert_eq!(p.cursor_chars, 1);
1338    }
1339
1340    #[test]
1341    fn filter_prompt_insert_in_middle_keeps_cursor_at_char_position() {
1342        let mut p = prompt_with("helloworld", 5);
1343        p.insert_char(' ');
1344        assert_eq!(p.input, "hello world");
1345        assert_eq!(p.cursor_chars, 6);
1346    }
1347
1348    #[test]
1349    fn filter_prompt_backspace_at_zero_is_noop() {
1350        let mut p = prompt_with("abc", 0);
1351        p.backspace();
1352        assert_eq!(p.input, "abc");
1353        assert_eq!(p.cursor_chars, 0);
1354    }
1355
1356    #[test]
1357    fn filter_prompt_backspace_removes_one_char_value() {
1358        // Cursor sits after "hé" (2 chars); backspace should delete "é" only.
1359        let mut p = prompt_with("héx", 2);
1360        p.backspace();
1361        assert_eq!(p.input, "hx");
1362        assert_eq!(p.cursor_chars, 1);
1363    }
1364
1365    #[test]
1366    fn filter_prompt_clamp_cursor_pulls_back_past_end() {
1367        let mut p = prompt_with("abc", 99);
1368        p.clamp_cursor();
1369        assert_eq!(p.cursor_chars, 3);
1370    }
1371
1372    #[test]
1373    fn edge_scroll_speed_stops_outside_band() {
1374        // Outside the 90 px trigger band: no scroll.
1375        assert_eq!(edge_scroll_speed(120.0), 0.0);
1376        assert_eq!(edge_scroll_speed(90.01), 0.0);
1377        // 60 ..= 90 -> 4 px/tick (slowest band).
1378        assert_eq!(edge_scroll_speed(90.0), 4.0);
1379        assert_eq!(edge_scroll_speed(60.0), 4.0);
1380        assert_eq!(edge_scroll_speed(59.99), 8.0);
1381        // 30 ..= 60 -> 8 px/tick.
1382        assert_eq!(edge_scroll_speed(30.0), 8.0);
1383        assert_eq!(edge_scroll_speed(29.99), 16.0);
1384        // < 30 -> 16 px/tick (really fast).
1385        assert_eq!(edge_scroll_speed(0.0), 16.0);
1386        assert_eq!(edge_scroll_speed(29.99), 16.0);
1387    }
1388
1389    #[test]
1390    fn edge_scroll_speed_caps_negative_runaway() {
1391        // Past the edge: saturate at the really-fast speed (16), not higher.
1392        assert_eq!(edge_scroll_speed(-100.0), 16.0);
1393        assert_eq!(edge_scroll_speed(-1000.0), 16.0);
1394    }
1395
1396    /// `GridState` requires a real GPUI `FocusHandle` from
1397    /// `gpui::Application`, but `gpui::Application::new()` panics on any
1398    /// thread other than `main`. Since Rust's test runner executes on a
1399    /// worker pool, the GPUI-backed assertions cannot run alongside pure
1400    /// tests. We mark this test `#[ignore]` so `cargo test` stays green; run
1401    /// it with `cargo test -- --ignored grid_state_behavior_under_application`
1402    /// from the workspace root on the test thread observable to GPUI.
1403    #[allow(clippy::expect_used, clippy::unwrap_used)]
1404    #[test]
1405    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1406    fn grid_state_behavior_under_application() {
1407        gpui::Application::new().run(|cx| {
1408            let focus = cx.focus_handle();
1409
1410            // format_current_status_handles_initial_state
1411            let mut state = GridState::new(
1412                GridData::new(
1413                    vec![Column::new("n", ColumnKind::Integer, 100.0)],
1414                    vec![vec![CellValue::Integer(1)]],
1415                )
1416                .expect("rectangular"),
1417                crate::config::GridConfig::default(),
1418                focus.clone(),
1419            );
1420            let _ = format_current_status(&state);
1421            assert_eq!(state.selection, Selection::None);
1422
1423            // format_current_status_replaces_with_supplied_pos
1424            state.last_mouse_pos = Some(Point {
1425                x: px(120.0),
1426                y: px(80.0),
1427            });
1428            let s = format_current_status(&state);
1429            assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
1430
1431            // recompute_filters_then_sorts_then_clears
1432            let mut state = GridState::new(
1433                GridData::new(
1434                    vec![Column::new("name", ColumnKind::Text, 100.0)],
1435                    vec![
1436                        vec![CellValue::Text("alpha".into())],
1437                        vec![CellValue::Text("beta".into())],
1438                        vec![CellValue::Text("gamma".into())],
1439                    ],
1440                )
1441                .expect("rectangular"),
1442                crate::config::GridConfig::default(),
1443                focus.clone(),
1444            );
1445            state.filters[0] = "a".into();
1446            state.toggle_sort(0);
1447            state.recompute();
1448            assert_eq!(state.display_indices, vec![0, 2]);
1449            state.toggle_sort(0);
1450            state.recompute();
1451            assert_eq!(state.display_indices, vec![2, 0]);
1452            state.filters[0].clear();
1453            state.toggle_sort(0);
1454            state.recompute();
1455            assert_eq!(state.display_indices, vec![0, 1, 2]);
1456
1457            // toggle_sort_cycles_through_three_states
1458            let mut state = GridState::new(
1459                GridData::new(
1460                    vec![Column::new("v", ColumnKind::Integer, 80.0)],
1461                    vec![vec![CellValue::Integer(1)]],
1462                )
1463                .expect("rectangular"),
1464                crate::config::GridConfig::default(),
1465                focus.clone(),
1466            );
1467            state.toggle_sort(0);
1468            assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
1469            state.toggle_sort(0);
1470            assert_eq!(state.sort, Some((0, SortDirection::Descending)));
1471            state.toggle_sort(0);
1472            assert_eq!(state.sort, None);
1473
1474            // select_all_picks_full_range_when_data_present
1475            let mut state = GridState::new(
1476                GridData::new(
1477                    vec![
1478                        Column::new("a", ColumnKind::Integer, 80.0),
1479                        Column::new("b", ColumnKind::Integer, 80.0),
1480                    ],
1481                    vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
1482                )
1483                .expect("rectangular"),
1484                crate::config::GridConfig::default(),
1485                focus.clone(),
1486            );
1487            state.select_all();
1488            assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
1489
1490            // select_all_is_noop_on_empty
1491            let mut state = GridState::new(
1492                GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
1493                    .expect("rectangular"),
1494                crate::config::GridConfig::default(),
1495                focus.clone(),
1496            );
1497            state.select_all();
1498            assert_eq!(state.selection, Selection::None);
1499
1500            // set_config_refreshes_resolved_formats
1501            let mut state = GridState::new(
1502                GridData::new(
1503                    vec![Column::new("v", ColumnKind::Decimal, 100.0)],
1504                    vec![vec![CellValue::Decimal(1.234)]],
1505                )
1506                .expect("rectangular"),
1507                crate::config::GridConfig::default(),
1508                focus.clone(),
1509            );
1510            assert_eq!(state.resolved_formats[0].number.decimals, 2);
1511            let mut cfg = crate::config::GridConfig::default();
1512            cfg.column_overrides = vec![crate::config::ColumnOverride {
1513                number: Some(crate::config::NumberFormat {
1514                    decimals: 6,
1515                    ..Default::default()
1516                }),
1517                ..Default::default()
1518            }];
1519            state.set_config(cfg);
1520            assert_eq!(state.resolved_formats[0].number.decimals, 6);
1521
1522            // wants_edge_scroll_tick_mirrors_is_dragging
1523            let mut state = GridState::new(
1524                GridData::new(
1525                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
1526                    vec![vec![CellValue::Integer(1)]],
1527                )
1528                .expect("rectangular"),
1529                crate::config::GridConfig::default(),
1530                focus.clone(),
1531            );
1532            assert!(!state.wants_edge_scroll_tick());
1533            state.is_dragging = true;
1534            assert!(state.wants_edge_scroll_tick());
1535
1536            cx.quit();
1537        });
1538    }
1539
1540    #[allow(clippy::expect_used, clippy::unwrap_used)]
1541    #[test]
1542    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1543    fn context_menu_request_construction() {
1544        use crate::grid::context_menu::ContextMenuTarget;
1545
1546        gpui::Application::new().run(|cx| {
1547            let focus = cx.focus_handle();
1548
1549            // 3 rows, 2 columns. Sort descending so display_indices != source.
1550            let mut state = GridState::new(
1551                GridData::new(
1552                    vec![
1553                        Column::new("id", ColumnKind::Integer, 80.0),
1554                        Column::new("name", ColumnKind::Text, 100.0),
1555                    ],
1556                    vec![
1557                        vec![CellValue::Integer(1), CellValue::Text("alpha".into())],
1558                        vec![CellValue::Integer(2), CellValue::Text("beta".into())],
1559                        vec![CellValue::Integer(3), CellValue::Text("gamma".into())],
1560                    ],
1561                )
1562                .expect("rectangular"),
1563                crate::config::GridConfig::default(),
1564                focus.clone(),
1565            );
1566            // Sort descending on column 0: display order is [2, 1, 0].
1567            state.sort = Some((0, SortDirection::Descending));
1568            state.recompute();
1569            assert_eq!(state.display_indices, vec![2, 1, 0]);
1570
1571            // Cell target at display row 0 -> source row 2.
1572            let target = ContextMenuTarget::Cell {
1573                display_row_index: 0,
1574                source_row_index: 2,
1575                column_index: 1,
1576            };
1577            let sel = Selection::Cell(0, 1);
1578            let req = state.build_context_menu_request(target, &sel);
1579            assert_eq!(req.target.column_index(), Some(1));
1580            assert_eq!(req.selected_cells.len(), 1);
1581            assert_eq!(req.selected_cells[0].source_row_index, 2);
1582            assert_eq!(req.selected_cells[0].column_name, "name");
1583            assert_eq!(req.selected_cells[0].value, CellValue::Text("gamma".into()));
1584            assert_eq!(req.selected_rows.len(), 1);
1585            assert_eq!(req.selected_rows[0].source_row_index, 2);
1586            assert_eq!(
1587                req.selected_rows[0].value_by_name("id"),
1588                Some(&CellValue::Integer(3))
1589            );
1590
1591            // Cell-range selection (display rows 0-1, cols 0-1).
1592            let target = ContextMenuTarget::Cell {
1593                display_row_index: 0,
1594                source_row_index: 2,
1595                column_index: 0,
1596            };
1597            let sel = Selection::CellRange(0, 0, 1, 1);
1598            let req = state.build_context_menu_request(target, &sel);
1599            assert_eq!(req.selected_cells.len(), 4); // 2 rows x 2 cols
1600            assert_eq!(req.selected_rows.len(), 2);
1601            // Display row 0 -> source 2, display row 1 -> source 1.
1602            assert_eq!(req.selected_rows[0].source_row_index, 2);
1603            assert_eq!(req.selected_rows[1].source_row_index, 1);
1604
1605            // Row-range selection (display rows 0-2).
1606            let target = ContextMenuTarget::RowHeader {
1607                display_row_index: 1,
1608                source_row_index: 1,
1609            };
1610            let sel = Selection::RowRange(0, 2);
1611            let req = state.build_context_menu_request(target, &sel);
1612            assert_eq!(req.selected_rows.len(), 3);
1613            // Each row should have all column values.
1614            assert_eq!(req.selected_rows[0].values.len(), 2);
1615            assert_eq!(req.selected_cells.len(), 6); // 3 rows x 2 cols
1616
1617            // Column selection (all display rows, column 0).
1618            let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
1619            let sel = Selection::Column(0);
1620            let req = state.build_context_menu_request(target, &sel);
1621            assert_eq!(req.selected_rows.len(), 3);
1622            assert_eq!(req.selected_cells.len(), 3); // 3 rows x 1 col
1623
1624            // Empty data — no panic, empty vectors.
1625            let empty_state = GridState::new(
1626                GridData::new(vec![Column::new("x", ColumnKind::Integer, 80.0)], vec![])
1627                    .expect("rectangular"),
1628                crate::config::GridConfig::default(),
1629                focus.clone(),
1630            );
1631            let target = ContextMenuTarget::Cell {
1632                display_row_index: 0,
1633                source_row_index: 0,
1634                column_index: 0,
1635            };
1636            let req = empty_state.build_context_menu_request(target, &Selection::None);
1637            assert!(req.selected_cells.is_empty());
1638            assert!(req.selected_rows.is_empty());
1639
1640            cx.quit();
1641        });
1642    }
1643
1644    #[allow(clippy::expect_used, clippy::unwrap_used)]
1645    #[test]
1646    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1647    fn effective_selection_for_context_target() {
1648        gpui::Application::new().run(|cx| {
1649            let focus = cx.focus_handle();
1650            let mut state = GridState::new(
1651                GridData::new(
1652                    vec![
1653                        Column::new("a", ColumnKind::Integer, 80.0),
1654                        Column::new("b", ColumnKind::Integer, 80.0),
1655                    ],
1656                    vec![
1657                        vec![CellValue::Integer(1), CellValue::Integer(2)],
1658                        vec![CellValue::Integer(3), CellValue::Integer(4)],
1659                    ],
1660                )
1661                .expect("rectangular"),
1662                crate::config::GridConfig::default(),
1663                focus,
1664            );
1665
1666            // Outside current selection -> collapses to target cell.
1667            state.selection = Selection::Cell(0, 0);
1668            let target = ContextMenuTarget::Cell {
1669                display_row_index: 1,
1670                source_row_index: 1,
1671                column_index: 1,
1672            };
1673            let eff = state.effective_selection_for_context_target(&target);
1674            assert_eq!(eff, Selection::Cell(1, 1));
1675
1676            // Inside current selection -> keeps selection.
1677            state.selection = Selection::CellRange(0, 0, 1, 1);
1678            let target = ContextMenuTarget::Cell {
1679                display_row_index: 1,
1680                source_row_index: 1,
1681                column_index: 1,
1682            };
1683            let eff = state.effective_selection_for_context_target(&target);
1684            assert_eq!(eff, Selection::CellRange(0, 0, 1, 1));
1685
1686            // Row header outside -> collapses to row.
1687            state.selection = Selection::Cell(0, 0);
1688            let target = ContextMenuTarget::RowHeader {
1689                display_row_index: 1,
1690                source_row_index: 1,
1691            };
1692            let eff = state.effective_selection_for_context_target(&target);
1693            assert_eq!(eff, Selection::Row(1));
1694
1695            // Row header inside row range -> keeps range.
1696            state.selection = Selection::RowRange(0, 1);
1697            let target = ContextMenuTarget::RowHeader {
1698                display_row_index: 1,
1699                source_row_index: 1,
1700            };
1701            let eff = state.effective_selection_for_context_target(&target);
1702            assert_eq!(eff, Selection::RowRange(0, 1));
1703
1704            // Column header -> does not change selection.
1705            state.selection = Selection::Cell(1, 1);
1706            let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
1707            let eff = state.effective_selection_for_context_target(&target);
1708            assert_eq!(eff, Selection::Cell(1, 1));
1709
1710            cx.quit();
1711        });
1712    }
1713
1714    #[allow(clippy::expect_used, clippy::unwrap_used)]
1715    #[test]
1716    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1717    fn context_menu_target_from_hit_maps_correctly() {
1718        gpui::Application::new().run(|cx| {
1719            let focus = cx.focus_handle();
1720            let state = GridState::new(
1721                GridData::new(
1722                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
1723                    vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(2)]],
1724                )
1725                .expect("rectangular"),
1726                crate::config::GridConfig::default(),
1727                focus,
1728            );
1729
1730            // Cell hit -> Cell target with source mapping.
1731            let t = state
1732                .context_menu_target_from_hit(HitResult::Cell(1, 0))
1733                .unwrap();
1734            assert_eq!(
1735                t,
1736                ContextMenuTarget::Cell {
1737                    display_row_index: 1,
1738                    source_row_index: 1,
1739                    column_index: 0,
1740                }
1741            );
1742
1743            // Row header -> RowHeader target.
1744            let t = state
1745                .context_menu_target_from_hit(HitResult::RowHeader(0))
1746                .unwrap();
1747            assert_eq!(
1748                t,
1749                ContextMenuTarget::RowHeader {
1750                    display_row_index: 0,
1751                    source_row_index: 0,
1752                }
1753            );
1754
1755            // Column header -> ColumnHeader target.
1756            let t = state
1757                .context_menu_target_from_hit(HitResult::ColumnHeader(0))
1758                .unwrap();
1759            assert_eq!(t, ContextMenuTarget::ColumnHeader { column_index: 0 });
1760
1761            // Sort button -> SortButton target.
1762            let t = state
1763                .context_menu_target_from_hit(HitResult::SortButton(0))
1764                .unwrap();
1765            assert_eq!(t, ContextMenuTarget::SortButton { column_index: 0 });
1766
1767            // Unsupported hits -> None.
1768            assert!(state
1769                .context_menu_target_from_hit(HitResult::VerticalScrollbar)
1770                .is_none());
1771            assert!(state
1772                .context_menu_target_from_hit(HitResult::None)
1773                .is_none());
1774
1775            cx.quit();
1776        });
1777    }
1778
1779    #[allow(clippy::expect_used, clippy::unwrap_used)]
1780    #[test]
1781    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1782    fn convert_context_menu_items_maps_variants() {
1783        use crate::grid::context_menu::ContextMenuItem;
1784
1785        let items = vec![
1786            ContextMenuItem::BuiltIn(MenuAction::SortAscending),
1787            ContextMenuItem::action("copy", "Copy value"),
1788            ContextMenuItem::separator(),
1789        ];
1790        let internal = GridState::convert_context_menu_items(items);
1791        assert!(matches!(
1792            internal[0],
1793            MenuItem::Action(MenuAction::SortAscending)
1794        ));
1795        assert!(
1796            matches!(&internal[1], MenuItem::Custom { id, label } if id == "copy" && label == "Copy value")
1797        );
1798        assert!(matches!(internal[2], MenuItem::Separator));
1799    }
1800
1801    #[allow(clippy::expect_used, clippy::unwrap_used)]
1802    #[test]
1803    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1804    fn execute_custom_context_menu_action_invokes_provider() {
1805        use crate::grid::context_menu::{
1806            ContextMenuProvider, ContextMenuProviderHandle, ContextMenuRequest,
1807        };
1808        use std::sync::{Arc, Mutex};
1809
1810        #[derive(Default)]
1811        struct TestProvider {
1812            last_action: Arc<Mutex<Option<String>>>,
1813        }
1814        impl ContextMenuProvider for TestProvider {
1815            fn menu_items(&self, _request: &ContextMenuRequest) -> Vec<ContextMenuItem> {
1816                vec![ContextMenuItem::action("test", "Test")]
1817            }
1818            fn on_action(
1819                &self,
1820                action_id: &str,
1821                _request: &ContextMenuRequest,
1822                _state: &mut GridState,
1823                _cx: &mut gpui::App,
1824            ) {
1825                *self.last_action.lock().unwrap() = Some(action_id.to_string());
1826            }
1827        }
1828
1829        gpui::Application::new().run(|cx| {
1830            let focus = cx.focus_handle();
1831            let mut state = GridState::new(
1832                GridData::new(
1833                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
1834                    vec![vec![CellValue::Integer(1)]],
1835                )
1836                .expect("rectangular"),
1837                crate::config::GridConfig::default(),
1838                focus,
1839            );
1840
1841            let last = Arc::new(Mutex::new(None));
1842            state.context_menu_provider = Some(ContextMenuProviderHandle::new(TestProvider {
1843                last_action: last.clone(),
1844            }));
1845
1846            let target = ContextMenuTarget::Cell {
1847                display_row_index: 0,
1848                source_row_index: 0,
1849                column_index: 0,
1850            };
1851            let request = state.build_context_menu_request(target, &Selection::Cell(0, 0));
1852            state.execute_custom_context_menu_action(
1853                PendingCustomContextMenuAction {
1854                    id: "test".into(),
1855                    request,
1856                },
1857                cx,
1858            );
1859            assert_eq!(*last.lock().unwrap(), Some("test".to_string()));
1860            assert!(state.context_menu.is_none());
1861
1862            cx.quit();
1863        });
1864    }
1865}