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