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