Skip to main content

sqlly_datatable/grid/
state.rs

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