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, ColumnKind, GridData, GridDataError};
7use crate::filter::{
8    cell_passes_filter, parse_ymd_to_unix, uses_number_ops, ColumnFilter, FilterPredicate,
9    NumberOp, TextOp,
10};
11use crate::format::format_cell;
12use crate::grid::state::state_inner::apply_edge_scroll;
13use crate::grid::theme::GridTheme;
14
15use crate::config::{GridConfig, ResolvedColumnFormat};
16use gpui::{
17    px, App, Bounds, FocusHandle, Keystroke, MouseButton, Pixels, Point, ScrollHandle, Size,
18};
19use std::sync::Arc;
20
21// Pull selection / menu types into scope unqualified for this module's impl.
22use crate::grid::menu as menu_mod;
23#[allow(unused_imports)]
24pub(crate) use crate::grid::menu::{ContextMenu, MenuAction, MenuItem};
25use crate::grid::selection::{
26    is_cell_selected, is_row_selected, HitResult, ScrollbarAxis, Selection, SortDirection,
27};
28
29use crate::grid::context_menu::{
30    ColumnContext, ContextMenuItem, ContextMenuProviderHandle, ContextMenuRequest,
31    ContextMenuSelection, ContextMenuTarget, PendingCustomContextMenuAction,
32};
33
34/// Inline constructor / state mutators used by the widget's render loop.
35/// Kept in its own submodule so this module remains the public surface while
36/// its helpers are exposed for unit tests.
37pub mod state_inner {
38    use super::{
39        format_cell, CellValue, GridState, HitResult, Pixels, Point, ResolvedColumnFormat,
40    };
41    pub use crate::grid::selection::screen_to_content;
42    pub use crate::grid::selection::to_grid_relative;
43    use std::fmt::Write as _;
44
45    /// Per-tick edge-scroll velocity in pixels (positive scrolls the content
46    /// forward; the caller applies sign). Three staged bands spaced 30 px
47    /// apart, each a little faster than the last as the pointer approaches the
48    /// edge, with a final "really fast" tier inside 30 px. Ticks fire every
49    /// [`EDGE_SCROLL_TICK_MS`] (~60 fps), so px/sec ≈ px/tick × 62.5:
50    ///
51    /// | distance from edge | px/tick |  ~px/sec @ 60fps |
52    /// |--------------------|---------|------------------|
53    /// | > 90               | 0       | (no scroll)      |
54    /// | 60 ..= 90          | 4       | 250              |
55    /// | 30 ..= 60          | 8       | 500              |
56    /// | < 30               | 16      | 1000 (really fast)|
57    /// | < 0 (past edge)    | 16      | (saturate)       |
58    const REALLY_FAST: f32 = 16.0;
59    pub fn edge_scroll_speed(dist_from_edge: f32) -> f32 {
60        if dist_from_edge > 90.0 {
61            return 0.0;
62        }
63        if dist_from_edge < 0.0 {
64            // Cursor dragged past the edge: saturate at the really-fast speed
65            // so going further out never exceeds the closest in-bounds band.
66            return REALLY_FAST;
67        }
68        if dist_from_edge < 30.0 {
69            REALLY_FAST
70        } else if dist_from_edge < 60.0 {
71            8.0
72        } else {
73            4.0
74        }
75    }
76
77    pub fn apply_edge_scroll(state: &mut GridState) -> bool {
78        if !state.is_dragging {
79            return false;
80        }
81        let Some(pos) = state.last_mouse_pos else {
82            return false;
83        };
84        let bounds = state.bounds;
85        // `pos` (last_mouse_pos) is grid-relative, and the viewport edges are
86        // FIXED in that same frame — they don't move when the content scrolls
87        // underneath. So distance-from-edge MUST be measured grid-relative.
88        // Adding the scroll offset here (as this once did) slides the 90 px
89        // trigger bands along with the content: the forward band collapses to
90        // zero the moment any scrolling begins (instant max speed, no staged
91        // acceleration) and the reverse band grows past 90 px and never
92        // fires — so edge-scroll works only before you've scrolled at all.
93        let vw: f32 = bounds.size.width.into();
94        let vh: f32 = bounds.size.height.into();
95        let px: f32 = pos.x.into();
96        let py: f32 = pos.y.into();
97        let right_dist = vw - px;
98        let left_dist = px - state.row_header_width;
99        let bottom_dist = vh - py;
100        let top_dist = py - state.header_height;
101        let mut dx = 0.0_f32;
102        let mut dy = 0.0_f32;
103        if right_dist < 90.0 && right_dist <= left_dist {
104            dx = edge_scroll_speed(right_dist);
105        } else if left_dist < 90.0 {
106            dx = -edge_scroll_speed(left_dist);
107        }
108        if bottom_dist < 90.0 && bottom_dist <= top_dist {
109            dy = edge_scroll_speed(bottom_dist);
110        } else if top_dist < 90.0 {
111            dy = -edge_scroll_speed(top_dist);
112        }
113        if dx == 0.0 && dy == 0.0 {
114            return false;
115        }
116        state.scroll_one_edge_tick(dx, dy);
117        if state.drag_start.is_some() {
118            state.update_drag_from_last();
119        }
120        true
121    }
122
123    #[must_use]
124    pub fn format_current_status(state: &GridState) -> String {
125        let scroll = state.scroll_handle.offset();
126        let (click_col, click_row) = col_row_from_hit(state.click_hit);
127        let (hover_col, hover_row) = col_row_from_hit(state.hover_hit);
128        let mut out = String::new();
129        let _ = write!(
130            out,
131            "Click: {}  Scroll@Click: {}  Cell: {}  |  Cur: {}  Scroll: {}  Over: {}",
132            fmt_point(state.click_pos),
133            fmt_point(state.scroll_at_click),
134            fmt_cr(click_col, click_row),
135            fmt_point(state.last_mouse_pos),
136            fmt_point(Some(scroll)),
137            fmt_cr(hover_col, hover_row),
138        );
139        out
140    }
141
142    fn col_row_from_hit(hit: Option<HitResult>) -> (Option<usize>, Option<usize>) {
143        match hit {
144            Some(HitResult::Cell(r, c)) => (Some(c), Some(r)),
145            Some(HitResult::RowHeader(r)) => (None, Some(r)),
146            Some(HitResult::ColumnHeader(c)) | Some(HitResult::SortButton(c)) => (Some(c), None),
147            _ => (None, None),
148        }
149    }
150
151    fn fmt_point(p: Option<Point<Pixels>>) -> String {
152        match p {
153            Some(p) => format!("({:.0}, {:.0})", f32::from(p.x), f32::from(p.y)),
154            None => "—".into(),
155        }
156    }
157
158    fn fmt_cr(c: Option<usize>, r: Option<usize>) -> String {
159        match (c, r) {
160            (Some(c), Some(r)) => format!("(col {c}, row {r})"),
161            (Some(c), None) => format!("(col {c})"),
162            (None, Some(r)) => format!("(row {r})"),
163            (None, None) => "—".into(),
164        }
165    }
166
167    #[must_use]
168    pub fn cell_text(cell: &CellValue, fmt: &ResolvedColumnFormat) -> String {
169        format_cell(cell, fmt).0
170    }
171}
172
173/// Width, in pixels, of vertical and horizontal scrollbar strips.
174pub const SCROLLBAR_SIZE: f32 = 20.0;
175/// Polling interval used to drive auto-scroll during drag.
176pub const EDGE_SCROLL_TICK_MS: u64 = 16;
177
178/// Windowed-row mode: the grid presents `total_rows` virtual rows (scrollbar,
179/// row numbers, hit-testing, selection all speak the virtual index space)
180/// while `data.rows` holds only a resident window of them starting at
181/// `offset`. The host pages rows in/out with [`GridState::set_row_window`] as
182/// the user scrolls, keeping memory O(window) for arbitrarily large sets.
183/// Sorting and filtering are disabled while a window is active — the grid
184/// only sees a slice of the data, so a resident-only sort would lie.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub struct RowWindow {
187    /// Total rows in the virtual set.
188    pub total_rows: usize,
189    /// Virtual index of `data.rows[0]`.
190    pub offset: usize,
191}
192
193/// Complete grid state owned by a GPUI `Entity<GridState>`.
194#[derive(Debug)]
195pub struct GridState {
196    pub data: GridData,
197    pub config: GridConfig,
198    /// When `Some`, the grid is in windowed-row mode (see [`RowWindow`]).
199    pub window: Option<RowWindow>,
200    /// Cached resolved-format list, kept in sync with `data.columns` and
201    /// `config`. Paint, copy, and filter read this directly instead of
202    /// recomputing per cell.
203    pub resolved_formats: Vec<ResolvedColumnFormat>,
204    /// Arc-wrapped row data so `PaintData::from_state` can clone cheaply
205    /// (O(1)) instead of deep-cloning every cell every frame. Rows are
206    /// immutable after `GridState::new`, so the Arc never needs rebuilding.
207    pub(crate) data_rows: Arc<Vec<Vec<CellValue>>>,
208    pub display_indices: Arc<Vec<usize>>,
209    pub selection: Selection,
210    /// Fixed corner of a keyboard/shift range selection (row, col). Set when a
211    /// single cell is selected; held steady while shift+arrow moves the active
212    /// corner. Mirrors the Swift grid's `ResultGridCellRange.anchor`.
213    pub(crate) range_anchor: Option<(usize, usize)>,
214    /// Moving corner of a keyboard/shift range selection (row, col). Mirrors
215    /// the Swift grid's `ResultGridCellRange.extent`.
216    pub(crate) range_active: Option<(usize, usize)>,
217    pub sort: Option<(usize, SortDirection)>,
218    pub filters: Vec<ColumnFilter>,
219    pub scroll_handle: ScrollHandle,
220    pub focus_handle: FocusHandle,
221    pub bounds: Bounds<Pixels>,
222    pub row_height: f32,
223    pub header_height: f32,
224    pub row_header_width: f32,
225    pub font_size: f32,
226    pub char_width: f32,
227    pub theme: GridTheme,
228    pub is_dragging: bool,
229    pub drag_start: Option<Point<Pixels>>,
230    pub drag_start_hit: Option<HitResult>,
231    pub scroll_at_click: Option<Point<Pixels>>,
232    pub last_mouse_pos: Option<Point<Pixels>>,
233    pub status_bar_height: f32,
234    /// When `true`, the debug status bar is painted at the bottom of the grid
235    /// showing click position, scroll offset, and hovered cell. Off by
236    /// default; enable via [`SqllyDataTableBuilder::debug_bar`] or
237    /// [`GridState::set_debug_bar_enabled`].
238    pub debug_bar_enabled: bool,
239    pub click_pos: Option<Point<Pixels>>,
240    pub click_hit: Option<HitResult>,
241    pub hover_hit: Option<HitResult>,
242    pub resizing_col: Option<usize>,
243    pub resize_start_x: f32,
244    pub resize_start_width: f32,
245    pub context_menu: Option<ContextMenu>,
246    pub filter_panel: Option<FilterPanel>,
247    pub pending_action: Option<(MenuAction, usize)>,
248    pub(crate) pending_custom_context_menu_action: Option<PendingCustomContextMenuAction>,
249    pub(crate) context_menu_provider: Option<ContextMenuProviderHandle>,
250    pub scrollbar_drag: Option<ScrollbarAxis>,
251    pub scrollbar_drag_start_offset: f32,
252    pub scrollbar_drag_start_pos: f32,
253    /// Full window viewport size (updated each paint). Used to position the
254    /// context menu against the window edges so it is never clipped by the
255    /// grid area and flips up only when there is no room below on-screen.
256    pub(crate) window_viewport: Size<Pixels>,
257    /// `true` while a single edge-scroll timer task is running. Guards against
258    /// `render` spawning a new task on every frame/notify during a drag, which
259    /// would stack many concurrent 16 ms loops and multiply the scroll speed.
260    pub(crate) edge_scroll_active: bool,
261    /// Shared, immutable column metadata (index/name/kind) built once in
262    /// `new()`. Cloned (O(1)) into every [`ContextMenuRequest`] so building a
263    /// right-click request never walks the columns.
264    pub(crate) column_meta: Arc<[ColumnContext]>,
265    /// Weak handle to this state's own entity, set in
266    /// [`SqllyDataTableBuilder::build`]. Lets [`GridState::spawn_background`]
267    /// deliver results back to `self` from an async task.
268    pub(crate) self_weak: Option<gpui::WeakEntity<GridState>>,
269    /// When `Some`, a background task is in progress and the widget paints a
270    /// loading overlay. Set by [`GridState::spawn_background`] /
271    /// [`GridState::set_busy`]; cleared on completion.
272    pub(crate) busy: Option<BusyState>,
273}
274
275/// State backing the built-in loading overlay shown while a background task
276/// runs. Construct indirectly via [`GridState::spawn_background`] or set
277/// directly with [`GridState::set_busy`].
278#[derive(Clone, Debug)]
279pub struct BusyState {
280    /// Text shown in the overlay (e.g. `"Exporting…"`).
281    pub label: String,
282    /// Optional determinate progress in `0.0..=1.0`. `None` renders an
283    /// indeterminate animated bar.
284    pub progress: Option<f32>,
285}
286
287/// A minimal single-line text input with a **char-based** cursor (not a byte
288/// offset), so multi-byte input never panics on a grapheme-misaligned insert.
289/// Shared by the filter panel's search box and its operand fields.
290#[derive(Clone, Debug, Default)]
291pub struct TextInput {
292    /// Current text value.
293    pub value: String,
294    /// Cursor position measured in characters from the start.
295    pub cursor_chars: usize,
296}
297
298impl TextInput {
299    fn new(value: String) -> Self {
300        let cursor_chars = value.chars().count();
301        Self {
302            value,
303            cursor_chars,
304        }
305    }
306
307    fn clamp_cursor(&mut self) {
308        let total = self.value.chars().count();
309        if self.cursor_chars > total {
310            self.cursor_chars = total;
311        }
312    }
313
314    fn insert_char(&mut self, ch: char) {
315        let byte_idx = byte_index_for_char(&self.value, self.cursor_chars);
316        self.value.insert(byte_idx, ch);
317        self.cursor_chars += 1;
318    }
319
320    fn backspace(&mut self) {
321        if self.cursor_chars == 0 {
322            return;
323        }
324        let end = byte_index_for_char(&self.value, self.cursor_chars);
325        let start = byte_index_for_char(&self.value, self.cursor_chars - 1);
326        self.value.replace_range(start..end, "");
327        self.cursor_chars -= 1;
328    }
329
330    fn move_left(&mut self) {
331        if self.cursor_chars > 0 {
332            self.cursor_chars -= 1;
333        }
334    }
335
336    fn move_right(&mut self) {
337        self.clamp_cursor();
338        if self.cursor_chars < self.value.chars().count() {
339            self.cursor_chars += 1;
340        }
341    }
342}
343
344/// Which text field inside the filter panel currently receives typed keys.
345#[derive(Clone, Copy, Debug, PartialEq, Eq)]
346pub enum FilterInput {
347    /// The value-list search box.
348    Search,
349    /// The first operator operand (e.g. "greater than X", "between X …").
350    OperandA,
351    /// The second operator operand (the upper bound of a range).
352    OperandB,
353}
354
355/// One row in the filter panel's searchable value checklist.
356#[derive(Clone, Debug)]
357pub struct FilterValueRow {
358    /// The formatted value as displayed in the grid.
359    pub label: String,
360    /// Whether the value is currently included by the filter.
361    pub checked: bool,
362}
363
364/// Interactive state backing the Numbers-style per-column filter popover.
365///
366/// This is the *working* copy that the overlay edits; it is committed to
367/// [`GridState::filters`] automatically (auto-apply) as the user interacts
368/// with the panel. Rendered as a `deferred` + `anchored` GPUI overlay in
369/// `widget.rs`, mirroring the context-menu overlay.
370#[derive(Clone, Debug)]
371pub struct FilterPanel {
372    /// Target column index.
373    pub col: usize,
374    /// Grid-relative anchor point (from the triggering click).
375    pub anchor: Point<Pixels>,
376    /// Column kind; selects the text vs. numeric/date operator set.
377    pub kind: ColumnKind,
378    /// The value-list search box.
379    pub search: TextInput,
380    /// Selected operator index into [`Self::op_labels`]; `0` == "Choose One"
381    /// (no predicate).
382    pub op_index: usize,
383    /// Whether the operator dropdown is expanded.
384    pub op_menu_open: bool,
385    /// First operand input.
386    pub operand_a: TextInput,
387    /// Second operand input (range upper bound).
388    pub operand_b: TextInput,
389    /// Which text field currently has keyboard focus.
390    pub focus: FilterInput,
391    /// When set, edits apply to [`GridState::filters`] immediately.
392    pub auto_apply: bool,
393    /// All distinct formatted values for the column with their checked state.
394    pub distinct: Vec<FilterValueRow>,
395}
396
397/// Operator labels for text/string-like columns. Index `0` is the inert
398/// "Choose One" sentinel; the rest map 1:1 to [`TextOp`] via
399/// [`FilterPanel::text_op_for_index`].
400const TEXT_OP_LABELS: &[&str] = &[
401    "Choose One",
402    "contains",
403    "does not contain",
404    "begins with",
405    "ends with",
406    "is",
407    "is not",
408    "matches (regex)",
409];
410
411/// Operator labels for numeric/date columns. Index `0` is "Choose One".
412const NUMBER_OP_LABELS: &[&str] = &[
413    "Choose One",
414    "equal to",
415    "not equal to",
416    "greater than",
417    "greater than or equal to",
418    "less than",
419    "less than or equal to",
420    "between",
421    "not between",
422];
423
424impl FilterPanel {
425    /// Operator labels appropriate to this column's kind.
426    #[must_use]
427    pub fn op_labels(&self) -> &'static [&'static str] {
428        if uses_number_ops(self.kind) {
429            NUMBER_OP_LABELS
430        } else {
431            TEXT_OP_LABELS
432        }
433    }
434
435    /// The currently selected operator label.
436    #[must_use]
437    pub fn current_op_label(&self) -> &'static str {
438        self.op_labels()
439            .get(self.op_index)
440            .copied()
441            .unwrap_or("Choose One")
442    }
443
444    /// `true` when the selected operator needs at least one operand.
445    #[must_use]
446    pub fn needs_operand(&self) -> bool {
447        self.op_index != 0
448    }
449
450    /// `true` when the selected operator is a range needing a second operand.
451    #[must_use]
452    pub fn needs_second_operand(&self) -> bool {
453        uses_number_ops(self.kind) && matches!(self.op_index, 7 | 8)
454    }
455
456    fn text_op_for_index(index: usize) -> Option<TextOp> {
457        match index {
458            1 => Some(TextOp::Contains),
459            2 => Some(TextOp::DoesNotContain),
460            3 => Some(TextOp::BeginsWith),
461            4 => Some(TextOp::EndsWith),
462            5 => Some(TextOp::Is),
463            6 => Some(TextOp::IsNot),
464            7 => Some(TextOp::Matches),
465            _ => None,
466        }
467    }
468
469    fn number_op_for_index(index: usize) -> Option<NumberOp> {
470        match index {
471            1 => Some(NumberOp::Eq),
472            2 => Some(NumberOp::Ne),
473            3 => Some(NumberOp::Gt),
474            4 => Some(NumberOp::Ge),
475            5 => Some(NumberOp::Lt),
476            6 => Some(NumberOp::Le),
477            7 => Some(NumberOp::Between),
478            8 => Some(NumberOp::NotBetween),
479            _ => None,
480        }
481    }
482
483    fn active_input_mut(&mut self) -> &mut TextInput {
484        match self.focus {
485            FilterInput::Search => &mut self.search,
486            FilterInput::OperandA => &mut self.operand_a,
487            FilterInput::OperandB => &mut self.operand_b,
488        }
489    }
490
491    /// Indices into [`Self::distinct`] whose label matches the current search
492    /// box (case-insensitive substring). Drives only which rows are rendered
493    /// in the checklist; it does not affect the "(Select All)" state.
494    #[must_use]
495    pub fn visible_indices(&self) -> Vec<usize> {
496        let needle = self.search.value.to_lowercase();
497        self.distinct
498            .iter()
499            .enumerate()
500            .filter(|(_, row)| needle.is_empty() || row.label.to_lowercase().contains(&needle))
501            .map(|(i, _)| i)
502            .collect()
503    }
504
505    /// `true` when every distinct value row is checked. Deliberately
506    /// independent of the search box: typing in the search only narrows which
507    /// rows are *displayed*, it must never change the "(Select All)" state.
508    #[must_use]
509    pub fn all_checked(&self) -> bool {
510        !self.distinct.is_empty() && self.distinct.iter().all(|r| r.checked)
511    }
512
513    /// Build the committed [`ColumnFilter`] from the working state. Returns an
514    /// inert filter when no predicate is set and all values are checked.
515    fn to_filter(&self) -> ColumnFilter {
516        let predicate = self.build_predicate();
517        let all_checked = self.distinct.iter().all(|r| r.checked);
518        let values = if all_checked {
519            None
520        } else {
521            Some(
522                self.distinct
523                    .iter()
524                    .filter(|r| r.checked)
525                    .map(|r| r.label.clone())
526                    .collect(),
527            )
528        };
529        ColumnFilter { predicate, values }
530    }
531
532    fn build_predicate(&self) -> FilterPredicate {
533        if self.op_index == 0 {
534            return FilterPredicate::None;
535        }
536        if uses_number_ops(self.kind) {
537            let Some(op) = Self::number_op_for_index(self.op_index) else {
538                return FilterPredicate::None;
539            };
540            let Some(a) = self.parse_number_operand(&self.operand_a.value) else {
541                return FilterPredicate::None;
542            };
543            let b = if self.needs_second_operand() {
544                self.parse_number_operand(&self.operand_b.value)
545                    .unwrap_or(a)
546            } else {
547                a
548            };
549            FilterPredicate::Number { op, a, b }
550        } else {
551            let Some(op) = Self::text_op_for_index(self.op_index) else {
552                return FilterPredicate::None;
553            };
554            FilterPredicate::Text {
555                op,
556                operand: self.operand_a.value.clone(),
557            }
558        }
559    }
560
561    fn parse_number_operand(&self, s: &str) -> Option<f64> {
562        let t = s.trim();
563        if t.is_empty() {
564            return None;
565        }
566        if self.kind == ColumnKind::Date {
567            return parse_ymd_to_unix(t).map(|v| v as f64);
568        }
569        // Tolerate thousands separators pasted from the grid's formatted view.
570        t.replace(',', "").parse::<f64>().ok()
571    }
572}
573
574fn byte_index_for_char(input: &str, char_idx: usize) -> usize {
575    input
576        .char_indices()
577        .nth(char_idx)
578        .map_or(input.len(), |(idx, _)| idx)
579}
580
581/// Derive a panel operator index and its operand strings from an already
582/// committed predicate, so reopening a filter shows the same rule.
583fn seed_operator(kind: ColumnKind, predicate: &FilterPredicate) -> (usize, String, String) {
584    match predicate {
585        FilterPredicate::None => (0, String::new(), String::new()),
586        FilterPredicate::Text { op, operand } => {
587            (text_op_index(*op), operand.clone(), String::new())
588        }
589        FilterPredicate::Number { op, a, b } => {
590            let b_str = if matches!(op, NumberOp::Between | NumberOp::NotBetween) {
591                fmt_number_operand(kind, *b)
592            } else {
593                String::new()
594            };
595            (number_op_index(*op), fmt_number_operand(kind, *a), b_str)
596        }
597    }
598}
599
600fn text_op_index(op: TextOp) -> usize {
601    match op {
602        TextOp::Contains => 1,
603        TextOp::DoesNotContain => 2,
604        TextOp::BeginsWith => 3,
605        TextOp::EndsWith => 4,
606        TextOp::Is => 5,
607        TextOp::IsNot => 6,
608        TextOp::Matches => 7,
609    }
610}
611
612fn number_op_index(op: NumberOp) -> usize {
613    match op {
614        NumberOp::Eq => 1,
615        NumberOp::Ne => 2,
616        NumberOp::Gt => 3,
617        NumberOp::Ge => 4,
618        NumberOp::Lt => 5,
619        NumberOp::Le => 6,
620        NumberOp::Between => 7,
621        NumberOp::NotBetween => 8,
622    }
623}
624
625fn fmt_number_operand(kind: ColumnKind, v: f64) -> String {
626    if kind == ColumnKind::Date {
627        let secs = v as i64;
628        let fmt = crate::config::DateFormat {
629            format: "%Y-%m-%d".into(),
630            ..Default::default()
631        };
632        crate::format::format_date_at(secs, secs, &fmt)
633    } else {
634        // Display prints `50.0` as `50`, so integer operands stay clean.
635        v.to_string()
636    }
637}
638
639impl GridState {
640    #[must_use]
641    pub fn new(data: GridData, config: GridConfig, focus_handle: FocusHandle) -> Self {
642        let resolved_formats = config.resolve_all(&data.columns);
643        let col_count = data.columns.len();
644        let display_indices = Arc::new((0..data.rows.len()).collect::<Vec<_>>());
645        let data_rows = Arc::new(data.rows.clone());
646        let column_meta: Arc<[ColumnContext]> = data
647            .columns
648            .iter()
649            .enumerate()
650            .map(|(index, col)| ColumnContext {
651                index,
652                name: col.name.clone(),
653                kind: col.kind,
654            })
655            .collect();
656        Self {
657            data,
658            config,
659            window: None,
660            resolved_formats,
661            data_rows,
662            display_indices,
663            selection: Selection::None,
664            range_anchor: None,
665            range_active: None,
666            sort: None,
667            filters: vec![ColumnFilter::default(); col_count],
668            scroll_handle: ScrollHandle::new(),
669            focus_handle,
670            bounds: Bounds::default(),
671            row_height: 24.0,
672            header_height: 32.0,
673            row_header_width: 50.0,
674            font_size: 14.0,
675            char_width: 7.6,
676            theme: GridTheme::default(),
677            is_dragging: false,
678            drag_start: None,
679            drag_start_hit: None,
680            scroll_at_click: None,
681            last_mouse_pos: None,
682            status_bar_height: 24.0,
683            debug_bar_enabled: false,
684            click_pos: None,
685            click_hit: None,
686            hover_hit: None,
687            resizing_col: None,
688            resize_start_x: 0.0,
689            resize_start_width: 0.0,
690            context_menu: None,
691            filter_panel: None,
692            pending_action: None,
693            pending_custom_context_menu_action: None,
694            context_menu_provider: None,
695            scrollbar_drag: None,
696            scrollbar_drag_start_offset: 0.0,
697            scrollbar_drag_start_pos: 0.0,
698            window_viewport: Size::default(),
699            edge_scroll_active: false,
700            column_meta,
701            self_weak: None,
702            busy: None,
703        }
704    }
705
706    pub fn set_config(&mut self, config: GridConfig) {
707        self.config = config;
708        self.rebuild_resolved_formats();
709        self.recompute();
710    }
711
712    /// Append rows to the grid in place — the streaming-results fast path.
713    ///
714    /// Rows are validated against the rectangular invariant, then appended to
715    /// the canonical `data.rows`, the paint-path `data_rows` snapshot, and the
716    /// display order. With no active sort or per-column filter this is
717    /// O(new rows): the fresh indices are pushed onto `display_indices`
718    /// directly. When a sort or filter is active, [`GridState::recompute`]
719    /// re-derives the full display order so the new rows land in the right
720    /// place.
721    ///
722    /// The `data_rows` Arc is extended via [`Arc::make_mut`]; a clone only
723    /// occurs if a paint or context-menu snapshot is still alive, so repeated
724    /// appends stay cheap. Selection, scroll position, filters, and sort state
725    /// are untouched.
726    ///
727    /// # Errors
728    ///
729    /// Returns [`GridDataError::RaggedRow`] (with the would-be absolute row
730    /// index) if any incoming row's length differs from the column count; the
731    /// grid is left unmodified in that case.
732    pub fn append_rows(&mut self, rows: Vec<Vec<CellValue>>) -> Result<(), GridDataError> {
733        let expected = self.data.columns.len();
734        let base = self.data.rows.len();
735        for (offset, row) in rows.iter().enumerate() {
736            if row.len() != expected {
737                return Err(GridDataError::RaggedRow {
738                    row_index: base + offset,
739                    expected,
740                    actual: row.len(),
741                });
742            }
743        }
744        if rows.is_empty() {
745            return Ok(());
746        }
747        Arc::make_mut(&mut self.data_rows).extend(rows.iter().cloned());
748        self.data.rows.extend(rows);
749        if self.sort.is_some() || self.filters.iter().any(ColumnFilter::is_active) {
750            self.recompute();
751        } else {
752            Arc::make_mut(&mut self.display_indices).extend(base..self.data.rows.len());
753        }
754        if let Some(window) = &mut self.window {
755            window.total_rows += self.data.rows.len() - base;
756        }
757        Ok(())
758    }
759
760    /// Number of rows the grid PRESENTS — the basis for the scrollbar, row
761    /// numbers, hit-testing, and selection clamping. Equal to the sort/filter
762    /// display order length normally, or the virtual total in windowed mode.
763    #[must_use]
764    pub fn display_row_count(&self) -> usize {
765        self.window
766            .map(|w| w.total_rows)
767            .unwrap_or(self.display_indices.len())
768    }
769
770    /// Maps a display-row index to an index into `data.rows`. Normal mode
771    /// goes through the sort/filter display order; windowed mode subtracts
772    /// the window offset. `None` when the display row is out of range or not
773    /// currently resident (windowed rows that have not been paged in).
774    #[must_use]
775    pub fn resident_row_for_display(&self, display_row: usize) -> Option<usize> {
776        match self.window {
777            Some(w) => display_row
778                .checked_sub(w.offset)
779                .filter(|r| *r < self.data.rows.len() && display_row < w.total_rows),
780            None => self.display_indices.get(display_row).copied(),
781        }
782    }
783
784    /// Enter (or update) windowed-row mode: the grid presents `total_rows`
785    /// virtual rows while holding only `rows` in memory, positioned so that
786    /// `rows[0]` is virtual row `offset`. Replaces the resident rows, the
787    /// paint snapshot, and the display order in one step; selection and
788    /// scroll position (both in virtual space) are untouched. Clears any
789    /// active sort/filter — they are unsupported while windowed.
790    ///
791    /// # Errors
792    ///
793    /// Returns [`GridDataError::RaggedRow`] if any incoming row's length
794    /// differs from the column count; the grid is left unmodified.
795    pub fn set_row_window(
796        &mut self,
797        total_rows: usize,
798        offset: usize,
799        rows: Vec<Vec<CellValue>>,
800    ) -> Result<(), GridDataError> {
801        let expected = self.data.columns.len();
802        for (i, row) in rows.iter().enumerate() {
803            if row.len() != expected {
804                return Err(GridDataError::RaggedRow {
805                    row_index: offset + i,
806                    expected,
807                    actual: row.len(),
808                });
809            }
810        }
811        self.sort = None;
812        for filter in &mut self.filters {
813            *filter = ColumnFilter::default();
814        }
815        self.data_rows = Arc::new(rows.clone());
816        self.display_indices = Arc::new((0..rows.len()).collect());
817        self.data.rows = rows;
818        self.window = Some(RowWindow { total_rows, offset });
819        Ok(())
820    }
821
822    /// The half-open display-row range currently visible in the viewport,
823    /// derived from the scroll offset and painted bounds — the same math the
824    /// paint pass uses. Hosts drive window paging from this: when the range
825    /// nears the resident window's edges, page more rows in via
826    /// [`GridState::set_row_window`]. Returns `(0, 0)` before first paint.
827    #[must_use]
828    pub fn visible_row_range(&self) -> (usize, usize) {
829        let total = self.display_row_count();
830        let sy: f32 = self.scroll_handle.offset().y.into();
831        let vh: f32 = self.bounds.size.height.into();
832        let visible_h = vh - self.header_height;
833        if visible_h <= 0.0 || self.row_height <= 0.0 {
834            return (0, 0);
835        }
836        let first = ((sy / self.row_height) as usize).min(total);
837        let last = (first + (visible_h / self.row_height) as usize + 1).min(total);
838        (first, last)
839    }
840
841    /// Enable or disable the debug status bar at runtime. When enabled, a bar
842    /// is painted at the bottom of the grid showing click position, scroll
843    /// offset, and hovered cell coordinates.
844    pub fn set_debug_bar_enabled(&mut self, enabled: bool) {
845        self.debug_bar_enabled = enabled;
846    }
847
848    /// Whether a background task is currently running (the loading overlay is
849    /// shown).
850    #[must_use]
851    pub fn is_busy(&self) -> bool {
852        self.busy.is_some()
853    }
854
855    /// The current busy state, if any.
856    #[must_use]
857    pub fn busy(&self) -> Option<&BusyState> {
858        self.busy.as_ref()
859    }
860
861    /// Show the loading overlay with the given label and indeterminate
862    /// progress. Call [`GridState::clear_busy`] to hide it. For work that
863    /// should run off the UI thread, prefer [`GridState::spawn_background`],
864    /// which manages this automatically.
865    pub fn set_busy(&mut self, label: impl Into<String>) {
866        self.busy = Some(BusyState {
867            label: label.into(),
868            progress: None,
869        });
870    }
871
872    /// Update the determinate progress (`0.0..=1.0`) of the current busy
873    /// state. No-op if not busy.
874    pub fn set_busy_progress(&mut self, progress: f32) {
875        if let Some(b) = self.busy.as_mut() {
876            b.progress = Some(progress.clamp(0.0, 1.0));
877        }
878    }
879
880    /// Hide the loading overlay.
881    pub fn clear_busy(&mut self) {
882        self.busy = None;
883    }
884
885    /// Run `work` on a background thread, showing the loading overlay labelled
886    /// `label` for the duration, then deliver the result back on the UI thread
887    /// via `on_done` (which receives `&mut GridState` and `&mut App`).
888    ///
889    /// This is the recommended way to do expensive work triggered from a
890    /// context-menu action (e.g. building an export from a large selection):
891    /// the right-click stays instant, the work does not block the UI, and a
892    /// loading indicator is shown until it completes.
893    ///
894    /// `work` and its result `R` must be `Send + 'static`; `on_done` runs on
895    /// the UI thread and need not be `Send`. A cloned [`ContextMenuRequest`]
896    /// can be moved into `work` (it is `Send + Sync + 'static`).
897    ///
898    /// If this state has no entity handle yet (constructed via
899    /// [`GridState::new`] directly, e.g. in tests rather than through the
900    /// builder), `work` runs synchronously as a fallback.
901    pub fn spawn_background<R, W, D>(
902        &mut self,
903        cx: &mut App,
904        label: impl Into<String>,
905        work: W,
906        on_done: D,
907    ) where
908        R: Send + 'static,
909        W: FnOnce() -> R + Send + 'static,
910        D: FnOnce(R, &mut GridState, &mut App) + 'static,
911    {
912        let Some(weak) = self.self_weak.clone() else {
913            // No entity handle: run synchronously so the callback still fires.
914            let result = work();
915            on_done(result, self, cx);
916            return;
917        };
918
919        self.busy = Some(BusyState {
920            label: label.into(),
921            progress: None,
922        });
923
924        let background = cx.background_executor().clone();
925        cx.spawn(async move |cx| {
926            // Paint the overlay before starting the heavy work.
927            let _ = cx.update(|app| {
928                let _ = weak.update(app, |_s, c| c.notify());
929            });
930            let result = background.spawn(async move { work() }).await;
931            let _ = cx.update(|app| {
932                let _ = weak.update(app, |s, c| {
933                    s.busy = None;
934                    on_done(result, s, c);
935                    c.notify();
936                });
937            });
938        })
939        .detach();
940    }
941
942    fn rebuild_resolved_formats(&mut self) {
943        self.resolved_formats = self.config.resolve_all(&self.data.columns);
944    }
945
946    pub fn recompute(&mut self) {
947        // Windowed-row mode: sort/filter are unsupported, the display order
948        // is always the identity over the resident window.
949        if self.window.is_some() {
950            self.display_indices = Arc::new((0..self.data.rows.len()).collect());
951            return;
952        }
953        let mut indices: Vec<usize> = (0..self.data.rows.len())
954            .filter(|&row_idx| {
955                self.data.columns.iter().enumerate().all(|(col_idx, _col)| {
956                    let filter = &self.filters[col_idx];
957                    if !filter.is_active() {
958                        return true;
959                    }
960                    let cell = &self.data.rows[row_idx][col_idx];
961                    cell_passes_filter(cell, &self.resolved_formats[col_idx], filter)
962                })
963            })
964            .collect();
965
966        if let Some((sort_col, direction)) = self.sort {
967            indices.sort_by(|&a, &b| {
968                let cell_a = &self.data.rows[a][sort_col];
969                let cell_b = &self.data.rows[b][sort_col];
970                let ord = compare_cells(cell_a, cell_b);
971                match direction {
972                    SortDirection::Ascending => ord,
973                    SortDirection::Descending => ord.reverse(),
974                }
975            });
976        }
977        self.display_indices = Arc::new(indices);
978    }
979
980    fn content_size(&self) -> (f32, f32) {
981        let cw: f32 = self.data.columns.iter().map(|c| c.width).sum();
982        let ch = self.display_row_count() as f32 * self.row_height;
983        (cw, ch)
984    }
985
986    pub(crate) fn max_scroll(&self) -> (f32, f32) {
987        let (cw, ch) = self.content_size();
988        let (rw, rh) = self.scrollbar_reserved();
989        let vw: f32 = self.bounds.size.width.into();
990        let vh: f32 = self.bounds.size.height.into();
991        let vw = vw - self.row_header_width - rw;
992        let vh = vh - self.header_height - rh;
993        ((cw - vw).max(0.0), (ch - vh).max(0.0))
994    }
995
996    fn scrollbar_reserved(&self) -> (f32, f32) {
997        let (cw, ch) = self.content_size();
998        let vw: f32 = self.bounds.size.width.into();
999        let vh: f32 = self.bounds.size.height.into();
1000        let vw = vw - self.row_header_width;
1001        let vh = vh - self.header_height;
1002        let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
1003        let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
1004        (reserved_w, reserved_h)
1005    }
1006
1007    fn vbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
1008        let (_, ch) = self.content_size();
1009        let (_, rh) = self.scrollbar_reserved();
1010        let vh: f32 = self.bounds.size.height.into();
1011        let vh = vh - self.header_height - rh;
1012        if ch <= vh {
1013            return None;
1014        }
1015        // Grid-relative track geometry (matches the grid-relative mouse coords
1016        // passed to `scroll_to_vbar`).
1017        let sw: f32 = self.bounds.size.width.into();
1018        let sh: f32 = self.bounds.size.height.into();
1019        let track_x = sw - SCROLLBAR_SIZE;
1020        let track_y = self.header_height;
1021        let track_h = sh - self.header_height - rh;
1022        let thumb_h = ((track_h * (vh / ch)).max(20.0)).min(track_h);
1023        Some((track_x, track_y, SCROLLBAR_SIZE, track_h, thumb_h))
1024    }
1025
1026    fn hbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
1027        let (cw, _) = self.content_size();
1028        let (rw, _) = self.scrollbar_reserved();
1029        let vw: f32 = self.bounds.size.width.into();
1030        let vw = vw - self.row_header_width - rw;
1031        if cw <= vw {
1032            return None;
1033        }
1034        // Grid-relative track geometry (matches the grid-relative mouse coords
1035        // passed to `scroll_to_hbar`).
1036        let sw: f32 = self.bounds.size.width.into();
1037        let sh: f32 = self.bounds.size.height.into();
1038        let track_x = self.row_header_width;
1039        let track_y = sh - SCROLLBAR_SIZE;
1040        let track_w = sw - self.row_header_width - rw;
1041        let thumb_w = ((track_w * (vw / cw)).max(20.0)).min(track_w);
1042        Some((track_x, track_y, track_w, SCROLLBAR_SIZE, thumb_w))
1043    }
1044
1045    pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
1046        if let Some((_, track_y, _, track_h, thumb_h)) = self.vbar_geom() {
1047            let (_, max_y) = self.max_scroll();
1048            let range = (track_h - thumb_h).max(0.0);
1049            let rel = (mouse_y - track_y - thumb_h * 0.5).clamp(0.0, range);
1050            let frac = if range > 0.0 { rel / range } else { 0.0 };
1051            let new_y = frac * max_y;
1052            let x = self.scroll_handle.offset().x;
1053            self.scroll_handle.set_offset(Point { x, y: px(new_y) });
1054        }
1055    }
1056
1057    pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
1058        if let Some((track_x, _, track_w, _, thumb_w)) = self.hbar_geom() {
1059            let (max_x, _) = self.max_scroll();
1060            let range = (track_w - thumb_w).max(0.0);
1061            let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
1062            let frac = if range > 0.0 { rel / range } else { 0.0 };
1063            let new_x = frac * max_x;
1064            let y = self.scroll_handle.offset().y;
1065            self.scroll_handle.set_offset(Point { x: px(new_x), y });
1066        }
1067    }
1068
1069    pub(crate) fn scroll_one_edge_tick(&mut self, dx: f32, dy: f32) {
1070        let (mx, my) = self.max_scroll();
1071        let s = self.scroll_handle.offset();
1072        let new_x: f32 = (f32::from(s.x) + dx).clamp(0.0, mx);
1073        let new_y: f32 = (f32::from(s.y) + dy).clamp(0.0, my);
1074        self.scroll_handle.set_offset(Point {
1075            x: px(new_x),
1076            y: px(new_y),
1077        });
1078    }
1079
1080    pub fn toggle_sort(&mut self, col: usize) {
1081        // Sorting is unsupported in windowed-row mode — only a slice of the
1082        // set is resident, so a resident-only sort would present wrong data.
1083        if self.window.is_some() {
1084            return;
1085        }
1086        self.sort = match self.sort {
1087            Some((c, SortDirection::Ascending)) if c == col => {
1088                Some((col, SortDirection::Descending))
1089            }
1090            Some((c, SortDirection::Descending)) if c == col => None,
1091            _ => Some((col, SortDirection::Ascending)),
1092        };
1093        self.recompute();
1094    }
1095
1096    pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
1097        self.handle_mouse_down_with_modifiers(pos, shift, false);
1098    }
1099
1100    pub fn handle_mouse_down_with_modifiers(&mut self, pos: Point<Pixels>, shift: bool, cmd: bool) {
1101        let hit = self.hit_test(pos);
1102        self.click_pos = Some(pos);
1103        self.click_hit = Some(hit);
1104        match hit {
1105            HitResult::VerticalScrollbar => {
1106                self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
1107                self.scroll_to_vbar(f32::from(pos.y));
1108                self.clear_drag();
1109            }
1110            HitResult::HorizontalScrollbar => {
1111                self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
1112                self.scroll_to_hbar(f32::from(pos.x));
1113                self.clear_drag();
1114            }
1115            HitResult::ColumnBorder(col) => {
1116                self.resizing_col = Some(col);
1117                self.resize_start_x = f32::from(pos.x);
1118                self.resize_start_width = self.data.columns[col].width;
1119                self.clear_drag();
1120            }
1121            HitResult::ColumnHeader(col) => {
1122                if cmd {
1123                    // Cmd-click toggles the column in/out of the current
1124                    // column selection set.
1125                    let mut cols: Vec<usize> = match &self.selection {
1126                        Selection::Column(c) => vec![*c],
1127                        Selection::Columns(cs) => cs.clone(),
1128                        _ => Vec::new(),
1129                    };
1130                    if let Some(idx) = cols.iter().position(|&c| c == col) {
1131                        cols.remove(idx);
1132                    } else {
1133                        cols.push(col);
1134                        cols.sort_unstable();
1135                    }
1136                    self.selection = match cols.len() {
1137                        0 => Selection::None,
1138                        1 => Selection::Column(cols[0]),
1139                        _ => Selection::Columns(cols),
1140                    };
1141                    self.clear_drag();
1142                } else {
1143                    self.selection = Selection::Column(col);
1144                    // Dragging across headers extends to a column range.
1145                    self.start_drag(pos);
1146                    self.drag_start_hit = Some(HitResult::ColumnHeader(col));
1147                }
1148            }
1149            HitResult::SortButton(col) => {
1150                // Clicking the sort button only toggles sort; it must not
1151                // change the current selection (the column is not selected).
1152                self.toggle_sort(col);
1153                self.clear_drag();
1154            }
1155            HitResult::ContextMenuItem(_) => {}
1156            HitResult::RowHeader(row) => {
1157                if cmd {
1158                    // Cmd-click toggles the row in/out of the current row
1159                    // selection set.
1160                    let mut rows: Vec<usize> = match &self.selection {
1161                        Selection::Row(r) => vec![*r],
1162                        Selection::RowRange(r1, r2) => (*r1.min(r2)..=*r1.max(r2)).collect(),
1163                        Selection::Rows(rs) => rs.clone(),
1164                        _ => Vec::new(),
1165                    };
1166                    if let Some(idx) = rows.iter().position(|&r| r == row) {
1167                        rows.remove(idx);
1168                    } else {
1169                        rows.push(row);
1170                        rows.sort_unstable();
1171                    }
1172                    self.selection = match rows.len() {
1173                        0 => Selection::None,
1174                        1 => Selection::Row(rows[0]),
1175                        _ => Selection::Rows(rows),
1176                    };
1177                    self.clear_drag();
1178                    return;
1179                }
1180                self.selection = if shift {
1181                    if let Selection::Row(prev) = self.selection {
1182                        let (s, e) = (prev, row);
1183                        Selection::RowRange(s.min(e), s.max(e))
1184                    } else {
1185                        Selection::Row(row)
1186                    }
1187                } else {
1188                    Selection::Row(row)
1189                };
1190                self.start_drag(pos);
1191                self.drag_start_hit = Some(HitResult::RowHeader(row));
1192            }
1193            HitResult::Cell(row, col) => {
1194                if cmd {
1195                    // Cmd-click toggles the individual cell in/out of the
1196                    // current cell selection set.
1197                    let mut cells: Vec<(usize, usize)> = match &self.selection {
1198                        Selection::Cell(r, c) => vec![(*r, *c)],
1199                        Selection::Cells(cs) => cs.clone(),
1200                        _ => Vec::new(),
1201                    };
1202                    if let Some(idx) = cells.iter().position(|&rc| rc == (row, col)) {
1203                        cells.remove(idx);
1204                    } else {
1205                        cells.push((row, col));
1206                        cells.sort_unstable();
1207                    }
1208                    self.selection = match cells.len() {
1209                        0 => Selection::None,
1210                        1 => Selection::Cell(cells[0].0, cells[0].1),
1211                        _ => Selection::Cells(cells),
1212                    };
1213                    self.range_anchor = None;
1214                    self.range_active = None;
1215                    self.clear_drag();
1216                    return;
1217                }
1218                self.selection = if shift {
1219                    // Extend from the existing anchor (Swift: anchor/extent).
1220                    let anchor = self
1221                        .range_anchor
1222                        .or(match self.selection {
1223                            Selection::Cell(pr, pc) => Some((pr, pc)),
1224                            _ => None,
1225                        })
1226                        .unwrap_or((row, col));
1227                    self.range_anchor = Some(anchor);
1228                    self.range_active = Some((row, col));
1229                    Selection::CellRange(
1230                        anchor.0.min(row),
1231                        anchor.1.min(col),
1232                        anchor.0.max(row),
1233                        anchor.1.max(col),
1234                    )
1235                } else {
1236                    self.range_anchor = Some((row, col));
1237                    self.range_active = Some((row, col));
1238                    Selection::Cell(row, col)
1239                };
1240                self.start_drag(pos);
1241                self.drag_start_hit = Some(HitResult::Cell(row, col));
1242            }
1243            HitResult::Corner | HitResult::None => {
1244                self.selection = Selection::None;
1245                self.range_anchor = None;
1246                self.range_active = None;
1247                self.context_menu = None;
1248                self.filter_panel = None;
1249                self.clear_drag();
1250            }
1251        }
1252    }
1253
1254    fn start_drag(&mut self, pos: Point<Pixels>) {
1255        self.is_dragging = false;
1256        self.drag_start = Some(pos);
1257        self.scroll_at_click = Some(self.scroll_handle.offset());
1258        self.last_mouse_pos = Some(pos);
1259    }
1260
1261    pub(crate) fn open_context_menu(&mut self, col: usize, anchor: Point<Pixels>) {
1262        self.context_menu = Some(menu_mod::ContextMenu::standard(col, anchor));
1263        self.filter_panel = None;
1264    }
1265
1266    /// Convert a hit-test result to a context-menu target. Returns `None`
1267    /// for hits that don't map to a meaningful right-click target.
1268    pub(crate) fn context_menu_target_from_hit(&self, hit: HitResult) -> Option<ContextMenuTarget> {
1269        match hit {
1270            HitResult::Cell(row, col) => {
1271                let source_row = self.resident_row_for_display(row).unwrap_or(row);
1272                Some(ContextMenuTarget::Cell {
1273                    display_row_index: row,
1274                    source_row_index: source_row,
1275                    column_index: col,
1276                })
1277            }
1278            HitResult::RowHeader(row) => {
1279                let source_row = self.resident_row_for_display(row).unwrap_or(row);
1280                Some(ContextMenuTarget::RowHeader {
1281                    display_row_index: row,
1282                    source_row_index: source_row,
1283                })
1284            }
1285            HitResult::ColumnHeader(col) => {
1286                Some(ContextMenuTarget::ColumnHeader { column_index: col })
1287            }
1288            HitResult::SortButton(col) => Some(ContextMenuTarget::SortButton { column_index: col }),
1289            _ => None,
1290        }
1291    }
1292
1293    /// Compute the effective selection for a context-menu target. If the
1294    /// target is inside the current selection, the selection is preserved.
1295    /// If outside, the selection collapses to the target. Column-header
1296    /// targets do not change selection.
1297    pub(crate) fn effective_selection_for_context_target(
1298        &self,
1299        target: &ContextMenuTarget,
1300    ) -> Selection {
1301        match target {
1302            ContextMenuTarget::Cell {
1303                display_row_index,
1304                column_index,
1305                ..
1306            } => {
1307                if is_cell_selected(&self.selection, *display_row_index, *column_index) {
1308                    self.selection.clone()
1309                } else {
1310                    Selection::Cell(*display_row_index, *column_index)
1311                }
1312            }
1313            ContextMenuTarget::RowHeader {
1314                display_row_index, ..
1315            } => {
1316                if is_row_selected(&self.selection, *display_row_index) {
1317                    self.selection.clone()
1318                } else {
1319                    Selection::Row(*display_row_index)
1320                }
1321            }
1322            ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. } => {
1323                self.selection.clone()
1324            }
1325        }
1326    }
1327
1328    /// Build a **lazy** snapshot of the right-click context. Construction is
1329    /// O(1): it clamps the selection bounds and clones three shared [`Arc`]
1330    /// handles (row data, display order, column metadata). No per-cell or
1331    /// per-row data is cloned here, so right-clicking a huge selection is
1332    /// instant; the owned snapshots are materialized on demand by
1333    /// [`ContextMenuRequest`]'s accessors (ideally off the UI thread via
1334    /// [`GridState::spawn_background`]).
1335    ///
1336    /// For column-oriented targets (`ColumnHeader`, `SortButton`, or an
1337    /// explicit `Selection::Column`), the request is flagged column-oriented so
1338    /// its row accessors stay empty (`clicked_row()` is `None`).
1339    pub(crate) fn build_context_menu_request(
1340        &self,
1341        target: ContextMenuTarget,
1342        selection: &Selection,
1343    ) -> ContextMenuRequest {
1344        // Windowed-row mode: the request's row data and display order cover
1345        // only the RESIDENT window, so translate the virtual display rows in
1346        // the target and selection into resident space (clamped to the
1347        // window). Right-clicks land on visible — hence resident — rows, so
1348        // this is lossless for the clicked cell; a selection reaching beyond
1349        // the window is clamped to its resident part.
1350        let mut target = target;
1351        let mut selection = selection.clone();
1352        if let Some(w) = self.window {
1353            let resident_last = self.data.rows.len().saturating_sub(1);
1354            let to_resident = |dr: usize| dr.saturating_sub(w.offset).min(resident_last);
1355            target = match target {
1356                ContextMenuTarget::Cell {
1357                    display_row_index,
1358                    source_row_index,
1359                    column_index,
1360                } => ContextMenuTarget::Cell {
1361                    display_row_index: to_resident(display_row_index),
1362                    source_row_index,
1363                    column_index,
1364                },
1365                ContextMenuTarget::RowHeader {
1366                    display_row_index,
1367                    source_row_index,
1368                } => ContextMenuTarget::RowHeader {
1369                    display_row_index: to_resident(display_row_index),
1370                    source_row_index,
1371                },
1372                other => other,
1373            };
1374            selection = match selection {
1375                Selection::Cell(r, c) => Selection::Cell(to_resident(r), c),
1376                Selection::Row(r) => Selection::Row(to_resident(r)),
1377                Selection::CellRange(r1, c1, r2, c2) => {
1378                    Selection::CellRange(to_resident(r1), c1, to_resident(r2), c2)
1379                }
1380                other => other,
1381            };
1382        }
1383        let selection = &selection;
1384
1385        let nrows = self.display_indices.len();
1386        let ncols = self.data.columns.len();
1387
1388        let (r1, c1, r2, c2) = match selection.normalized_bounds() {
1389            Some((r1, c1, r2, c2)) => {
1390                let r1 = r1.min(nrows.saturating_sub(1));
1391                let r2 = r2.min(nrows.saturating_sub(1));
1392                let c1 = c1.min(ncols.saturating_sub(1));
1393                let c2 = c2.min(ncols.saturating_sub(1));
1394                (r1, c1, r2, c2)
1395            }
1396            None => match &target {
1397                ContextMenuTarget::Cell {
1398                    display_row_index,
1399                    column_index,
1400                    ..
1401                } => (
1402                    *display_row_index,
1403                    *column_index,
1404                    *display_row_index,
1405                    *column_index,
1406                ),
1407                ContextMenuTarget::RowHeader {
1408                    display_row_index, ..
1409                } => (
1410                    *display_row_index,
1411                    0,
1412                    *display_row_index,
1413                    ncols.saturating_sub(1),
1414                ),
1415                ContextMenuTarget::ColumnHeader { column_index }
1416                | ContextMenuTarget::SortButton { column_index } => {
1417                    (0, *column_index, nrows.saturating_sub(1), *column_index)
1418                }
1419            },
1420        };
1421
1422        let menu_selection = ContextMenuSelection {
1423            row_start: r1,
1424            row_end: r2,
1425            column_start: c1,
1426            column_end: c2,
1427        };
1428
1429        // A column-oriented right-click (column header, sort button, or an
1430        // explicit whole-column selection) selects cells within one column,
1431        // not whole rows. `clicked_row()` is always `None` for these targets,
1432        // so the request's row accessors stay empty.
1433        let column_oriented =
1434            matches!(
1435                target,
1436                ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. }
1437            ) || matches!(selection, Selection::Column(_) | Selection::Columns(_));
1438
1439        ContextMenuRequest::new(
1440            target,
1441            Some(menu_selection),
1442            Arc::clone(&self.data_rows),
1443            Arc::clone(&self.display_indices),
1444            Arc::clone(&self.column_meta),
1445            column_oriented,
1446        )
1447    }
1448
1449    /// Execute a deferred custom context-menu action by invoking the
1450    /// provider. The provider handle is cloned before the call to avoid
1451    /// `&mut self` borrow conflicts.
1452    pub(crate) fn execute_custom_context_menu_action(
1453        &mut self,
1454        pending: PendingCustomContextMenuAction,
1455        cx: &mut App,
1456    ) {
1457        self.context_menu = None;
1458        self.filter_panel = None;
1459
1460        let Some(provider) = self.context_menu_provider.clone() else {
1461            return;
1462        };
1463
1464        provider.on_action(&pending.id, &pending.request, self, cx);
1465    }
1466
1467    /// Convert public [`ContextMenuItem`]s to internal `MenuItem`s for the
1468    /// rendering pipeline.
1469    pub(crate) fn convert_context_menu_items(items: Vec<ContextMenuItem>) -> Vec<MenuItem> {
1470        items
1471            .into_iter()
1472            .map(|item| match item {
1473                ContextMenuItem::BuiltIn(action) => MenuItem::Action(action),
1474                ContextMenuItem::Action { id, label } => MenuItem::Custom { id, label },
1475                ContextMenuItem::Separator => MenuItem::Separator,
1476            })
1477            .collect()
1478    }
1479
1480    pub fn execute_action(&mut self, action: MenuAction, col: usize, cx: &mut App) {
1481        match action {
1482            MenuAction::SelectColumn => {
1483                self.selection = Selection::Column(col);
1484            }
1485            MenuAction::CopyColumn => {
1486                let text = self.column_text(col);
1487                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1488            }
1489            MenuAction::CopyColumnWithHeaders => {
1490                let mut text = String::new();
1491                text.push_str(&self.data.columns[col].name);
1492                text.push('\n');
1493                text.push_str(&self.column_text(col));
1494                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1495            }
1496            MenuAction::SortAscending => {
1497                self.sort = Some((col, SortDirection::Ascending));
1498                self.recompute();
1499            }
1500            MenuAction::SortDescending => {
1501                self.sort = Some((col, SortDirection::Descending));
1502                self.recompute();
1503            }
1504            MenuAction::ClearSort => {
1505                self.sort = None;
1506                self.recompute();
1507            }
1508            MenuAction::FilterPrompt => {
1509                let anchor = self.context_menu.as_ref().map(|m| m.anchor);
1510                self.open_filter_panel(col, anchor);
1511            }
1512            MenuAction::ClearFilter => {
1513                if col < self.filters.len() {
1514                    self.filters[col] = ColumnFilter::default();
1515                    self.recompute();
1516                }
1517            }
1518        }
1519        self.context_menu = None;
1520    }
1521
1522    /// Open the rich per-column filter popover for `col`, seeding its working
1523    /// state from any filter already committed on that column. The overlay is
1524    /// rendered by `widget.rs` as a `deferred` + `anchored` element so it can
1525    /// paint and receive events outside the grid's own layout bounds, exactly
1526    /// like the right-click context menu.
1527    ///
1528    /// `anchor` overrides the panel's spawn position; pass the original
1529    /// context-menu / header right-click position so the panel doesn't jump to
1530    /// the mouse's current location (which by now has moved to the menu item).
1531    /// Falls back to `last_mouse_pos` when `None`.
1532    pub fn open_filter_panel(&mut self, col: usize, _anchor: Option<Point<Pixels>>) {
1533        if col >= self.data.columns.len() {
1534            return;
1535        }
1536        let sx = f32::from(self.scroll_handle.offset().x);
1537        let col_x = self.row_header_width
1538            + self.data.columns[..col]
1539                .iter()
1540                .map(|c| c.width)
1541                .sum::<f32>()
1542            - sx;
1543        let anchor = Point {
1544            x: px(col_x + self.data.columns[col].width * 0.5),
1545            y: px(0.0),
1546        };
1547        let kind = self.data.columns[col].kind;
1548        let existing = self.filters.get(col).cloned().unwrap_or_default();
1549
1550        // Distinct formatted values in natural cell order, deduped by label.
1551        let distinct = {
1552            let fmt = &self.resolved_formats[col];
1553            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1554            let mut pairs: Vec<(String, &CellValue)> = Vec::new();
1555            for row in &self.data.rows {
1556                let cell = &row[col];
1557                let (label, _) = format_cell(cell, fmt);
1558                if seen.insert(label.clone()) {
1559                    pairs.push((label, cell));
1560                }
1561            }
1562            pairs.sort_by(|(_, a), (_, b)| compare_cells(a, b));
1563            pairs
1564                .into_iter()
1565                .map(|(label, _)| {
1566                    let checked = match &existing.values {
1567                        None => true,
1568                        Some(set) => set.contains(&label),
1569                    };
1570                    FilterValueRow { label, checked }
1571                })
1572                .collect()
1573        };
1574
1575        let (op_index, operand_a, operand_b) = seed_operator(kind, &existing.predicate);
1576
1577        self.context_menu = None;
1578        self.filter_panel = Some(FilterPanel {
1579            col,
1580            anchor,
1581            kind,
1582            search: TextInput::default(),
1583            op_index,
1584            op_menu_open: false,
1585            operand_a: TextInput::new(operand_a),
1586            operand_b: TextInput::new(operand_b),
1587            focus: FilterInput::Search,
1588            auto_apply: true,
1589            distinct,
1590        });
1591    }
1592
1593    /// Commit the panel's working state to [`Self::filters`] and re-filter.
1594    /// Called automatically on every interaction (auto-apply).
1595    pub fn apply_filter_panel(&mut self) {
1596        let Some(panel) = &self.filter_panel else {
1597            return;
1598        };
1599        let col = panel.col;
1600        let filter = panel.to_filter();
1601        if col < self.filters.len() {
1602            self.filters[col] = filter;
1603            self.recompute();
1604        }
1605    }
1606
1607    /// Apply immediately — the panel always auto-applies.
1608    pub fn maybe_auto_apply(&mut self) {
1609        if self.filter_panel.is_some() {
1610            self.apply_filter_panel();
1611        }
1612    }
1613
1614    /// Reset both the committed filter for the panel's column and the panel's
1615    /// working state (all values checked, no operator), then re-filter.
1616    pub fn clear_filter_panel(&mut self) {
1617        let mut target_col = None;
1618        if let Some(panel) = &mut self.filter_panel {
1619            panel.op_index = 0;
1620            panel.op_menu_open = false;
1621            panel.operand_a = TextInput::default();
1622            panel.operand_b = TextInput::default();
1623            panel.search = TextInput::default();
1624            for row in &mut panel.distinct {
1625                row.checked = true;
1626            }
1627            target_col = Some(panel.col);
1628        }
1629        if let Some(col) = target_col {
1630            if col < self.filters.len() {
1631                self.filters[col] = ColumnFilter::default();
1632            }
1633        }
1634        self.recompute();
1635    }
1636
1637    /// Set the sort direction on the panel's column (the panel's Sort buttons).
1638    /// Clicking the already-active direction turns the sort off.
1639    pub fn set_panel_sort(&mut self, direction: SortDirection) {
1640        if let Some(panel) = &self.filter_panel {
1641            let col = panel.col;
1642            self.sort = match self.sort {
1643                Some((c, d)) if c == col && d == direction => None,
1644                _ => Some((col, direction)),
1645            };
1646            self.recompute();
1647        }
1648    }
1649
1650    /// Toggle the checked state of a single distinct value row (by index into
1651    /// [`FilterPanel::distinct`]), then auto-apply if enabled.
1652    pub fn toggle_filter_value(&mut self, index: usize) {
1653        if let Some(panel) = &mut self.filter_panel {
1654            if let Some(row) = panel.distinct.get_mut(index) {
1655                row.checked = !row.checked;
1656            }
1657        }
1658        self.maybe_auto_apply();
1659    }
1660
1661    /// Toggle every distinct value row at once, then auto-apply if enabled.
1662    /// Mirrors the "(Select All)" checkbox. Operates on all values regardless
1663    /// of the active search, so searching never changes what "(Select All)"
1664    /// does.
1665    pub fn toggle_filter_select_all(&mut self) {
1666        if let Some(panel) = &mut self.filter_panel {
1667            let target = !panel.all_checked();
1668            for row in &mut panel.distinct {
1669                row.checked = target;
1670            }
1671        }
1672        self.maybe_auto_apply();
1673    }
1674
1675    /// Select an operator by its index in [`FilterPanel::op_labels`], close the
1676    /// dropdown, and auto-apply if enabled.
1677    pub fn set_filter_operator(&mut self, op_index: usize) {
1678        if let Some(panel) = &mut self.filter_panel {
1679            panel.op_index = op_index;
1680            panel.op_menu_open = false;
1681            if op_index != 0 {
1682                panel.focus = FilterInput::OperandA;
1683            }
1684        }
1685        self.maybe_auto_apply();
1686    }
1687
1688    /// Toggle the operator dropdown's expanded state.
1689    pub fn toggle_filter_op_menu(&mut self) {
1690        if let Some(panel) = &mut self.filter_panel {
1691            panel.op_menu_open = !panel.op_menu_open;
1692        }
1693    }
1694
1695    /// Point keyboard focus at one of the panel's text fields.
1696    pub fn set_filter_focus(&mut self, focus: FilterInput) {
1697        if let Some(panel) = &mut self.filter_panel {
1698            panel.focus = focus;
1699        }
1700    }
1701
1702    /// Toggle the panel's auto-apply flag; kept for API completeness.
1703    pub fn toggle_filter_auto_apply(&mut self) {
1704        if let Some(panel) = &mut self.filter_panel {
1705            panel.auto_apply = !panel.auto_apply;
1706        }
1707        self.maybe_auto_apply();
1708    }
1709
1710    fn column_text(&self, col: usize) -> String {
1711        let mut text = String::new();
1712        let fmt = &self.resolved_formats[col];
1713        for &row_idx in self.display_indices.iter() {
1714            let cell = &self.data.rows[row_idx][col];
1715            let (s, _) = format_cell(cell, fmt);
1716            text.push_str(&s);
1717            text.push('\n');
1718        }
1719        text
1720    }
1721
1722    fn clear_drag(&mut self) {
1723        self.is_dragging = false;
1724        self.drag_start = None;
1725        self.drag_start_hit = None;
1726        self.scroll_at_click = None;
1727    }
1728
1729    fn drag_world_corners(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1730        let start = self.drag_start?;
1731        let mouse = self.last_mouse_pos?;
1732        let click_scroll = self
1733            .scroll_at_click
1734            .unwrap_or_else(|| self.scroll_handle.offset());
1735        let scroll = self.scroll_handle.offset();
1736        let sx_click: f32 = click_scroll.x.into();
1737        let sy_click: f32 = click_scroll.y.into();
1738        let sx: f32 = scroll.x.into();
1739        let sy: f32 = scroll.y.into();
1740        let sx0: f32 = start.x.into();
1741        let sy0: f32 = start.y.into();
1742        let mx: f32 = mouse.x.into();
1743        let my: f32 = mouse.y.into();
1744        let start_world = Point {
1745            x: px(sx0 + sx_click),
1746            y: px(sy0 + sy_click),
1747        };
1748        let end_world = Point {
1749            x: px(mx + sx),
1750            y: px(my + sy),
1751        };
1752        Some((start_world, end_world))
1753    }
1754
1755    pub fn drag_screen_rect(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1756        if !self.is_dragging {
1757            return None;
1758        }
1759        let (start_world, end_world) = self.drag_world_corners()?;
1760        let scroll = self.scroll_handle.offset();
1761        let sx: f32 = scroll.x.into();
1762        let sy: f32 = scroll.y.into();
1763        let start_screen = Point {
1764            x: px(f32::from(start_world.x) - sx),
1765            y: px(f32::from(start_world.y) - sy),
1766        };
1767        let end_screen = Point {
1768            x: px(f32::from(end_world.x) - sx),
1769            y: px(f32::from(end_world.y) - sy),
1770        };
1771        Some((start_screen, end_screen))
1772    }
1773
1774    fn update_drag(&mut self) {
1775        let (start_world, end_world) = match self.drag_world_corners() {
1776            Some(c) => c,
1777            None => return,
1778        };
1779        if !self.is_dragging {
1780            let dx = f32::from(end_world.x) - f32::from(start_world.x);
1781            let dy = f32::from(end_world.y) - f32::from(start_world.y);
1782            if dx * dx + dy * dy <= 400.0 {
1783                return;
1784            }
1785            self.is_dragging = true;
1786        }
1787        let r1 = match self.drag_start_hit {
1788            Some(h) => h,
1789            None => return,
1790        };
1791        // `end_world` is already grid-relative + scroll (content space), since
1792        // `drag_start`/`last_mouse_pos` are stored grid-relative. Feed it
1793        // straight into content hit-testing with a zero scroll delta.
1794        let r2 = self.hit_test_content(f32::from(end_world.x), f32::from(end_world.y), 0.0, 0.0);
1795        match (r1, r2) {
1796            (HitResult::Cell(r1c, c1), HitResult::Cell(r2c, c2)) => {
1797                self.selection =
1798                    Selection::CellRange(r1c.min(r2c), c1.min(c2), r1c.max(r2c), c1.max(c2));
1799            }
1800            (HitResult::RowHeader(r1r), HitResult::RowHeader(r2r)) => {
1801                self.selection = Selection::RowRange(r1r.min(r2r), r1r.max(r2r));
1802            }
1803            (
1804                HitResult::ColumnHeader(c1),
1805                HitResult::ColumnHeader(c2)
1806                | HitResult::SortButton(c2)
1807                | HitResult::ColumnBorder(c2),
1808            ) => {
1809                self.selection = if c1 == c2 {
1810                    Selection::Column(c1)
1811                } else {
1812                    Selection::Columns((c1.min(c2)..=c1.max(c2)).collect())
1813                };
1814            }
1815            _ => {}
1816        }
1817    }
1818
1819    fn update_drag_from_last(&mut self) {
1820        self.update_drag();
1821    }
1822
1823    pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, pressed_button: Option<MouseButton>) {
1824        if self.is_dragging && pressed_button != Some(MouseButton::Left) {
1825            self.handle_mouse_up();
1826            return;
1827        }
1828        if let Some(col) = self.resizing_col {
1829            if pressed_button != Some(MouseButton::Left) {
1830                self.resizing_col = None;
1831                return;
1832            }
1833            let new_w =
1834                (self.resize_start_width + (f32::from(pos.x) - self.resize_start_x)).max(40.0);
1835            self.data.columns[col].width = new_w;
1836            return;
1837        }
1838        if let Some(axis) = self.scrollbar_drag {
1839            if pressed_button != Some(MouseButton::Left) {
1840                self.scrollbar_drag = None;
1841                return;
1842            }
1843            match axis {
1844                ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
1845                ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
1846            }
1847            self.last_mouse_pos = Some(pos);
1848            return;
1849        }
1850        self.last_mouse_pos = Some(pos);
1851        if self.context_menu.is_some() {
1852            // A menu is open. Hover highlighting is driven by the deferred
1853            // overlay's per-item `on_mouse_move` handlers (widget.rs), which
1854            // work even when the pointer is outside the grid's layout bounds.
1855            // Don't run grid hit-testing or drag logic underneath the menu.
1856            return;
1857        }
1858        self.hover_hit = Some(self.hit_test(pos));
1859        if self.drag_start.is_none() {
1860            return;
1861        }
1862        self.update_drag();
1863    }
1864
1865    pub fn handle_scroll_drag(&mut self) {
1866        if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
1867            self.update_drag();
1868        }
1869    }
1870
1871    pub fn handle_mouse_up(&mut self) {
1872        self.resizing_col = None;
1873        self.scrollbar_drag = None;
1874        self.clear_drag();
1875    }
1876
1877    pub fn apply_edge_scroll(&mut self) -> bool {
1878        apply_edge_scroll(self)
1879    }
1880
1881    pub fn select_all(&mut self) {
1882        let nrows = self.display_row_count();
1883        let ncols = self.data.columns.len();
1884        if nrows > 0 && ncols > 0 {
1885            self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
1886        }
1887    }
1888
1889    /// Display-row indices of fully selected rows, sorted ascending and
1890    /// clamped to the current row count. Empty when the selection is not
1891    /// row-oriented.
1892    #[must_use]
1893    pub fn selected_rows(&self) -> Vec<usize> {
1894        let nrows = self.display_row_count();
1895        match &self.selection {
1896            Selection::Row(r) if *r < nrows => vec![*r],
1897            Selection::RowRange(r1, r2) => {
1898                (*r1.min(r2)..=*r1.max(r2)).filter(|&r| r < nrows).collect()
1899            }
1900            Selection::Rows(rows) => rows.iter().copied().filter(|&r| r < nrows).collect(),
1901            _ => Vec::new(),
1902        }
1903    }
1904
1905    /// Column indices of fully selected columns, sorted ascending and clamped
1906    /// to the current column count. Empty when the selection is not
1907    /// column-oriented.
1908    #[must_use]
1909    pub fn selected_columns(&self) -> Vec<usize> {
1910        let ncols = self.data.columns.len();
1911        match &self.selection {
1912            Selection::Column(c) if *c < ncols => vec![*c],
1913            Selection::Columns(cols) => cols.iter().copied().filter(|&c| c < ncols).collect(),
1914            _ => Vec::new(),
1915        }
1916    }
1917
1918    /// Every selected `(display_row, column)` cell, expanded from whatever
1919    /// the current selection variant is (single cell, ranges, whole rows or
1920    /// columns, or discontiguous cmd-click sets), clamped to the current data
1921    /// dimensions. Row-major order.
1922    #[must_use]
1923    pub fn selected_cells(&self) -> Vec<(usize, usize)> {
1924        let nrows = self.display_row_count();
1925        let ncols = self.data.columns.len();
1926        if nrows == 0 || ncols == 0 {
1927            return Vec::new();
1928        }
1929        match &self.selection {
1930            Selection::None => Vec::new(),
1931            Selection::Cell(r, c) if *r < nrows && *c < ncols => vec![(*r, *c)],
1932            Selection::Cell(..) => Vec::new(),
1933            Selection::Cells(cells) => cells
1934                .iter()
1935                .copied()
1936                .filter(|&(r, c)| r < nrows && c < ncols)
1937                .collect(),
1938            Selection::Column(_) | Selection::Columns(_) => {
1939                let cols = self.selected_columns();
1940                (0..nrows)
1941                    .flat_map(|r| cols.iter().map(move |&c| (r, c)))
1942                    .collect()
1943            }
1944            Selection::Row(_) | Selection::RowRange(..) | Selection::Rows(_) => self
1945                .selected_rows()
1946                .into_iter()
1947                .flat_map(|r| (0..ncols).map(move |c| (r, c)))
1948                .collect(),
1949            Selection::CellRange(r1, c1, r2, c2) => {
1950                let (rmin, rmax) = (*r1.min(r2), *r1.max(r2));
1951                let (cmin, cmax) = (*c1.min(c2), *c1.max(c2));
1952                (rmin..=rmax.min(nrows.saturating_sub(1)))
1953                    .flat_map(|r| (cmin..=cmax.min(ncols.saturating_sub(1))).map(move |c| (r, c)))
1954                    .collect()
1955            }
1956        }
1957    }
1958
1959    pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
1960        let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
1961            return;
1962        };
1963        if self.display_row_count() == 0 || self.data.columns.is_empty() {
1964            return;
1965        }
1966        let last_row = self.display_row_count() - 1;
1967        let last_col = self.data.columns.len() - 1;
1968        let r1 = raw_r1.min(last_row);
1969        let r2 = raw_r2.min(last_row);
1970        let c1 = raw_c1.min(last_col);
1971        let c2 = raw_c2.min(last_col);
1972        let mut text = String::new();
1973        if with_headers {
1974            for c in c1..=c2 {
1975                if c > c1 {
1976                    text.push('\t');
1977                }
1978                text.push_str(&self.data.columns[c].name);
1979            }
1980            text.push('\n');
1981        }
1982        for dr in r1..=r2 {
1983            let Some(row_idx) = self.resident_row_for_display(dr) else {
1984                continue;
1985            };
1986            for c in c1..=c2 {
1987                if c > c1 {
1988                    text.push('\t');
1989                }
1990                let cell = &self.data.rows[row_idx][c];
1991                let (s, _) = format_cell(cell, &self.resolved_formats[c]);
1992                text.push_str(&s);
1993            }
1994            text.push('\n');
1995        }
1996        cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1997    }
1998
1999    pub fn page_up(&mut self) {
2000        let vh: f32 = self.bounds.size.height.into();
2001        let rows = ((vh - self.header_height) / self.row_height) as i32;
2002        self.move_selection(0, -rows);
2003    }
2004
2005    pub fn page_down(&mut self) {
2006        let vh: f32 = self.bounds.size.height.into();
2007        let rows = ((vh - self.header_height) / self.row_height) as i32;
2008        self.move_selection(0, rows);
2009    }
2010
2011    pub fn handle_key(&mut self, keystroke: &Keystroke) {
2012        if self.filter_panel.is_some() {
2013            match keystroke.key.as_str() {
2014                "escape" => {
2015                    self.filter_panel = None;
2016                    return;
2017                }
2018                "enter" => {
2019                    self.apply_filter_panel();
2020                    return;
2021                }
2022                _ => {}
2023            }
2024            let mut edited = false;
2025            if let Some(panel) = &mut self.filter_panel {
2026                let input = panel.active_input_mut();
2027                match keystroke.key.as_str() {
2028                    "backspace" => {
2029                        input.backspace();
2030                        edited = true;
2031                    }
2032                    "left" => input.move_left(),
2033                    "right" => input.move_right(),
2034                    _ => {
2035                        if let Some(ch) = keystroke_to_char(keystroke) {
2036                            input.insert_char(ch);
2037                            edited = true;
2038                        }
2039                    }
2040                }
2041            }
2042            // Typing into an operand re-applies live (search only narrows the
2043            // rendered checklist, so re-applying is a harmless no-op there).
2044            if edited {
2045                self.maybe_auto_apply();
2046            }
2047            return;
2048        }
2049        if self.context_menu.is_some() {
2050            if keystroke.key.as_str() == "escape" {
2051                self.context_menu = None;
2052            }
2053            return;
2054        }
2055        let shift = keystroke.modifiers.shift;
2056        match keystroke.key.as_str() {
2057            "up" if shift => self.extend_selection(0, -1),
2058            "down" if shift => self.extend_selection(0, 1),
2059            "left" if shift => self.extend_selection(-1, 0),
2060            "right" if shift => self.extend_selection(1, 0),
2061            "up" => self.move_selection(0, -1),
2062            "down" => self.move_selection(0, 1),
2063            "left" => self.move_selection(-1, 0),
2064            "right" => self.move_selection(1, 0),
2065            "escape" => {
2066                self.selection = Selection::None;
2067                self.range_anchor = None;
2068                self.range_active = None;
2069            }
2070            _ => {}
2071        }
2072    }
2073
2074    fn move_selection(&mut self, dx: i32, dy: i32) {
2075        let nrows = self.display_row_count() as i32;
2076        let ncols = self.data.columns.len() as i32;
2077        if nrows == 0 || ncols == 0 {
2078            return;
2079        }
2080        let last_row = nrows - 1;
2081        let last_col = ncols - 1;
2082        match self.selection {
2083            Selection::Cell(row, col) => {
2084                let nr = (row as i32 + dy).clamp(0, last_row) as usize;
2085                let nc = (col as i32 + dx).clamp(0, last_col) as usize;
2086                self.selection = Selection::Cell(nr, nc);
2087                self.range_anchor = Some((nr, nc));
2088                self.range_active = Some((nr, nc));
2089            }
2090            Selection::Row(row) if dy != 0 => {
2091                let nr = (row as i32 + dy).clamp(0, last_row) as usize;
2092                self.selection = Selection::Row(nr);
2093            }
2094            Selection::Column(col) if dx != 0 => {
2095                let nc = (col as i32 + dx).clamp(0, last_col) as usize;
2096                self.selection = Selection::Column(nc);
2097            }
2098            _ => {
2099                self.selection = Selection::Cell(0, 0);
2100                self.range_anchor = Some((0, 0));
2101                self.range_active = Some((0, 0));
2102            }
2103        }
2104    }
2105
2106    /// Extend a rectangular cell selection by moving the active corner while
2107    /// holding the anchor corner fixed (shift+arrow). Mirrors the Swift grid's
2108    /// anchor/extent range model. Row and column selections are left unchanged.
2109    fn extend_selection(&mut self, dx: i32, dy: i32) {
2110        let nrows = self.display_row_count() as i32;
2111        let ncols = self.data.columns.len() as i32;
2112        if nrows == 0 || ncols == 0 {
2113            return;
2114        }
2115        let last_row = nrows - 1;
2116        let last_col = ncols - 1;
2117
2118        // Seed anchor/active from the current selection when not already set.
2119        if self.range_anchor.is_none() || self.range_active.is_none() {
2120            match self.selection {
2121                Selection::Cell(r, c) => {
2122                    self.range_anchor = Some((r, c));
2123                    self.range_active = Some((r, c));
2124                }
2125                Selection::CellRange(r1, c1, r2, c2) => {
2126                    self.range_anchor = Some((r1, c1));
2127                    self.range_active = Some((r2, c2));
2128                }
2129                _ => {
2130                    self.range_anchor = Some((0, 0));
2131                    self.range_active = Some((0, 0));
2132                    self.selection = Selection::Cell(0, 0);
2133                }
2134            }
2135        }
2136
2137        let anchor = self.range_anchor.unwrap_or((0, 0));
2138        let active = self.range_active.unwrap_or(anchor);
2139        let nr = (active.0 as i32 + dy).clamp(0, last_row) as usize;
2140        let nc = (active.1 as i32 + dx).clamp(0, last_col) as usize;
2141        self.range_active = Some((nr, nc));
2142
2143        self.selection = if (nr, nc) == anchor {
2144            Selection::Cell(nr, nc)
2145        } else {
2146            Selection::CellRange(
2147                anchor.0.min(nr),
2148                anchor.1.min(nc),
2149                anchor.0.max(nr),
2150                anchor.1.max(nc),
2151            )
2152        };
2153    }
2154
2155    pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
2156        let bounds = self.bounds;
2157        let (sx, sy) = (
2158            f32::from(self.scroll_handle.offset().x),
2159            f32::from(self.scroll_handle.offset().y),
2160        );
2161        let bw: f32 = bounds.size.width.into();
2162        let bh: f32 = bounds.size.height.into();
2163        let (mx, my) = self.max_scroll();
2164        if let Some(menu) = &self.context_menu {
2165            let cw = self.char_width;
2166            // `pos` is grid-relative and the menu anchor is stored
2167            // grid-relative, so compare directly — no origin, no scroll.
2168            let x_rel = f32::from(pos.x);
2169            let y_rel = f32::from(pos.y);
2170            if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
2171                return HitResult::ContextMenuItem(idx);
2172            }
2173        }
2174        if my > 0.0
2175            && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
2176            && f32::from(pos.y) >= self.header_height
2177        {
2178            return HitResult::VerticalScrollbar;
2179        }
2180        if mx > 0.0
2181            && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
2182            && f32::from(pos.x) >= self.row_header_width
2183        {
2184            return HitResult::HorizontalScrollbar;
2185        }
2186        // `pos` is grid-relative. `hit_test_content` folds the scroll offset in
2187        // itself for each scrolling region, so pass `pos` directly — NOT
2188        // content-space coordinates, which would double-apply the offset and
2189        // also break the fixed header-region checks (`y < header_height`,
2190        // `x < row_header_width`) that are evaluated in grid-relative space.
2191        let px = f32::from(pos.x);
2192        let py = f32::from(pos.y);
2193        if px < 0.0 || py < 0.0 || px > bw || py > bh {
2194            return HitResult::None;
2195        }
2196        self.hit_test_content(px, py, sx, sy)
2197    }
2198
2199    fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
2200        if y < self.header_height {
2201            if x < self.row_header_width {
2202                return HitResult::Corner;
2203            }
2204            let col_x = x - self.row_header_width + sx;
2205            let mut acc = 0.0;
2206            for (i, col) in self.data.columns.iter().enumerate() {
2207                let right = acc + col.width;
2208                if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
2209                    return HitResult::ColumnBorder(i);
2210                }
2211                if col_x >= acc && col_x < right {
2212                    if col_x >= right - 20.0 {
2213                        return HitResult::SortButton(i);
2214                    }
2215                    return HitResult::ColumnHeader(i);
2216                }
2217                acc = right;
2218            }
2219            return HitResult::None;
2220        }
2221        if x < self.row_header_width {
2222            let row_y = y - self.header_height + sy;
2223            if row_y < 0.0 {
2224                return HitResult::None;
2225            }
2226            let row_idx = (row_y / self.row_height) as usize;
2227            if row_idx < self.display_row_count() {
2228                return HitResult::RowHeader(row_idx);
2229            }
2230            return HitResult::None;
2231        }
2232        let col_x = x - self.row_header_width + sx;
2233        let row_y = y - self.header_height + sy;
2234        if row_y < 0.0 {
2235            return HitResult::None;
2236        }
2237        let row_idx = (row_y / self.row_height) as usize;
2238        if row_idx >= self.display_row_count() {
2239            return HitResult::None;
2240        }
2241        let mut acc = 0.0;
2242        for (i, col) in self.data.columns.iter().enumerate() {
2243            if col_x >= acc && col_x < acc + col.width {
2244                return HitResult::Cell(row_idx, i);
2245            }
2246            acc += col.width;
2247        }
2248        HitResult::None
2249    }
2250
2251    #[must_use]
2252    pub fn wants_edge_scroll_tick(&self) -> bool {
2253        self.is_dragging
2254    }
2255}
2256
2257fn keystroke_to_char(k: &Keystroke) -> Option<char> {
2258    if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
2259        return None;
2260    }
2261    if let Some(key_char) = k.key_char.as_ref() {
2262        return key_char.chars().next();
2263    }
2264    if k.key.chars().count() == 1 {
2265        let c = k.key.chars().next()?;
2266        if k.modifiers.shift {
2267            Some(c.to_ascii_uppercase())
2268        } else {
2269            Some(c)
2270        }
2271    } else {
2272        None
2273    }
2274}
2275
2276#[cfg(test)]
2277#[allow(
2278    clippy::unwrap_used,
2279    clippy::expect_used,
2280    clippy::field_reassign_with_default
2281)]
2282mod tests {
2283    use super::*;
2284    use crate::data::{CellValue, Column, ColumnKind};
2285    use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
2286
2287    fn input_with(text: &str, cursor: usize) -> TextInput {
2288        let mut p = TextInput::new(text.to_owned());
2289        p.cursor_chars = cursor;
2290        p
2291    }
2292
2293    #[test]
2294    fn text_input_new_cursors_at_char_count_not_bytes() {
2295        // "hé🙂" is 3 chars but 7 bytes (h=1, é=2, 🙂=4).
2296        let p = TextInput::new("hé🙂".into());
2297        assert_eq!(p.cursor_chars, 3);
2298        assert_eq!(p.value.len(), 7);
2299    }
2300
2301    #[test]
2302    fn text_input_insert_emoji_at_start_does_not_panic() {
2303        let mut p = input_with("ab", 0);
2304        p.insert_char('\u{1F600}');
2305        assert_eq!(p.value, "\u{1F600}ab");
2306        assert_eq!(p.cursor_chars, 1);
2307    }
2308
2309    #[test]
2310    fn text_input_insert_in_middle_keeps_cursor_at_char_position() {
2311        let mut p = input_with("helloworld", 5);
2312        p.insert_char(' ');
2313        assert_eq!(p.value, "hello world");
2314        assert_eq!(p.cursor_chars, 6);
2315    }
2316
2317    #[test]
2318    fn text_input_backspace_at_zero_is_noop() {
2319        let mut p = input_with("abc", 0);
2320        p.backspace();
2321        assert_eq!(p.value, "abc");
2322        assert_eq!(p.cursor_chars, 0);
2323    }
2324
2325    #[test]
2326    fn text_input_backspace_removes_one_char_value() {
2327        // Cursor sits after "hé" (2 chars); backspace should delete "é" only.
2328        let mut p = input_with("héx", 2);
2329        p.backspace();
2330        assert_eq!(p.value, "hx");
2331        assert_eq!(p.cursor_chars, 1);
2332    }
2333
2334    #[test]
2335    fn text_input_clamp_cursor_pulls_back_past_end() {
2336        let mut p = input_with("abc", 99);
2337        p.clamp_cursor();
2338        assert_eq!(p.cursor_chars, 3);
2339    }
2340
2341    #[test]
2342    fn text_input_move_left_and_right_respect_bounds() {
2343        let mut p = input_with("ab", 2);
2344        p.move_right();
2345        assert_eq!(p.cursor_chars, 2);
2346        p.move_left();
2347        p.move_left();
2348        p.move_left();
2349        assert_eq!(p.cursor_chars, 0);
2350    }
2351
2352    #[test]
2353    fn edge_scroll_speed_stops_outside_band() {
2354        // Outside the 90 px trigger band: no scroll.
2355        assert_eq!(edge_scroll_speed(120.0), 0.0);
2356        assert_eq!(edge_scroll_speed(90.01), 0.0);
2357        // 60 ..= 90 -> 4 px/tick (slowest band).
2358        assert_eq!(edge_scroll_speed(90.0), 4.0);
2359        assert_eq!(edge_scroll_speed(60.0), 4.0);
2360        assert_eq!(edge_scroll_speed(59.99), 8.0);
2361        // 30 ..= 60 -> 8 px/tick.
2362        assert_eq!(edge_scroll_speed(30.0), 8.0);
2363        assert_eq!(edge_scroll_speed(29.99), 16.0);
2364        // < 30 -> 16 px/tick (really fast).
2365        assert_eq!(edge_scroll_speed(0.0), 16.0);
2366        assert_eq!(edge_scroll_speed(29.99), 16.0);
2367    }
2368
2369    #[test]
2370    fn edge_scroll_speed_caps_negative_runaway() {
2371        // Past the edge: saturate at the really-fast speed (16), not higher.
2372        assert_eq!(edge_scroll_speed(-100.0), 16.0);
2373        assert_eq!(edge_scroll_speed(-1000.0), 16.0);
2374    }
2375
2376    /// `GridState` requires a real GPUI `FocusHandle` from
2377    /// `gpui::Application`, but `gpui::Application::new()` panics on any
2378    /// thread other than `main`. Since Rust's test runner executes on a
2379    /// worker pool, the GPUI-backed assertions cannot run alongside pure
2380    /// tests. We mark this test `#[ignore]` so `cargo test` stays green; run
2381    /// it with `cargo test -- --ignored grid_state_behavior_under_application`
2382    /// from the workspace root on the test thread observable to GPUI.
2383    #[allow(clippy::expect_used, clippy::unwrap_used)]
2384    #[test]
2385    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2386    fn grid_state_behavior_under_application() {
2387        gpui::Application::new().run(|cx| {
2388            let focus = cx.focus_handle();
2389
2390            // format_current_status_handles_initial_state
2391            let mut state = GridState::new(
2392                GridData::new(
2393                    vec![Column::new("n", ColumnKind::Integer, 100.0)],
2394                    vec![vec![CellValue::Integer(1)]],
2395                )
2396                .expect("rectangular"),
2397                crate::config::GridConfig::default(),
2398                focus.clone(),
2399            );
2400            let _ = format_current_status(&state);
2401            assert_eq!(state.selection, Selection::None);
2402
2403            // format_current_status_replaces_with_supplied_pos
2404            state.last_mouse_pos = Some(Point {
2405                x: px(120.0),
2406                y: px(80.0),
2407            });
2408            let s = format_current_status(&state);
2409            assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
2410
2411            // recompute_filters_then_sorts_then_clears
2412            let mut state = GridState::new(
2413                GridData::new(
2414                    vec![Column::new("name", ColumnKind::Text, 100.0)],
2415                    vec![
2416                        vec![CellValue::Text("alpha".into())],
2417                        vec![CellValue::Text("beeb".into())],
2418                        vec![CellValue::Text("gamma".into())],
2419                    ],
2420                )
2421                .expect("rectangular"),
2422                crate::config::GridConfig::default(),
2423                focus.clone(),
2424            );
2425            state.filters[0] = ColumnFilter {
2426                predicate: FilterPredicate::Text {
2427                    op: TextOp::Contains,
2428                    operand: "a".into(),
2429                },
2430                values: None,
2431            };
2432            state.toggle_sort(0);
2433            state.recompute();
2434            assert_eq!(state.display_indices.as_slice(), &[0, 2]);
2435            state.toggle_sort(0);
2436            state.recompute();
2437            assert_eq!(state.display_indices.as_slice(), &[2, 0]);
2438            state.filters[0] = ColumnFilter::default();
2439            state.toggle_sort(0);
2440            state.recompute();
2441            assert_eq!(state.display_indices.as_slice(), &[0, 1, 2]);
2442
2443            // toggle_sort_cycles_through_three_states
2444            let mut state = GridState::new(
2445                GridData::new(
2446                    vec![Column::new("v", ColumnKind::Integer, 80.0)],
2447                    vec![vec![CellValue::Integer(1)]],
2448                )
2449                .expect("rectangular"),
2450                crate::config::GridConfig::default(),
2451                focus.clone(),
2452            );
2453            state.toggle_sort(0);
2454            assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
2455            state.toggle_sort(0);
2456            assert_eq!(state.sort, Some((0, SortDirection::Descending)));
2457            state.toggle_sort(0);
2458            assert_eq!(state.sort, None);
2459
2460            // select_all_picks_full_range_when_data_present
2461            let mut state = GridState::new(
2462                GridData::new(
2463                    vec![
2464                        Column::new("a", ColumnKind::Integer, 80.0),
2465                        Column::new("b", ColumnKind::Integer, 80.0),
2466                    ],
2467                    vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
2468                )
2469                .expect("rectangular"),
2470                crate::config::GridConfig::default(),
2471                focus.clone(),
2472            );
2473            state.select_all();
2474            assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
2475
2476            // select_all_is_noop_on_empty
2477            let mut state = GridState::new(
2478                GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
2479                    .expect("rectangular"),
2480                crate::config::GridConfig::default(),
2481                focus.clone(),
2482            );
2483            state.select_all();
2484            assert_eq!(state.selection, Selection::None);
2485
2486            // set_config_refreshes_resolved_formats
2487            let mut state = GridState::new(
2488                GridData::new(
2489                    vec![Column::new("v", ColumnKind::Decimal, 100.0)],
2490                    vec![vec![CellValue::Decimal(1.234)]],
2491                )
2492                .expect("rectangular"),
2493                crate::config::GridConfig::default(),
2494                focus.clone(),
2495            );
2496            assert_eq!(state.resolved_formats[0].number.decimals, 2);
2497            let mut cfg = crate::config::GridConfig::default();
2498            cfg.column_overrides = vec![crate::config::ColumnOverride {
2499                number: Some(crate::config::NumberFormat {
2500                    decimals: 6,
2501                    ..Default::default()
2502                }),
2503                ..Default::default()
2504            }];
2505            state.set_config(cfg);
2506            assert_eq!(state.resolved_formats[0].number.decimals, 6);
2507
2508            // wants_edge_scroll_tick_mirrors_is_dragging
2509            let mut state = GridState::new(
2510                GridData::new(
2511                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
2512                    vec![vec![CellValue::Integer(1)]],
2513                )
2514                .expect("rectangular"),
2515                crate::config::GridConfig::default(),
2516                focus.clone(),
2517            );
2518            assert!(!state.wants_edge_scroll_tick());
2519            state.is_dragging = true;
2520            assert!(state.wants_edge_scroll_tick());
2521
2522            cx.quit();
2523        });
2524    }
2525
2526    #[allow(clippy::expect_used, clippy::unwrap_used)]
2527    #[test]
2528    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2529    fn context_menu_request_construction() {
2530        use crate::grid::context_menu::ContextMenuTarget;
2531
2532        gpui::Application::new().run(|cx| {
2533            let focus = cx.focus_handle();
2534
2535            // 3 rows, 2 columns. Sort descending so display_indices != source.
2536            let mut state = GridState::new(
2537                GridData::new(
2538                    vec![
2539                        Column::new("id", ColumnKind::Integer, 80.0),
2540                        Column::new("name", ColumnKind::Text, 100.0),
2541                    ],
2542                    vec![
2543                        vec![CellValue::Integer(1), CellValue::Text("alpha".into())],
2544                        vec![CellValue::Integer(2), CellValue::Text("beta".into())],
2545                        vec![CellValue::Integer(3), CellValue::Text("gamma".into())],
2546                    ],
2547                )
2548                .expect("rectangular"),
2549                crate::config::GridConfig::default(),
2550                focus.clone(),
2551            );
2552            // Sort descending on column 0: display order is [2, 1, 0].
2553            state.sort = Some((0, SortDirection::Descending));
2554            state.recompute();
2555            assert_eq!(state.display_indices.as_slice(), &[2, 1, 0]);
2556
2557            // Cell target at display row 0 -> source row 2.
2558            let target = ContextMenuTarget::Cell {
2559                display_row_index: 0,
2560                source_row_index: 2,
2561                column_index: 1,
2562            };
2563            let sel = Selection::Cell(0, 1);
2564            let req = state.build_context_menu_request(target, &sel);
2565            assert_eq!(req.target.column_index(), Some(1));
2566            let cells = req.selected_cells();
2567            assert_eq!(cells.len(), 1);
2568            assert_eq!(cells[0].source_row_index, 2);
2569            assert_eq!(cells[0].column_name, "name");
2570            assert_eq!(cells[0].value, CellValue::Text("gamma".into()));
2571            let rows = req.selected_rows();
2572            assert_eq!(rows.len(), 1);
2573            assert_eq!(rows[0].source_row_index, 2);
2574            assert_eq!(rows[0].value_by_name("id"), Some(&CellValue::Integer(3)));
2575
2576            // Cell-range selection (display rows 0-1, cols 0-1).
2577            let target = ContextMenuTarget::Cell {
2578                display_row_index: 0,
2579                source_row_index: 2,
2580                column_index: 0,
2581            };
2582            let sel = Selection::CellRange(0, 0, 1, 1);
2583            let req = state.build_context_menu_request(target, &sel);
2584            assert_eq!(req.selected_cell_count(), 4); // 2 rows x 2 cols
2585            let rows = req.selected_rows();
2586            assert_eq!(rows.len(), 2);
2587            // Display row 0 -> source 2, display row 1 -> source 1.
2588            assert_eq!(rows[0].source_row_index, 2);
2589            assert_eq!(rows[1].source_row_index, 1);
2590
2591            // Row-range selection (display rows 0-2).
2592            let target = ContextMenuTarget::RowHeader {
2593                display_row_index: 1,
2594                source_row_index: 1,
2595            };
2596            let sel = Selection::RowRange(0, 2);
2597            let req = state.build_context_menu_request(target, &sel);
2598            let rows = req.selected_rows();
2599            assert_eq!(rows.len(), 3);
2600            // Each row should have all column values.
2601            assert_eq!(rows[0].values.len(), 2);
2602            assert_eq!(req.selected_cell_count(), 6); // 3 rows x 2 cols
2603
2604            // Column selection (all display rows, column 0). Column-oriented
2605            // targets do not populate `selected_rows` (see doc comment); the
2606            // column's values are exposed via `selected_cells`.
2607            let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2608            let sel = Selection::Column(0);
2609            let req = state.build_context_menu_request(target, &sel);
2610            assert!(req.is_column_oriented());
2611            assert_eq!(req.selected_row_count(), 0);
2612            assert!(req.selected_rows().is_empty());
2613            assert_eq!(req.selected_cells().len(), 3); // 3 rows x 1 col
2614
2615            // Empty data — no panic, empty vectors.
2616            let empty_state = GridState::new(
2617                GridData::new(vec![Column::new("x", ColumnKind::Integer, 80.0)], vec![])
2618                    .expect("rectangular"),
2619                crate::config::GridConfig::default(),
2620                focus.clone(),
2621            );
2622            let target = ContextMenuTarget::Cell {
2623                display_row_index: 0,
2624                source_row_index: 0,
2625                column_index: 0,
2626            };
2627            let req = empty_state.build_context_menu_request(target, &Selection::None);
2628            assert!(req.selected_cells().is_empty());
2629            assert!(req.selected_rows().is_empty());
2630
2631            cx.quit();
2632        });
2633    }
2634
2635    #[allow(clippy::expect_used, clippy::unwrap_used)]
2636    #[test]
2637    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2638    fn effective_selection_for_context_target() {
2639        gpui::Application::new().run(|cx| {
2640            let focus = cx.focus_handle();
2641            let mut state = GridState::new(
2642                GridData::new(
2643                    vec![
2644                        Column::new("a", ColumnKind::Integer, 80.0),
2645                        Column::new("b", ColumnKind::Integer, 80.0),
2646                    ],
2647                    vec![
2648                        vec![CellValue::Integer(1), CellValue::Integer(2)],
2649                        vec![CellValue::Integer(3), CellValue::Integer(4)],
2650                    ],
2651                )
2652                .expect("rectangular"),
2653                crate::config::GridConfig::default(),
2654                focus,
2655            );
2656
2657            // Outside current selection -> collapses to target cell.
2658            state.selection = Selection::Cell(0, 0);
2659            let target = ContextMenuTarget::Cell {
2660                display_row_index: 1,
2661                source_row_index: 1,
2662                column_index: 1,
2663            };
2664            let eff = state.effective_selection_for_context_target(&target);
2665            assert_eq!(eff, Selection::Cell(1, 1));
2666
2667            // Inside current selection -> keeps selection.
2668            state.selection = Selection::CellRange(0, 0, 1, 1);
2669            let target = ContextMenuTarget::Cell {
2670                display_row_index: 1,
2671                source_row_index: 1,
2672                column_index: 1,
2673            };
2674            let eff = state.effective_selection_for_context_target(&target);
2675            assert_eq!(eff, Selection::CellRange(0, 0, 1, 1));
2676
2677            // Row header outside -> collapses to row.
2678            state.selection = Selection::Cell(0, 0);
2679            let target = ContextMenuTarget::RowHeader {
2680                display_row_index: 1,
2681                source_row_index: 1,
2682            };
2683            let eff = state.effective_selection_for_context_target(&target);
2684            assert_eq!(eff, Selection::Row(1));
2685
2686            // Row header inside row range -> keeps range.
2687            state.selection = Selection::RowRange(0, 1);
2688            let target = ContextMenuTarget::RowHeader {
2689                display_row_index: 1,
2690                source_row_index: 1,
2691            };
2692            let eff = state.effective_selection_for_context_target(&target);
2693            assert_eq!(eff, Selection::RowRange(0, 1));
2694
2695            // Column header -> does not change selection.
2696            state.selection = Selection::Cell(1, 1);
2697            let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2698            let eff = state.effective_selection_for_context_target(&target);
2699            assert_eq!(eff, Selection::Cell(1, 1));
2700
2701            cx.quit();
2702        });
2703    }
2704
2705    #[allow(clippy::expect_used, clippy::unwrap_used)]
2706    #[test]
2707    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2708    fn context_menu_target_from_hit_maps_correctly() {
2709        gpui::Application::new().run(|cx| {
2710            let focus = cx.focus_handle();
2711            let state = GridState::new(
2712                GridData::new(
2713                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
2714                    vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(2)]],
2715                )
2716                .expect("rectangular"),
2717                crate::config::GridConfig::default(),
2718                focus,
2719            );
2720
2721            // Cell hit -> Cell target with source mapping.
2722            let t = state
2723                .context_menu_target_from_hit(HitResult::Cell(1, 0))
2724                .unwrap();
2725            assert_eq!(
2726                t,
2727                ContextMenuTarget::Cell {
2728                    display_row_index: 1,
2729                    source_row_index: 1,
2730                    column_index: 0,
2731                }
2732            );
2733
2734            // Row header -> RowHeader target.
2735            let t = state
2736                .context_menu_target_from_hit(HitResult::RowHeader(0))
2737                .unwrap();
2738            assert_eq!(
2739                t,
2740                ContextMenuTarget::RowHeader {
2741                    display_row_index: 0,
2742                    source_row_index: 0,
2743                }
2744            );
2745
2746            // Column header -> ColumnHeader target.
2747            let t = state
2748                .context_menu_target_from_hit(HitResult::ColumnHeader(0))
2749                .unwrap();
2750            assert_eq!(t, ContextMenuTarget::ColumnHeader { column_index: 0 });
2751
2752            // Sort button -> SortButton target.
2753            let t = state
2754                .context_menu_target_from_hit(HitResult::SortButton(0))
2755                .unwrap();
2756            assert_eq!(t, ContextMenuTarget::SortButton { column_index: 0 });
2757
2758            // Unsupported hits -> None.
2759            assert!(state
2760                .context_menu_target_from_hit(HitResult::VerticalScrollbar)
2761                .is_none());
2762            assert!(state
2763                .context_menu_target_from_hit(HitResult::None)
2764                .is_none());
2765
2766            cx.quit();
2767        });
2768    }
2769
2770    #[allow(clippy::expect_used, clippy::unwrap_used)]
2771    #[test]
2772    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2773    fn convert_context_menu_items_maps_variants() {
2774        use crate::grid::context_menu::ContextMenuItem;
2775
2776        let items = vec![
2777            ContextMenuItem::BuiltIn(MenuAction::SortAscending),
2778            ContextMenuItem::action("copy", "Copy value"),
2779            ContextMenuItem::separator(),
2780        ];
2781        let internal = GridState::convert_context_menu_items(items);
2782        assert!(matches!(
2783            internal[0],
2784            MenuItem::Action(MenuAction::SortAscending)
2785        ));
2786        assert!(
2787            matches!(&internal[1], MenuItem::Custom { id, label } if id == "copy" && label == "Copy value")
2788        );
2789        assert!(matches!(internal[2], MenuItem::Separator));
2790    }
2791
2792    #[allow(clippy::expect_used, clippy::unwrap_used)]
2793    #[test]
2794    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2795    fn execute_custom_context_menu_action_invokes_provider() {
2796        use crate::grid::context_menu::{
2797            ContextMenuProvider, ContextMenuProviderHandle, ContextMenuRequest,
2798        };
2799        use std::sync::{Arc, Mutex};
2800
2801        #[derive(Default)]
2802        struct TestProvider {
2803            last_action: Arc<Mutex<Option<String>>>,
2804        }
2805        impl ContextMenuProvider for TestProvider {
2806            fn menu_items(&self, _request: &ContextMenuRequest) -> Vec<ContextMenuItem> {
2807                vec![ContextMenuItem::action("test", "Test")]
2808            }
2809            fn on_action(
2810                &self,
2811                action_id: &str,
2812                _request: &ContextMenuRequest,
2813                _state: &mut GridState,
2814                _cx: &mut gpui::App,
2815            ) {
2816                *self.last_action.lock().unwrap() = Some(action_id.to_string());
2817            }
2818        }
2819
2820        gpui::Application::new().run(|cx| {
2821            let focus = cx.focus_handle();
2822            let mut state = GridState::new(
2823                GridData::new(
2824                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
2825                    vec![vec![CellValue::Integer(1)]],
2826                )
2827                .expect("rectangular"),
2828                crate::config::GridConfig::default(),
2829                focus,
2830            );
2831
2832            let last = Arc::new(Mutex::new(None));
2833            state.context_menu_provider = Some(ContextMenuProviderHandle::new(TestProvider {
2834                last_action: last.clone(),
2835            }));
2836
2837            let target = ContextMenuTarget::Cell {
2838                display_row_index: 0,
2839                source_row_index: 0,
2840                column_index: 0,
2841            };
2842            let request = state.build_context_menu_request(target, &Selection::Cell(0, 0));
2843            state.execute_custom_context_menu_action(
2844                PendingCustomContextMenuAction {
2845                    id: "test".into(),
2846                    request,
2847                },
2848                cx,
2849            );
2850            assert_eq!(*last.lock().unwrap(), Some("test".to_string()));
2851            assert!(state.context_menu.is_none());
2852
2853            cx.quit();
2854        });
2855    }
2856
2857    #[test]
2858    fn filter_panel_to_filter_with_all_checked_has_no_value_set() {
2859        let panel = FilterPanel {
2860            col: 0,
2861            anchor: Point {
2862                x: px(0.0),
2863                y: px(0.0),
2864            },
2865            kind: ColumnKind::Text,
2866            search: TextInput::default(),
2867            op_index: 0,
2868            op_menu_open: false,
2869            operand_a: TextInput::default(),
2870            operand_b: TextInput::default(),
2871            focus: FilterInput::Search,
2872            auto_apply: true,
2873            distinct: vec![
2874                FilterValueRow {
2875                    label: "alpha".into(),
2876                    checked: true,
2877                },
2878                FilterValueRow {
2879                    label: "beta".into(),
2880                    checked: true,
2881                },
2882            ],
2883        };
2884        let f = panel.to_filter();
2885        assert!(f.values.is_none(), "all checked => no value allow-list");
2886        assert!(
2887            !f.is_active(),
2888            "default predicate + all checked => inactive"
2889        );
2890    }
2891
2892    #[test]
2893    fn filter_panel_to_filter_with_unchecked_value_builds_allow_set() {
2894        let panel = FilterPanel {
2895            col: 0,
2896            anchor: Point {
2897                x: px(0.0),
2898                y: px(0.0),
2899            },
2900            kind: ColumnKind::Text,
2901            search: TextInput::default(),
2902            op_index: 0,
2903            op_menu_open: false,
2904            operand_a: TextInput::default(),
2905            operand_b: TextInput::default(),
2906            focus: FilterInput::Search,
2907            auto_apply: true,
2908            distinct: vec![
2909                FilterValueRow {
2910                    label: "alpha".into(),
2911                    checked: true,
2912                },
2913                FilterValueRow {
2914                    label: "beta".into(),
2915                    checked: false,
2916                },
2917            ],
2918        };
2919        let f = panel.to_filter();
2920        assert!(f.is_active(), "unchecked value => active filter");
2921        let set = f.values.expect("should have a value set");
2922        assert!(set.contains("alpha"));
2923        assert!(!set.contains("beta"));
2924    }
2925
2926    #[test]
2927    fn filter_panel_visible_indices_respects_search() {
2928        let panel = FilterPanel {
2929            col: 0,
2930            anchor: Point {
2931                x: px(0.0),
2932                y: px(0.0),
2933            },
2934            kind: ColumnKind::Text,
2935            search: TextInput::new("al".into()),
2936            op_index: 0,
2937            op_menu_open: false,
2938            operand_a: TextInput::default(),
2939            operand_b: TextInput::default(),
2940            focus: FilterInput::Search,
2941            auto_apply: true,
2942            distinct: vec![
2943                FilterValueRow {
2944                    label: "alpha".into(),
2945                    checked: true,
2946                },
2947                FilterValueRow {
2948                    label: "beta".into(),
2949                    checked: true,
2950                },
2951                FilterValueRow {
2952                    label: "gamma".into(),
2953                    checked: true,
2954                },
2955            ],
2956        };
2957        let vis = panel.visible_indices();
2958        assert_eq!(vis, vec![0], "search 'al' matches only alpha");
2959    }
2960
2961    #[test]
2962    fn filter_panel_all_checked_ignores_search() {
2963        let mut panel = FilterPanel {
2964            col: 0,
2965            anchor: Point {
2966                x: px(0.0),
2967                y: px(0.0),
2968            },
2969            kind: ColumnKind::Text,
2970            search: TextInput::new("al".into()),
2971            op_index: 0,
2972            op_menu_open: false,
2973            operand_a: TextInput::default(),
2974            operand_b: TextInput::default(),
2975            focus: FilterInput::Search,
2976            auto_apply: true,
2977            distinct: vec![
2978                FilterValueRow {
2979                    label: "alpha".into(),
2980                    checked: true,
2981                },
2982                FilterValueRow {
2983                    label: "beta".into(),
2984                    checked: false,
2985                },
2986                FilterValueRow {
2987                    label: "gamma".into(),
2988                    checked: true,
2989                },
2990            ],
2991        };
2992        // Even though the search "al" hides beta (unchecked), "(Select All)"
2993        // reflects the GLOBAL checked state, so it must be false.
2994        assert!(
2995            !panel.all_checked(),
2996            "beta is unchecked, so not all values are checked (search is irrelevant)"
2997        );
2998
2999        // A search that matches nothing must not flip "(Select All)".
3000        panel.search = TextInput::new("zzz".into());
3001        for row in &mut panel.distinct {
3002            row.checked = true;
3003        }
3004        assert!(
3005            panel.all_checked(),
3006            "all values checked -> Select All stays checked regardless of empty search"
3007        );
3008    }
3009
3010    #[allow(clippy::expect_used, clippy::unwrap_used)]
3011    #[test]
3012    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
3013    fn filter_panel_open_apply_clear_state_flow() {
3014        gpui::Application::new().run(|cx| {
3015            let focus = cx.focus_handle();
3016            let mut state = GridState::new(
3017                GridData::new(
3018                    vec![Column::new("name", ColumnKind::Text, 100.0)],
3019                    vec![
3020                        vec![CellValue::Text("alpha".into())],
3021                        vec![CellValue::Text("beta".into())],
3022                        vec![CellValue::Text("gamma".into())],
3023                    ],
3024                )
3025                .expect("rectangular"),
3026                crate::config::GridConfig::default(),
3027                focus,
3028            );
3029
3030            // Open filter panel for column 0 with an explicit anchor.
3031            let anchor = Point {
3032                x: px(50.0),
3033                y: px(20.0),
3034            };
3035            state.open_filter_panel(0, Some(anchor));
3036            let panel = state.filter_panel.as_ref().expect("panel should be open");
3037            assert_eq!(panel.col, 0);
3038            assert_eq!(panel.anchor, anchor);
3039            assert_eq!(panel.distinct.len(), 3);
3040            assert!(
3041                panel.distinct.iter().all(|r| r.checked),
3042                "all checked by default"
3043            );
3044            assert!(panel.auto_apply, "auto_apply defaults to true");
3045            assert_eq!(panel.kind, ColumnKind::Text);
3046
3047            // Uncheck "beta" (index 1) and apply.
3048            state.toggle_filter_value(1);
3049            state.apply_filter_panel();
3050            assert_eq!(
3051                state.display_indices.as_slice(),
3052                &[0, 2],
3053                "beta should be filtered out"
3054            );
3055
3056            // Clear the filter panel.
3057            state.clear_filter_panel();
3058            assert_eq!(
3059                state.display_indices.as_slice(),
3060                &[0, 1, 2],
3061                "all rows visible after clear"
3062            );
3063            assert!(
3064                state.filters[0] == ColumnFilter::default(),
3065                "filter reset to default"
3066            );
3067
3068            // Open with a text "contains" predicate.
3069            state.open_filter_panel(0, Some(anchor));
3070            let panel = state.filter_panel.as_mut().expect("panel open");
3071            panel.op_index = 1; // "contains"
3072            panel.operand_a = TextInput::new("a".into());
3073            state.apply_filter_panel();
3074            assert_eq!(
3075                state.display_indices.as_slice(),
3076                &[0, 2],
3077                "contains 'a' matches alpha and gamma"
3078            );
3079
3080            // Clear and verify restored.
3081            state.clear_filter_panel();
3082            assert_eq!(state.display_indices.as_slice(), &[0, 1, 2]);
3083
3084            cx.quit();
3085        });
3086    }
3087}