Skip to main content

sqlly_datatable/grid/
state.rs

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