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