Skip to main content

sqlly_datatable/grid/
state.rs

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