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