Skip to main content

sqlly_datatable/grid/
state.rs

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