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