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    fn scrollbar_reserved(&self) -> (f32, f32) {
997        let (cw, ch) = self.content_size();
998        let vw: f32 = self.bounds.size.width.into();
999        let vh: f32 = self.bounds.size.height.into();
1000        let vw = vw - self.row_header_width;
1001        let vh = vh - self.header_height;
1002        let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
1003        let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
1004        (reserved_w, reserved_h)
1005    }
1006
1007    fn vbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
1008        let (_, ch) = self.content_size();
1009        let (_, rh) = self.scrollbar_reserved();
1010        let vh: f32 = self.bounds.size.height.into();
1011        let vh = vh - self.header_height - rh;
1012        if ch <= vh {
1013            return None;
1014        }
1015        // Grid-relative track geometry (matches the grid-relative mouse coords
1016        // passed to `scroll_to_vbar`).
1017        let sw: f32 = self.bounds.size.width.into();
1018        let sh: f32 = self.bounds.size.height.into();
1019        let track_x = sw - SCROLLBAR_SIZE;
1020        let track_y = self.header_height;
1021        let track_h = sh - self.header_height - rh;
1022        let thumb_h = ((track_h * (vh / ch)).max(20.0)).min(track_h);
1023        Some((track_x, track_y, SCROLLBAR_SIZE, track_h, thumb_h))
1024    }
1025
1026    fn hbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
1027        let (cw, _) = self.content_size();
1028        let (rw, _) = self.scrollbar_reserved();
1029        let vw: f32 = self.bounds.size.width.into();
1030        let vw = vw - self.row_header_width - rw;
1031        if cw <= vw {
1032            return None;
1033        }
1034        // Grid-relative track geometry (matches the grid-relative mouse coords
1035        // passed to `scroll_to_hbar`).
1036        let sw: f32 = self.bounds.size.width.into();
1037        let sh: f32 = self.bounds.size.height.into();
1038        let track_x = self.row_header_width;
1039        let track_y = sh - SCROLLBAR_SIZE;
1040        let track_w = sw - self.row_header_width - rw;
1041        let thumb_w = ((track_w * (vw / cw)).max(20.0)).min(track_w);
1042        Some((track_x, track_y, track_w, SCROLLBAR_SIZE, thumb_w))
1043    }
1044
1045    pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
1046        if let Some((_, track_y, _, track_h, thumb_h)) = self.vbar_geom() {
1047            let (_, max_y) = self.max_scroll();
1048            let range = (track_h - thumb_h).max(0.0);
1049            let rel = (mouse_y - track_y - thumb_h * 0.5).clamp(0.0, range);
1050            let frac = if range > 0.0 { rel / range } else { 0.0 };
1051            let new_y = frac * max_y;
1052            let x = self.scroll_handle.offset().x;
1053            self.scroll_handle.set_offset(Point { x, y: px(new_y) });
1054        }
1055    }
1056
1057    pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
1058        if let Some((track_x, _, track_w, _, thumb_w)) = self.hbar_geom() {
1059            let (max_x, _) = self.max_scroll();
1060            let range = (track_w - thumb_w).max(0.0);
1061            let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
1062            let frac = if range > 0.0 { rel / range } else { 0.0 };
1063            let new_x = frac * max_x;
1064            let y = self.scroll_handle.offset().y;
1065            self.scroll_handle.set_offset(Point { x: px(new_x), y });
1066        }
1067    }
1068
1069    pub(crate) fn scroll_one_edge_tick(&mut self, dx: f32, dy: f32) {
1070        let (mx, my) = self.max_scroll();
1071        let s = self.scroll_handle.offset();
1072        let new_x: f32 = (f32::from(s.x) + dx).clamp(0.0, mx);
1073        let new_y: f32 = (f32::from(s.y) + dy).clamp(0.0, my);
1074        self.scroll_handle.set_offset(Point {
1075            x: px(new_x),
1076            y: px(new_y),
1077        });
1078    }
1079
1080    pub fn toggle_sort(&mut self, col: usize) {
1081        // Sorting is unsupported in windowed-row mode — only a slice of the
1082        // set is resident, so a resident-only sort would present wrong data.
1083        if self.window.is_some() {
1084            return;
1085        }
1086        self.sort = match self.sort {
1087            Some((c, SortDirection::Ascending)) if c == col => {
1088                Some((col, SortDirection::Descending))
1089            }
1090            Some((c, SortDirection::Descending)) if c == col => None,
1091            _ => Some((col, SortDirection::Ascending)),
1092        };
1093        self.recompute();
1094    }
1095
1096    pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
1097        let hit = self.hit_test(pos);
1098        self.click_pos = Some(pos);
1099        self.click_hit = Some(hit);
1100        match hit {
1101            HitResult::VerticalScrollbar => {
1102                self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
1103                self.scroll_to_vbar(f32::from(pos.y));
1104                self.clear_drag();
1105            }
1106            HitResult::HorizontalScrollbar => {
1107                self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
1108                self.scroll_to_hbar(f32::from(pos.x));
1109                self.clear_drag();
1110            }
1111            HitResult::ColumnBorder(col) => {
1112                self.resizing_col = Some(col);
1113                self.resize_start_x = f32::from(pos.x);
1114                self.resize_start_width = self.data.columns[col].width;
1115                self.clear_drag();
1116            }
1117            HitResult::ColumnHeader(col) => {
1118                self.selection = Selection::Column(col);
1119                self.clear_drag();
1120            }
1121            HitResult::SortButton(col) => {
1122                // Clicking the sort button only toggles sort; it must not
1123                // change the current selection (the column is not selected).
1124                self.toggle_sort(col);
1125                self.clear_drag();
1126            }
1127            HitResult::ContextMenuItem(_) => {}
1128            HitResult::RowHeader(row) => {
1129                self.selection = if shift {
1130                    if let Selection::Row(prev) = self.selection {
1131                        let (s, e) = (prev, row);
1132                        Selection::RowRange(s.min(e), s.max(e))
1133                    } else {
1134                        Selection::Row(row)
1135                    }
1136                } else {
1137                    Selection::Row(row)
1138                };
1139                self.start_drag(pos);
1140                self.drag_start_hit = Some(HitResult::RowHeader(row));
1141            }
1142            HitResult::Cell(row, col) => {
1143                self.selection = if shift {
1144                    // Extend from the existing anchor (Swift: anchor/extent).
1145                    let anchor = self
1146                        .range_anchor
1147                        .or(match self.selection {
1148                            Selection::Cell(pr, pc) => Some((pr, pc)),
1149                            _ => None,
1150                        })
1151                        .unwrap_or((row, col));
1152                    self.range_anchor = Some(anchor);
1153                    self.range_active = Some((row, col));
1154                    Selection::CellRange(
1155                        anchor.0.min(row),
1156                        anchor.1.min(col),
1157                        anchor.0.max(row),
1158                        anchor.1.max(col),
1159                    )
1160                } else {
1161                    self.range_anchor = Some((row, col));
1162                    self.range_active = Some((row, col));
1163                    Selection::Cell(row, col)
1164                };
1165                self.start_drag(pos);
1166                self.drag_start_hit = Some(HitResult::Cell(row, col));
1167            }
1168            HitResult::Corner | HitResult::None => {
1169                self.selection = Selection::None;
1170                self.range_anchor = None;
1171                self.range_active = None;
1172                self.context_menu = None;
1173                self.filter_panel = None;
1174                self.clear_drag();
1175            }
1176        }
1177    }
1178
1179    fn start_drag(&mut self, pos: Point<Pixels>) {
1180        self.is_dragging = false;
1181        self.drag_start = Some(pos);
1182        self.scroll_at_click = Some(self.scroll_handle.offset());
1183        self.last_mouse_pos = Some(pos);
1184    }
1185
1186    pub(crate) fn open_context_menu(&mut self, col: usize, anchor: Point<Pixels>) {
1187        self.context_menu = Some(menu_mod::ContextMenu::standard(col, anchor));
1188        self.filter_panel = None;
1189    }
1190
1191    /// Convert a hit-test result to a context-menu target. Returns `None`
1192    /// for hits that don't map to a meaningful right-click target.
1193    pub(crate) fn context_menu_target_from_hit(&self, hit: HitResult) -> Option<ContextMenuTarget> {
1194        match hit {
1195            HitResult::Cell(row, col) => {
1196                let source_row = self.resident_row_for_display(row).unwrap_or(row);
1197                Some(ContextMenuTarget::Cell {
1198                    display_row_index: row,
1199                    source_row_index: source_row,
1200                    column_index: col,
1201                })
1202            }
1203            HitResult::RowHeader(row) => {
1204                let source_row = self.resident_row_for_display(row).unwrap_or(row);
1205                Some(ContextMenuTarget::RowHeader {
1206                    display_row_index: row,
1207                    source_row_index: source_row,
1208                })
1209            }
1210            HitResult::ColumnHeader(col) => {
1211                Some(ContextMenuTarget::ColumnHeader { column_index: col })
1212            }
1213            HitResult::SortButton(col) => Some(ContextMenuTarget::SortButton { column_index: col }),
1214            _ => None,
1215        }
1216    }
1217
1218    /// Compute the effective selection for a context-menu target. If the
1219    /// target is inside the current selection, the selection is preserved.
1220    /// If outside, the selection collapses to the target. Column-header
1221    /// targets do not change selection.
1222    pub(crate) fn effective_selection_for_context_target(
1223        &self,
1224        target: &ContextMenuTarget,
1225    ) -> Selection {
1226        match target {
1227            ContextMenuTarget::Cell {
1228                display_row_index,
1229                column_index,
1230                ..
1231            } => {
1232                if is_cell_selected(&self.selection, *display_row_index, *column_index) {
1233                    self.selection.clone()
1234                } else {
1235                    Selection::Cell(*display_row_index, *column_index)
1236                }
1237            }
1238            ContextMenuTarget::RowHeader {
1239                display_row_index, ..
1240            } => {
1241                if is_row_selected(&self.selection, *display_row_index) {
1242                    self.selection.clone()
1243                } else {
1244                    Selection::Row(*display_row_index)
1245                }
1246            }
1247            ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. } => {
1248                self.selection.clone()
1249            }
1250        }
1251    }
1252
1253    /// Build a **lazy** snapshot of the right-click context. Construction is
1254    /// O(1): it clamps the selection bounds and clones three shared [`Arc`]
1255    /// handles (row data, display order, column metadata). No per-cell or
1256    /// per-row data is cloned here, so right-clicking a huge selection is
1257    /// instant; the owned snapshots are materialized on demand by
1258    /// [`ContextMenuRequest`]'s accessors (ideally off the UI thread via
1259    /// [`GridState::spawn_background`]).
1260    ///
1261    /// For column-oriented targets (`ColumnHeader`, `SortButton`, or an
1262    /// explicit `Selection::Column`), the request is flagged column-oriented so
1263    /// its row accessors stay empty (`clicked_row()` is `None`).
1264    pub(crate) fn build_context_menu_request(
1265        &self,
1266        target: ContextMenuTarget,
1267        selection: &Selection,
1268    ) -> ContextMenuRequest {
1269        // Windowed-row mode: the request's row data and display order cover
1270        // only the RESIDENT window, so translate the virtual display rows in
1271        // the target and selection into resident space (clamped to the
1272        // window). Right-clicks land on visible — hence resident — rows, so
1273        // this is lossless for the clicked cell; a selection reaching beyond
1274        // the window is clamped to its resident part.
1275        let mut target = target;
1276        let mut selection = selection.clone();
1277        if let Some(w) = self.window {
1278            let resident_last = self.data.rows.len().saturating_sub(1);
1279            let to_resident = |dr: usize| dr.saturating_sub(w.offset).min(resident_last);
1280            target = match target {
1281                ContextMenuTarget::Cell {
1282                    display_row_index,
1283                    source_row_index,
1284                    column_index,
1285                } => ContextMenuTarget::Cell {
1286                    display_row_index: to_resident(display_row_index),
1287                    source_row_index,
1288                    column_index,
1289                },
1290                ContextMenuTarget::RowHeader {
1291                    display_row_index,
1292                    source_row_index,
1293                } => ContextMenuTarget::RowHeader {
1294                    display_row_index: to_resident(display_row_index),
1295                    source_row_index,
1296                },
1297                other => other,
1298            };
1299            selection = match selection {
1300                Selection::Cell(r, c) => Selection::Cell(to_resident(r), c),
1301                Selection::Row(r) => Selection::Row(to_resident(r)),
1302                Selection::CellRange(r1, c1, r2, c2) => {
1303                    Selection::CellRange(to_resident(r1), c1, to_resident(r2), c2)
1304                }
1305                other => other,
1306            };
1307        }
1308        let selection = &selection;
1309
1310        let nrows = self.display_indices.len();
1311        let ncols = self.data.columns.len();
1312
1313        let (r1, c1, r2, c2) = match selection.normalized_bounds() {
1314            Some((r1, c1, r2, c2)) => {
1315                let r1 = r1.min(nrows.saturating_sub(1));
1316                let r2 = r2.min(nrows.saturating_sub(1));
1317                let c1 = c1.min(ncols.saturating_sub(1));
1318                let c2 = c2.min(ncols.saturating_sub(1));
1319                (r1, c1, r2, c2)
1320            }
1321            None => match &target {
1322                ContextMenuTarget::Cell {
1323                    display_row_index,
1324                    column_index,
1325                    ..
1326                } => (
1327                    *display_row_index,
1328                    *column_index,
1329                    *display_row_index,
1330                    *column_index,
1331                ),
1332                ContextMenuTarget::RowHeader {
1333                    display_row_index, ..
1334                } => (
1335                    *display_row_index,
1336                    0,
1337                    *display_row_index,
1338                    ncols.saturating_sub(1),
1339                ),
1340                ContextMenuTarget::ColumnHeader { column_index }
1341                | ContextMenuTarget::SortButton { column_index } => {
1342                    (0, *column_index, nrows.saturating_sub(1), *column_index)
1343                }
1344            },
1345        };
1346
1347        let menu_selection = ContextMenuSelection {
1348            row_start: r1,
1349            row_end: r2,
1350            column_start: c1,
1351            column_end: c2,
1352        };
1353
1354        // A column-oriented right-click (column header, sort button, or an
1355        // explicit whole-column selection) selects cells within one column,
1356        // not whole rows. `clicked_row()` is always `None` for these targets,
1357        // so the request's row accessors stay empty.
1358        let column_oriented = matches!(
1359            target,
1360            ContextMenuTarget::ColumnHeader { .. } | ContextMenuTarget::SortButton { .. }
1361        ) || matches!(selection, Selection::Column(_));
1362
1363        ContextMenuRequest::new(
1364            target,
1365            Some(menu_selection),
1366            Arc::clone(&self.data_rows),
1367            Arc::clone(&self.display_indices),
1368            Arc::clone(&self.column_meta),
1369            column_oriented,
1370        )
1371    }
1372
1373    /// Execute a deferred custom context-menu action by invoking the
1374    /// provider. The provider handle is cloned before the call to avoid
1375    /// `&mut self` borrow conflicts.
1376    pub(crate) fn execute_custom_context_menu_action(
1377        &mut self,
1378        pending: PendingCustomContextMenuAction,
1379        cx: &mut App,
1380    ) {
1381        self.context_menu = None;
1382        self.filter_panel = None;
1383
1384        let Some(provider) = self.context_menu_provider.clone() else {
1385            return;
1386        };
1387
1388        provider.on_action(&pending.id, &pending.request, self, cx);
1389    }
1390
1391    /// Convert public [`ContextMenuItem`]s to internal `MenuItem`s for the
1392    /// rendering pipeline.
1393    pub(crate) fn convert_context_menu_items(items: Vec<ContextMenuItem>) -> Vec<MenuItem> {
1394        items
1395            .into_iter()
1396            .map(|item| match item {
1397                ContextMenuItem::BuiltIn(action) => MenuItem::Action(action),
1398                ContextMenuItem::Action { id, label } => MenuItem::Custom { id, label },
1399                ContextMenuItem::Separator => MenuItem::Separator,
1400            })
1401            .collect()
1402    }
1403
1404    pub fn execute_action(&mut self, action: MenuAction, col: usize, cx: &mut App) {
1405        match action {
1406            MenuAction::SelectColumn => {
1407                self.selection = Selection::Column(col);
1408            }
1409            MenuAction::CopyColumn => {
1410                let text = self.column_text(col);
1411                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1412            }
1413            MenuAction::CopyColumnWithHeaders => {
1414                let mut text = String::new();
1415                text.push_str(&self.data.columns[col].name);
1416                text.push('\n');
1417                text.push_str(&self.column_text(col));
1418                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1419            }
1420            MenuAction::SortAscending => {
1421                self.sort = Some((col, SortDirection::Ascending));
1422                self.recompute();
1423            }
1424            MenuAction::SortDescending => {
1425                self.sort = Some((col, SortDirection::Descending));
1426                self.recompute();
1427            }
1428            MenuAction::ClearSort => {
1429                self.sort = None;
1430                self.recompute();
1431            }
1432            MenuAction::FilterPrompt => {
1433                let anchor = self.context_menu.as_ref().map(|m| m.anchor);
1434                self.open_filter_panel(col, anchor);
1435            }
1436            MenuAction::ClearFilter => {
1437                if col < self.filters.len() {
1438                    self.filters[col] = ColumnFilter::default();
1439                    self.recompute();
1440                }
1441            }
1442        }
1443        self.context_menu = None;
1444    }
1445
1446    /// Open the rich per-column filter popover for `col`, seeding its working
1447    /// state from any filter already committed on that column. The overlay is
1448    /// rendered by `widget.rs` as a `deferred` + `anchored` element so it can
1449    /// paint and receive events outside the grid's own layout bounds, exactly
1450    /// like the right-click context menu.
1451    ///
1452    /// `anchor` overrides the panel's spawn position; pass the original
1453    /// context-menu / header right-click position so the panel doesn't jump to
1454    /// the mouse's current location (which by now has moved to the menu item).
1455    /// Falls back to `last_mouse_pos` when `None`.
1456    pub fn open_filter_panel(&mut self, col: usize, _anchor: Option<Point<Pixels>>) {
1457        if col >= self.data.columns.len() {
1458            return;
1459        }
1460        let sx = f32::from(self.scroll_handle.offset().x);
1461        let col_x = self.row_header_width
1462            + self.data.columns[..col]
1463                .iter()
1464                .map(|c| c.width)
1465                .sum::<f32>()
1466            - sx;
1467        let anchor = Point {
1468            x: px(col_x + self.data.columns[col].width * 0.5),
1469            y: px(0.0),
1470        };
1471        let kind = self.data.columns[col].kind;
1472        let existing = self.filters.get(col).cloned().unwrap_or_default();
1473
1474        // Distinct formatted values in natural cell order, deduped by label.
1475        let distinct = {
1476            let fmt = &self.resolved_formats[col];
1477            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1478            let mut pairs: Vec<(String, &CellValue)> = Vec::new();
1479            for row in &self.data.rows {
1480                let cell = &row[col];
1481                let (label, _) = format_cell(cell, fmt);
1482                if seen.insert(label.clone()) {
1483                    pairs.push((label, cell));
1484                }
1485            }
1486            pairs.sort_by(|(_, a), (_, b)| compare_cells(a, b));
1487            pairs
1488                .into_iter()
1489                .map(|(label, _)| {
1490                    let checked = match &existing.values {
1491                        None => true,
1492                        Some(set) => set.contains(&label),
1493                    };
1494                    FilterValueRow { label, checked }
1495                })
1496                .collect()
1497        };
1498
1499        let (op_index, operand_a, operand_b) = seed_operator(kind, &existing.predicate);
1500
1501        self.context_menu = None;
1502        self.filter_panel = Some(FilterPanel {
1503            col,
1504            anchor,
1505            kind,
1506            search: TextInput::default(),
1507            op_index,
1508            op_menu_open: false,
1509            operand_a: TextInput::new(operand_a),
1510            operand_b: TextInput::new(operand_b),
1511            focus: FilterInput::Search,
1512            auto_apply: true,
1513            distinct,
1514        });
1515    }
1516
1517    /// Commit the panel's working state to [`Self::filters`] and re-filter.
1518    /// Called automatically on every interaction (auto-apply).
1519    pub fn apply_filter_panel(&mut self) {
1520        let Some(panel) = &self.filter_panel else {
1521            return;
1522        };
1523        let col = panel.col;
1524        let filter = panel.to_filter();
1525        if col < self.filters.len() {
1526            self.filters[col] = filter;
1527            self.recompute();
1528        }
1529    }
1530
1531    /// Apply immediately — the panel always auto-applies.
1532    pub fn maybe_auto_apply(&mut self) {
1533        if self.filter_panel.is_some() {
1534            self.apply_filter_panel();
1535        }
1536    }
1537
1538    /// Reset both the committed filter for the panel's column and the panel's
1539    /// working state (all values checked, no operator), then re-filter.
1540    pub fn clear_filter_panel(&mut self) {
1541        let mut target_col = None;
1542        if let Some(panel) = &mut self.filter_panel {
1543            panel.op_index = 0;
1544            panel.op_menu_open = false;
1545            panel.operand_a = TextInput::default();
1546            panel.operand_b = TextInput::default();
1547            panel.search = TextInput::default();
1548            for row in &mut panel.distinct {
1549                row.checked = true;
1550            }
1551            target_col = Some(panel.col);
1552        }
1553        if let Some(col) = target_col {
1554            if col < self.filters.len() {
1555                self.filters[col] = ColumnFilter::default();
1556            }
1557        }
1558        self.recompute();
1559    }
1560
1561    /// Set the sort direction on the panel's column (the panel's Sort buttons).
1562    /// Clicking the already-active direction turns the sort off.
1563    pub fn set_panel_sort(&mut self, direction: SortDirection) {
1564        if let Some(panel) = &self.filter_panel {
1565            let col = panel.col;
1566            self.sort = match self.sort {
1567                Some((c, d)) if c == col && d == direction => None,
1568                _ => Some((col, direction)),
1569            };
1570            self.recompute();
1571        }
1572    }
1573
1574    /// Toggle the checked state of a single distinct value row (by index into
1575    /// [`FilterPanel::distinct`]), then auto-apply if enabled.
1576    pub fn toggle_filter_value(&mut self, index: usize) {
1577        if let Some(panel) = &mut self.filter_panel {
1578            if let Some(row) = panel.distinct.get_mut(index) {
1579                row.checked = !row.checked;
1580            }
1581        }
1582        self.maybe_auto_apply();
1583    }
1584
1585    /// Toggle every distinct value row at once, then auto-apply if enabled.
1586    /// Mirrors the "(Select All)" checkbox. Operates on all values regardless
1587    /// of the active search, so searching never changes what "(Select All)"
1588    /// does.
1589    pub fn toggle_filter_select_all(&mut self) {
1590        if let Some(panel) = &mut self.filter_panel {
1591            let target = !panel.all_checked();
1592            for row in &mut panel.distinct {
1593                row.checked = target;
1594            }
1595        }
1596        self.maybe_auto_apply();
1597    }
1598
1599    /// Select an operator by its index in [`FilterPanel::op_labels`], close the
1600    /// dropdown, and auto-apply if enabled.
1601    pub fn set_filter_operator(&mut self, op_index: usize) {
1602        if let Some(panel) = &mut self.filter_panel {
1603            panel.op_index = op_index;
1604            panel.op_menu_open = false;
1605            if op_index != 0 {
1606                panel.focus = FilterInput::OperandA;
1607            }
1608        }
1609        self.maybe_auto_apply();
1610    }
1611
1612    /// Toggle the operator dropdown's expanded state.
1613    pub fn toggle_filter_op_menu(&mut self) {
1614        if let Some(panel) = &mut self.filter_panel {
1615            panel.op_menu_open = !panel.op_menu_open;
1616        }
1617    }
1618
1619    /// Point keyboard focus at one of the panel's text fields.
1620    pub fn set_filter_focus(&mut self, focus: FilterInput) {
1621        if let Some(panel) = &mut self.filter_panel {
1622            panel.focus = focus;
1623        }
1624    }
1625
1626    /// Toggle the panel's auto-apply flag; kept for API completeness.
1627    pub fn toggle_filter_auto_apply(&mut self) {
1628        if let Some(panel) = &mut self.filter_panel {
1629            panel.auto_apply = !panel.auto_apply;
1630        }
1631        self.maybe_auto_apply();
1632    }
1633
1634    fn column_text(&self, col: usize) -> String {
1635        let mut text = String::new();
1636        let fmt = &self.resolved_formats[col];
1637        for &row_idx in self.display_indices.iter() {
1638            let cell = &self.data.rows[row_idx][col];
1639            let (s, _) = format_cell(cell, fmt);
1640            text.push_str(&s);
1641            text.push('\n');
1642        }
1643        text
1644    }
1645
1646    fn clear_drag(&mut self) {
1647        self.is_dragging = false;
1648        self.drag_start = None;
1649        self.drag_start_hit = None;
1650        self.scroll_at_click = None;
1651    }
1652
1653    fn drag_world_corners(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1654        let start = self.drag_start?;
1655        let mouse = self.last_mouse_pos?;
1656        let click_scroll = self
1657            .scroll_at_click
1658            .unwrap_or_else(|| self.scroll_handle.offset());
1659        let scroll = self.scroll_handle.offset();
1660        let sx_click: f32 = click_scroll.x.into();
1661        let sy_click: f32 = click_scroll.y.into();
1662        let sx: f32 = scroll.x.into();
1663        let sy: f32 = scroll.y.into();
1664        let sx0: f32 = start.x.into();
1665        let sy0: f32 = start.y.into();
1666        let mx: f32 = mouse.x.into();
1667        let my: f32 = mouse.y.into();
1668        let start_world = Point {
1669            x: px(sx0 + sx_click),
1670            y: px(sy0 + sy_click),
1671        };
1672        let end_world = Point {
1673            x: px(mx + sx),
1674            y: px(my + sy),
1675        };
1676        Some((start_world, end_world))
1677    }
1678
1679    pub fn drag_screen_rect(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
1680        if !self.is_dragging {
1681            return None;
1682        }
1683        let (start_world, end_world) = self.drag_world_corners()?;
1684        let scroll = self.scroll_handle.offset();
1685        let sx: f32 = scroll.x.into();
1686        let sy: f32 = scroll.y.into();
1687        let start_screen = Point {
1688            x: px(f32::from(start_world.x) - sx),
1689            y: px(f32::from(start_world.y) - sy),
1690        };
1691        let end_screen = Point {
1692            x: px(f32::from(end_world.x) - sx),
1693            y: px(f32::from(end_world.y) - sy),
1694        };
1695        Some((start_screen, end_screen))
1696    }
1697
1698    fn update_drag(&mut self) {
1699        let (start_world, end_world) = match self.drag_world_corners() {
1700            Some(c) => c,
1701            None => return,
1702        };
1703        if !self.is_dragging {
1704            let dx = f32::from(end_world.x) - f32::from(start_world.x);
1705            let dy = f32::from(end_world.y) - f32::from(start_world.y);
1706            if dx * dx + dy * dy <= 400.0 {
1707                return;
1708            }
1709            self.is_dragging = true;
1710        }
1711        let r1 = match self.drag_start_hit {
1712            Some(h) => h,
1713            None => return,
1714        };
1715        // `end_world` is already grid-relative + scroll (content space), since
1716        // `drag_start`/`last_mouse_pos` are stored grid-relative. Feed it
1717        // straight into content hit-testing with a zero scroll delta.
1718        let r2 = self.hit_test_content(f32::from(end_world.x), f32::from(end_world.y), 0.0, 0.0);
1719        match (r1, r2) {
1720            (HitResult::Cell(r1c, c1), HitResult::Cell(r2c, c2)) => {
1721                self.selection =
1722                    Selection::CellRange(r1c.min(r2c), c1.min(c2), r1c.max(r2c), c1.max(c2));
1723            }
1724            (HitResult::RowHeader(r1r), HitResult::RowHeader(r2r)) => {
1725                self.selection = Selection::RowRange(r1r.min(r2r), r1r.max(r2r));
1726            }
1727            _ => {}
1728        }
1729    }
1730
1731    fn update_drag_from_last(&mut self) {
1732        self.update_drag();
1733    }
1734
1735    pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, pressed_button: Option<MouseButton>) {
1736        if self.is_dragging && pressed_button != Some(MouseButton::Left) {
1737            self.handle_mouse_up();
1738            return;
1739        }
1740        if let Some(col) = self.resizing_col {
1741            if pressed_button != Some(MouseButton::Left) {
1742                self.resizing_col = None;
1743                return;
1744            }
1745            let new_w =
1746                (self.resize_start_width + (f32::from(pos.x) - self.resize_start_x)).max(40.0);
1747            self.data.columns[col].width = new_w;
1748            return;
1749        }
1750        if let Some(axis) = self.scrollbar_drag {
1751            if pressed_button != Some(MouseButton::Left) {
1752                self.scrollbar_drag = None;
1753                return;
1754            }
1755            match axis {
1756                ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
1757                ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
1758            }
1759            self.last_mouse_pos = Some(pos);
1760            return;
1761        }
1762        self.last_mouse_pos = Some(pos);
1763        if self.context_menu.is_some() {
1764            // A menu is open. Hover highlighting is driven by the deferred
1765            // overlay's per-item `on_mouse_move` handlers (widget.rs), which
1766            // work even when the pointer is outside the grid's layout bounds.
1767            // Don't run grid hit-testing or drag logic underneath the menu.
1768            return;
1769        }
1770        self.hover_hit = Some(self.hit_test(pos));
1771        if self.drag_start.is_none() {
1772            return;
1773        }
1774        self.update_drag();
1775    }
1776
1777    pub fn handle_scroll_drag(&mut self) {
1778        if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
1779            self.update_drag();
1780        }
1781    }
1782
1783    pub fn handle_mouse_up(&mut self) {
1784        self.resizing_col = None;
1785        self.scrollbar_drag = None;
1786        self.clear_drag();
1787    }
1788
1789    pub fn apply_edge_scroll(&mut self) -> bool {
1790        apply_edge_scroll(self)
1791    }
1792
1793    pub fn select_all(&mut self) {
1794        let nrows = self.display_row_count();
1795        let ncols = self.data.columns.len();
1796        if nrows > 0 && ncols > 0 {
1797            self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
1798        }
1799    }
1800
1801    pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
1802        let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
1803            return;
1804        };
1805        if self.display_row_count() == 0 || self.data.columns.is_empty() {
1806            return;
1807        }
1808        let last_row = self.display_row_count() - 1;
1809        let last_col = self.data.columns.len() - 1;
1810        let r1 = raw_r1.min(last_row);
1811        let r2 = raw_r2.min(last_row);
1812        let c1 = raw_c1.min(last_col);
1813        let c2 = raw_c2.min(last_col);
1814        let mut text = String::new();
1815        if with_headers {
1816            for c in c1..=c2 {
1817                if c > c1 {
1818                    text.push('\t');
1819                }
1820                text.push_str(&self.data.columns[c].name);
1821            }
1822            text.push('\n');
1823        }
1824        for dr in r1..=r2 {
1825            let Some(row_idx) = self.resident_row_for_display(dr) else {
1826                continue;
1827            };
1828            for c in c1..=c2 {
1829                if c > c1 {
1830                    text.push('\t');
1831                }
1832                let cell = &self.data.rows[row_idx][c];
1833                let (s, _) = format_cell(cell, &self.resolved_formats[c]);
1834                text.push_str(&s);
1835            }
1836            text.push('\n');
1837        }
1838        cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1839    }
1840
1841    pub fn page_up(&mut self) {
1842        let vh: f32 = self.bounds.size.height.into();
1843        let rows = ((vh - self.header_height) / self.row_height) as i32;
1844        self.move_selection(0, -rows);
1845    }
1846
1847    pub fn page_down(&mut self) {
1848        let vh: f32 = self.bounds.size.height.into();
1849        let rows = ((vh - self.header_height) / self.row_height) as i32;
1850        self.move_selection(0, rows);
1851    }
1852
1853    pub fn handle_key(&mut self, keystroke: &Keystroke) {
1854        if self.filter_panel.is_some() {
1855            match keystroke.key.as_str() {
1856                "escape" => {
1857                    self.filter_panel = None;
1858                    return;
1859                }
1860                "enter" => {
1861                    self.apply_filter_panel();
1862                    return;
1863                }
1864                _ => {}
1865            }
1866            let mut edited = false;
1867            if let Some(panel) = &mut self.filter_panel {
1868                let input = panel.active_input_mut();
1869                match keystroke.key.as_str() {
1870                    "backspace" => {
1871                        input.backspace();
1872                        edited = true;
1873                    }
1874                    "left" => input.move_left(),
1875                    "right" => input.move_right(),
1876                    _ => {
1877                        if let Some(ch) = keystroke_to_char(keystroke) {
1878                            input.insert_char(ch);
1879                            edited = true;
1880                        }
1881                    }
1882                }
1883            }
1884            // Typing into an operand re-applies live (search only narrows the
1885            // rendered checklist, so re-applying is a harmless no-op there).
1886            if edited {
1887                self.maybe_auto_apply();
1888            }
1889            return;
1890        }
1891        if self.context_menu.is_some() {
1892            if keystroke.key.as_str() == "escape" {
1893                self.context_menu = None;
1894            }
1895            return;
1896        }
1897        let shift = keystroke.modifiers.shift;
1898        match keystroke.key.as_str() {
1899            "up" if shift => self.extend_selection(0, -1),
1900            "down" if shift => self.extend_selection(0, 1),
1901            "left" if shift => self.extend_selection(-1, 0),
1902            "right" if shift => self.extend_selection(1, 0),
1903            "up" => self.move_selection(0, -1),
1904            "down" => self.move_selection(0, 1),
1905            "left" => self.move_selection(-1, 0),
1906            "right" => self.move_selection(1, 0),
1907            "escape" => {
1908                self.selection = Selection::None;
1909                self.range_anchor = None;
1910                self.range_active = None;
1911            }
1912            _ => {}
1913        }
1914    }
1915
1916    fn move_selection(&mut self, dx: i32, dy: i32) {
1917        let nrows = self.display_row_count() as i32;
1918        let ncols = self.data.columns.len() as i32;
1919        if nrows == 0 || ncols == 0 {
1920            return;
1921        }
1922        let last_row = nrows - 1;
1923        let last_col = ncols - 1;
1924        match self.selection {
1925            Selection::Cell(row, col) => {
1926                let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1927                let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1928                self.selection = Selection::Cell(nr, nc);
1929                self.range_anchor = Some((nr, nc));
1930                self.range_active = Some((nr, nc));
1931            }
1932            Selection::Row(row) if dy != 0 => {
1933                let nr = (row as i32 + dy).clamp(0, last_row) as usize;
1934                self.selection = Selection::Row(nr);
1935            }
1936            Selection::Column(col) if dx != 0 => {
1937                let nc = (col as i32 + dx).clamp(0, last_col) as usize;
1938                self.selection = Selection::Column(nc);
1939            }
1940            _ => {
1941                self.selection = Selection::Cell(0, 0);
1942                self.range_anchor = Some((0, 0));
1943                self.range_active = Some((0, 0));
1944            }
1945        }
1946    }
1947
1948    /// Extend a rectangular cell selection by moving the active corner while
1949    /// holding the anchor corner fixed (shift+arrow). Mirrors the Swift grid's
1950    /// anchor/extent range model. Row and column selections are left unchanged.
1951    fn extend_selection(&mut self, dx: i32, dy: i32) {
1952        let nrows = self.display_row_count() as i32;
1953        let ncols = self.data.columns.len() as i32;
1954        if nrows == 0 || ncols == 0 {
1955            return;
1956        }
1957        let last_row = nrows - 1;
1958        let last_col = ncols - 1;
1959
1960        // Seed anchor/active from the current selection when not already set.
1961        if self.range_anchor.is_none() || self.range_active.is_none() {
1962            match self.selection {
1963                Selection::Cell(r, c) => {
1964                    self.range_anchor = Some((r, c));
1965                    self.range_active = Some((r, c));
1966                }
1967                Selection::CellRange(r1, c1, r2, c2) => {
1968                    self.range_anchor = Some((r1, c1));
1969                    self.range_active = Some((r2, c2));
1970                }
1971                _ => {
1972                    self.range_anchor = Some((0, 0));
1973                    self.range_active = Some((0, 0));
1974                    self.selection = Selection::Cell(0, 0);
1975                }
1976            }
1977        }
1978
1979        let anchor = self.range_anchor.unwrap_or((0, 0));
1980        let active = self.range_active.unwrap_or(anchor);
1981        let nr = (active.0 as i32 + dy).clamp(0, last_row) as usize;
1982        let nc = (active.1 as i32 + dx).clamp(0, last_col) as usize;
1983        self.range_active = Some((nr, nc));
1984
1985        self.selection = if (nr, nc) == anchor {
1986            Selection::Cell(nr, nc)
1987        } else {
1988            Selection::CellRange(
1989                anchor.0.min(nr),
1990                anchor.1.min(nc),
1991                anchor.0.max(nr),
1992                anchor.1.max(nc),
1993            )
1994        };
1995    }
1996
1997    pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
1998        let bounds = self.bounds;
1999        let (sx, sy) = (
2000            f32::from(self.scroll_handle.offset().x),
2001            f32::from(self.scroll_handle.offset().y),
2002        );
2003        let bw: f32 = bounds.size.width.into();
2004        let bh: f32 = bounds.size.height.into();
2005        let (mx, my) = self.max_scroll();
2006        if let Some(menu) = &self.context_menu {
2007            let cw = self.char_width;
2008            // `pos` is grid-relative and the menu anchor is stored
2009            // grid-relative, so compare directly — no origin, no scroll.
2010            let x_rel = f32::from(pos.x);
2011            let y_rel = f32::from(pos.y);
2012            if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
2013                return HitResult::ContextMenuItem(idx);
2014            }
2015        }
2016        if my > 0.0
2017            && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
2018            && f32::from(pos.y) >= self.header_height
2019        {
2020            return HitResult::VerticalScrollbar;
2021        }
2022        if mx > 0.0
2023            && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
2024            && f32::from(pos.x) >= self.row_header_width
2025        {
2026            return HitResult::HorizontalScrollbar;
2027        }
2028        // `pos` is grid-relative. `hit_test_content` folds the scroll offset in
2029        // itself for each scrolling region, so pass `pos` directly — NOT
2030        // content-space coordinates, which would double-apply the offset and
2031        // also break the fixed header-region checks (`y < header_height`,
2032        // `x < row_header_width`) that are evaluated in grid-relative space.
2033        let px = f32::from(pos.x);
2034        let py = f32::from(pos.y);
2035        if px < 0.0 || py < 0.0 || px > bw || py > bh {
2036            return HitResult::None;
2037        }
2038        self.hit_test_content(px, py, sx, sy)
2039    }
2040
2041    fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
2042        if y < self.header_height {
2043            if x < self.row_header_width {
2044                return HitResult::Corner;
2045            }
2046            let col_x = x - self.row_header_width + sx;
2047            let mut acc = 0.0;
2048            for (i, col) in self.data.columns.iter().enumerate() {
2049                let right = acc + col.width;
2050                if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
2051                    return HitResult::ColumnBorder(i);
2052                }
2053                if col_x >= acc && col_x < right {
2054                    if col_x >= right - 20.0 {
2055                        return HitResult::SortButton(i);
2056                    }
2057                    return HitResult::ColumnHeader(i);
2058                }
2059                acc = right;
2060            }
2061            return HitResult::None;
2062        }
2063        if x < self.row_header_width {
2064            let row_y = y - self.header_height + sy;
2065            if row_y < 0.0 {
2066                return HitResult::None;
2067            }
2068            let row_idx = (row_y / self.row_height) as usize;
2069            if row_idx < self.display_row_count() {
2070                return HitResult::RowHeader(row_idx);
2071            }
2072            return HitResult::None;
2073        }
2074        let col_x = x - self.row_header_width + sx;
2075        let row_y = y - self.header_height + sy;
2076        if row_y < 0.0 {
2077            return HitResult::None;
2078        }
2079        let row_idx = (row_y / self.row_height) as usize;
2080        if row_idx >= self.display_row_count() {
2081            return HitResult::None;
2082        }
2083        let mut acc = 0.0;
2084        for (i, col) in self.data.columns.iter().enumerate() {
2085            if col_x >= acc && col_x < acc + col.width {
2086                return HitResult::Cell(row_idx, i);
2087            }
2088            acc += col.width;
2089        }
2090        HitResult::None
2091    }
2092
2093    #[must_use]
2094    pub fn wants_edge_scroll_tick(&self) -> bool {
2095        self.is_dragging
2096    }
2097}
2098
2099fn keystroke_to_char(k: &Keystroke) -> Option<char> {
2100    if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
2101        return None;
2102    }
2103    if let Some(key_char) = k.key_char.as_ref() {
2104        return key_char.chars().next();
2105    }
2106    if k.key.chars().count() == 1 {
2107        let c = k.key.chars().next()?;
2108        if k.modifiers.shift {
2109            Some(c.to_ascii_uppercase())
2110        } else {
2111            Some(c)
2112        }
2113    } else {
2114        None
2115    }
2116}
2117
2118#[cfg(test)]
2119#[allow(
2120    clippy::unwrap_used,
2121    clippy::expect_used,
2122    clippy::field_reassign_with_default
2123)]
2124mod tests {
2125    use super::*;
2126    use crate::data::{CellValue, Column, ColumnKind};
2127    use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
2128
2129    fn input_with(text: &str, cursor: usize) -> TextInput {
2130        let mut p = TextInput::new(text.to_owned());
2131        p.cursor_chars = cursor;
2132        p
2133    }
2134
2135    #[test]
2136    fn text_input_new_cursors_at_char_count_not_bytes() {
2137        // "hé🙂" is 3 chars but 7 bytes (h=1, é=2, 🙂=4).
2138        let p = TextInput::new("hé🙂".into());
2139        assert_eq!(p.cursor_chars, 3);
2140        assert_eq!(p.value.len(), 7);
2141    }
2142
2143    #[test]
2144    fn text_input_insert_emoji_at_start_does_not_panic() {
2145        let mut p = input_with("ab", 0);
2146        p.insert_char('\u{1F600}');
2147        assert_eq!(p.value, "\u{1F600}ab");
2148        assert_eq!(p.cursor_chars, 1);
2149    }
2150
2151    #[test]
2152    fn text_input_insert_in_middle_keeps_cursor_at_char_position() {
2153        let mut p = input_with("helloworld", 5);
2154        p.insert_char(' ');
2155        assert_eq!(p.value, "hello world");
2156        assert_eq!(p.cursor_chars, 6);
2157    }
2158
2159    #[test]
2160    fn text_input_backspace_at_zero_is_noop() {
2161        let mut p = input_with("abc", 0);
2162        p.backspace();
2163        assert_eq!(p.value, "abc");
2164        assert_eq!(p.cursor_chars, 0);
2165    }
2166
2167    #[test]
2168    fn text_input_backspace_removes_one_char_value() {
2169        // Cursor sits after "hé" (2 chars); backspace should delete "é" only.
2170        let mut p = input_with("héx", 2);
2171        p.backspace();
2172        assert_eq!(p.value, "hx");
2173        assert_eq!(p.cursor_chars, 1);
2174    }
2175
2176    #[test]
2177    fn text_input_clamp_cursor_pulls_back_past_end() {
2178        let mut p = input_with("abc", 99);
2179        p.clamp_cursor();
2180        assert_eq!(p.cursor_chars, 3);
2181    }
2182
2183    #[test]
2184    fn text_input_move_left_and_right_respect_bounds() {
2185        let mut p = input_with("ab", 2);
2186        p.move_right();
2187        assert_eq!(p.cursor_chars, 2);
2188        p.move_left();
2189        p.move_left();
2190        p.move_left();
2191        assert_eq!(p.cursor_chars, 0);
2192    }
2193
2194    #[test]
2195    fn edge_scroll_speed_stops_outside_band() {
2196        // Outside the 90 px trigger band: no scroll.
2197        assert_eq!(edge_scroll_speed(120.0), 0.0);
2198        assert_eq!(edge_scroll_speed(90.01), 0.0);
2199        // 60 ..= 90 -> 4 px/tick (slowest band).
2200        assert_eq!(edge_scroll_speed(90.0), 4.0);
2201        assert_eq!(edge_scroll_speed(60.0), 4.0);
2202        assert_eq!(edge_scroll_speed(59.99), 8.0);
2203        // 30 ..= 60 -> 8 px/tick.
2204        assert_eq!(edge_scroll_speed(30.0), 8.0);
2205        assert_eq!(edge_scroll_speed(29.99), 16.0);
2206        // < 30 -> 16 px/tick (really fast).
2207        assert_eq!(edge_scroll_speed(0.0), 16.0);
2208        assert_eq!(edge_scroll_speed(29.99), 16.0);
2209    }
2210
2211    #[test]
2212    fn edge_scroll_speed_caps_negative_runaway() {
2213        // Past the edge: saturate at the really-fast speed (16), not higher.
2214        assert_eq!(edge_scroll_speed(-100.0), 16.0);
2215        assert_eq!(edge_scroll_speed(-1000.0), 16.0);
2216    }
2217
2218    /// `GridState` requires a real GPUI `FocusHandle` from
2219    /// `gpui::Application`, but `gpui::Application::new()` panics on any
2220    /// thread other than `main`. Since Rust's test runner executes on a
2221    /// worker pool, the GPUI-backed assertions cannot run alongside pure
2222    /// tests. We mark this test `#[ignore]` so `cargo test` stays green; run
2223    /// it with `cargo test -- --ignored grid_state_behavior_under_application`
2224    /// from the workspace root on the test thread observable to GPUI.
2225    #[allow(clippy::expect_used, clippy::unwrap_used)]
2226    #[test]
2227    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2228    fn grid_state_behavior_under_application() {
2229        gpui::Application::new().run(|cx| {
2230            let focus = cx.focus_handle();
2231
2232            // format_current_status_handles_initial_state
2233            let mut state = GridState::new(
2234                GridData::new(
2235                    vec![Column::new("n", ColumnKind::Integer, 100.0)],
2236                    vec![vec![CellValue::Integer(1)]],
2237                )
2238                .expect("rectangular"),
2239                crate::config::GridConfig::default(),
2240                focus.clone(),
2241            );
2242            let _ = format_current_status(&state);
2243            assert_eq!(state.selection, Selection::None);
2244
2245            // format_current_status_replaces_with_supplied_pos
2246            state.last_mouse_pos = Some(Point {
2247                x: px(120.0),
2248                y: px(80.0),
2249            });
2250            let s = format_current_status(&state);
2251            assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
2252
2253            // recompute_filters_then_sorts_then_clears
2254            let mut state = GridState::new(
2255                GridData::new(
2256                    vec![Column::new("name", ColumnKind::Text, 100.0)],
2257                    vec![
2258                        vec![CellValue::Text("alpha".into())],
2259                        vec![CellValue::Text("beeb".into())],
2260                        vec![CellValue::Text("gamma".into())],
2261                    ],
2262                )
2263                .expect("rectangular"),
2264                crate::config::GridConfig::default(),
2265                focus.clone(),
2266            );
2267            state.filters[0] = ColumnFilter {
2268                predicate: FilterPredicate::Text {
2269                    op: TextOp::Contains,
2270                    operand: "a".into(),
2271                },
2272                values: None,
2273            };
2274            state.toggle_sort(0);
2275            state.recompute();
2276            assert_eq!(state.display_indices.as_slice(), &[0, 2]);
2277            state.toggle_sort(0);
2278            state.recompute();
2279            assert_eq!(state.display_indices.as_slice(), &[2, 0]);
2280            state.filters[0] = ColumnFilter::default();
2281            state.toggle_sort(0);
2282            state.recompute();
2283            assert_eq!(state.display_indices.as_slice(), &[0, 1, 2]);
2284
2285            // toggle_sort_cycles_through_three_states
2286            let mut state = GridState::new(
2287                GridData::new(
2288                    vec![Column::new("v", ColumnKind::Integer, 80.0)],
2289                    vec![vec![CellValue::Integer(1)]],
2290                )
2291                .expect("rectangular"),
2292                crate::config::GridConfig::default(),
2293                focus.clone(),
2294            );
2295            state.toggle_sort(0);
2296            assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
2297            state.toggle_sort(0);
2298            assert_eq!(state.sort, Some((0, SortDirection::Descending)));
2299            state.toggle_sort(0);
2300            assert_eq!(state.sort, None);
2301
2302            // select_all_picks_full_range_when_data_present
2303            let mut state = GridState::new(
2304                GridData::new(
2305                    vec![
2306                        Column::new("a", ColumnKind::Integer, 80.0),
2307                        Column::new("b", ColumnKind::Integer, 80.0),
2308                    ],
2309                    vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
2310                )
2311                .expect("rectangular"),
2312                crate::config::GridConfig::default(),
2313                focus.clone(),
2314            );
2315            state.select_all();
2316            assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
2317
2318            // select_all_is_noop_on_empty
2319            let mut state = GridState::new(
2320                GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
2321                    .expect("rectangular"),
2322                crate::config::GridConfig::default(),
2323                focus.clone(),
2324            );
2325            state.select_all();
2326            assert_eq!(state.selection, Selection::None);
2327
2328            // set_config_refreshes_resolved_formats
2329            let mut state = GridState::new(
2330                GridData::new(
2331                    vec![Column::new("v", ColumnKind::Decimal, 100.0)],
2332                    vec![vec![CellValue::Decimal(1.234)]],
2333                )
2334                .expect("rectangular"),
2335                crate::config::GridConfig::default(),
2336                focus.clone(),
2337            );
2338            assert_eq!(state.resolved_formats[0].number.decimals, 2);
2339            let mut cfg = crate::config::GridConfig::default();
2340            cfg.column_overrides = vec![crate::config::ColumnOverride {
2341                number: Some(crate::config::NumberFormat {
2342                    decimals: 6,
2343                    ..Default::default()
2344                }),
2345                ..Default::default()
2346            }];
2347            state.set_config(cfg);
2348            assert_eq!(state.resolved_formats[0].number.decimals, 6);
2349
2350            // wants_edge_scroll_tick_mirrors_is_dragging
2351            let mut state = GridState::new(
2352                GridData::new(
2353                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
2354                    vec![vec![CellValue::Integer(1)]],
2355                )
2356                .expect("rectangular"),
2357                crate::config::GridConfig::default(),
2358                focus.clone(),
2359            );
2360            assert!(!state.wants_edge_scroll_tick());
2361            state.is_dragging = true;
2362            assert!(state.wants_edge_scroll_tick());
2363
2364            cx.quit();
2365        });
2366    }
2367
2368    #[allow(clippy::expect_used, clippy::unwrap_used)]
2369    #[test]
2370    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2371    fn context_menu_request_construction() {
2372        use crate::grid::context_menu::ContextMenuTarget;
2373
2374        gpui::Application::new().run(|cx| {
2375            let focus = cx.focus_handle();
2376
2377            // 3 rows, 2 columns. Sort descending so display_indices != source.
2378            let mut state = GridState::new(
2379                GridData::new(
2380                    vec![
2381                        Column::new("id", ColumnKind::Integer, 80.0),
2382                        Column::new("name", ColumnKind::Text, 100.0),
2383                    ],
2384                    vec![
2385                        vec![CellValue::Integer(1), CellValue::Text("alpha".into())],
2386                        vec![CellValue::Integer(2), CellValue::Text("beta".into())],
2387                        vec![CellValue::Integer(3), CellValue::Text("gamma".into())],
2388                    ],
2389                )
2390                .expect("rectangular"),
2391                crate::config::GridConfig::default(),
2392                focus.clone(),
2393            );
2394            // Sort descending on column 0: display order is [2, 1, 0].
2395            state.sort = Some((0, SortDirection::Descending));
2396            state.recompute();
2397            assert_eq!(state.display_indices.as_slice(), &[2, 1, 0]);
2398
2399            // Cell target at display row 0 -> source row 2.
2400            let target = ContextMenuTarget::Cell {
2401                display_row_index: 0,
2402                source_row_index: 2,
2403                column_index: 1,
2404            };
2405            let sel = Selection::Cell(0, 1);
2406            let req = state.build_context_menu_request(target, &sel);
2407            assert_eq!(req.target.column_index(), Some(1));
2408            let cells = req.selected_cells();
2409            assert_eq!(cells.len(), 1);
2410            assert_eq!(cells[0].source_row_index, 2);
2411            assert_eq!(cells[0].column_name, "name");
2412            assert_eq!(cells[0].value, CellValue::Text("gamma".into()));
2413            let rows = req.selected_rows();
2414            assert_eq!(rows.len(), 1);
2415            assert_eq!(rows[0].source_row_index, 2);
2416            assert_eq!(rows[0].value_by_name("id"), Some(&CellValue::Integer(3)));
2417
2418            // Cell-range selection (display rows 0-1, cols 0-1).
2419            let target = ContextMenuTarget::Cell {
2420                display_row_index: 0,
2421                source_row_index: 2,
2422                column_index: 0,
2423            };
2424            let sel = Selection::CellRange(0, 0, 1, 1);
2425            let req = state.build_context_menu_request(target, &sel);
2426            assert_eq!(req.selected_cell_count(), 4); // 2 rows x 2 cols
2427            let rows = req.selected_rows();
2428            assert_eq!(rows.len(), 2);
2429            // Display row 0 -> source 2, display row 1 -> source 1.
2430            assert_eq!(rows[0].source_row_index, 2);
2431            assert_eq!(rows[1].source_row_index, 1);
2432
2433            // Row-range selection (display rows 0-2).
2434            let target = ContextMenuTarget::RowHeader {
2435                display_row_index: 1,
2436                source_row_index: 1,
2437            };
2438            let sel = Selection::RowRange(0, 2);
2439            let req = state.build_context_menu_request(target, &sel);
2440            let rows = req.selected_rows();
2441            assert_eq!(rows.len(), 3);
2442            // Each row should have all column values.
2443            assert_eq!(rows[0].values.len(), 2);
2444            assert_eq!(req.selected_cell_count(), 6); // 3 rows x 2 cols
2445
2446            // Column selection (all display rows, column 0). Column-oriented
2447            // targets do not populate `selected_rows` (see doc comment); the
2448            // column's values are exposed via `selected_cells`.
2449            let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2450            let sel = Selection::Column(0);
2451            let req = state.build_context_menu_request(target, &sel);
2452            assert!(req.is_column_oriented());
2453            assert_eq!(req.selected_row_count(), 0);
2454            assert!(req.selected_rows().is_empty());
2455            assert_eq!(req.selected_cells().len(), 3); // 3 rows x 1 col
2456
2457            // Empty data — no panic, empty vectors.
2458            let empty_state = GridState::new(
2459                GridData::new(vec![Column::new("x", ColumnKind::Integer, 80.0)], vec![])
2460                    .expect("rectangular"),
2461                crate::config::GridConfig::default(),
2462                focus.clone(),
2463            );
2464            let target = ContextMenuTarget::Cell {
2465                display_row_index: 0,
2466                source_row_index: 0,
2467                column_index: 0,
2468            };
2469            let req = empty_state.build_context_menu_request(target, &Selection::None);
2470            assert!(req.selected_cells().is_empty());
2471            assert!(req.selected_rows().is_empty());
2472
2473            cx.quit();
2474        });
2475    }
2476
2477    #[allow(clippy::expect_used, clippy::unwrap_used)]
2478    #[test]
2479    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2480    fn effective_selection_for_context_target() {
2481        gpui::Application::new().run(|cx| {
2482            let focus = cx.focus_handle();
2483            let mut state = GridState::new(
2484                GridData::new(
2485                    vec![
2486                        Column::new("a", ColumnKind::Integer, 80.0),
2487                        Column::new("b", ColumnKind::Integer, 80.0),
2488                    ],
2489                    vec![
2490                        vec![CellValue::Integer(1), CellValue::Integer(2)],
2491                        vec![CellValue::Integer(3), CellValue::Integer(4)],
2492                    ],
2493                )
2494                .expect("rectangular"),
2495                crate::config::GridConfig::default(),
2496                focus,
2497            );
2498
2499            // Outside current selection -> collapses to target cell.
2500            state.selection = Selection::Cell(0, 0);
2501            let target = ContextMenuTarget::Cell {
2502                display_row_index: 1,
2503                source_row_index: 1,
2504                column_index: 1,
2505            };
2506            let eff = state.effective_selection_for_context_target(&target);
2507            assert_eq!(eff, Selection::Cell(1, 1));
2508
2509            // Inside current selection -> keeps selection.
2510            state.selection = Selection::CellRange(0, 0, 1, 1);
2511            let target = ContextMenuTarget::Cell {
2512                display_row_index: 1,
2513                source_row_index: 1,
2514                column_index: 1,
2515            };
2516            let eff = state.effective_selection_for_context_target(&target);
2517            assert_eq!(eff, Selection::CellRange(0, 0, 1, 1));
2518
2519            // Row header outside -> collapses to row.
2520            state.selection = Selection::Cell(0, 0);
2521            let target = ContextMenuTarget::RowHeader {
2522                display_row_index: 1,
2523                source_row_index: 1,
2524            };
2525            let eff = state.effective_selection_for_context_target(&target);
2526            assert_eq!(eff, Selection::Row(1));
2527
2528            // Row header inside row range -> keeps range.
2529            state.selection = Selection::RowRange(0, 1);
2530            let target = ContextMenuTarget::RowHeader {
2531                display_row_index: 1,
2532                source_row_index: 1,
2533            };
2534            let eff = state.effective_selection_for_context_target(&target);
2535            assert_eq!(eff, Selection::RowRange(0, 1));
2536
2537            // Column header -> does not change selection.
2538            state.selection = Selection::Cell(1, 1);
2539            let target = ContextMenuTarget::ColumnHeader { column_index: 0 };
2540            let eff = state.effective_selection_for_context_target(&target);
2541            assert_eq!(eff, Selection::Cell(1, 1));
2542
2543            cx.quit();
2544        });
2545    }
2546
2547    #[allow(clippy::expect_used, clippy::unwrap_used)]
2548    #[test]
2549    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2550    fn context_menu_target_from_hit_maps_correctly() {
2551        gpui::Application::new().run(|cx| {
2552            let focus = cx.focus_handle();
2553            let state = GridState::new(
2554                GridData::new(
2555                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
2556                    vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(2)]],
2557                )
2558                .expect("rectangular"),
2559                crate::config::GridConfig::default(),
2560                focus,
2561            );
2562
2563            // Cell hit -> Cell target with source mapping.
2564            let t = state
2565                .context_menu_target_from_hit(HitResult::Cell(1, 0))
2566                .unwrap();
2567            assert_eq!(
2568                t,
2569                ContextMenuTarget::Cell {
2570                    display_row_index: 1,
2571                    source_row_index: 1,
2572                    column_index: 0,
2573                }
2574            );
2575
2576            // Row header -> RowHeader target.
2577            let t = state
2578                .context_menu_target_from_hit(HitResult::RowHeader(0))
2579                .unwrap();
2580            assert_eq!(
2581                t,
2582                ContextMenuTarget::RowHeader {
2583                    display_row_index: 0,
2584                    source_row_index: 0,
2585                }
2586            );
2587
2588            // Column header -> ColumnHeader target.
2589            let t = state
2590                .context_menu_target_from_hit(HitResult::ColumnHeader(0))
2591                .unwrap();
2592            assert_eq!(t, ContextMenuTarget::ColumnHeader { column_index: 0 });
2593
2594            // Sort button -> SortButton target.
2595            let t = state
2596                .context_menu_target_from_hit(HitResult::SortButton(0))
2597                .unwrap();
2598            assert_eq!(t, ContextMenuTarget::SortButton { column_index: 0 });
2599
2600            // Unsupported hits -> None.
2601            assert!(state
2602                .context_menu_target_from_hit(HitResult::VerticalScrollbar)
2603                .is_none());
2604            assert!(state
2605                .context_menu_target_from_hit(HitResult::None)
2606                .is_none());
2607
2608            cx.quit();
2609        });
2610    }
2611
2612    #[allow(clippy::expect_used, clippy::unwrap_used)]
2613    #[test]
2614    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2615    fn convert_context_menu_items_maps_variants() {
2616        use crate::grid::context_menu::ContextMenuItem;
2617
2618        let items = vec![
2619            ContextMenuItem::BuiltIn(MenuAction::SortAscending),
2620            ContextMenuItem::action("copy", "Copy value"),
2621            ContextMenuItem::separator(),
2622        ];
2623        let internal = GridState::convert_context_menu_items(items);
2624        assert!(matches!(
2625            internal[0],
2626            MenuItem::Action(MenuAction::SortAscending)
2627        ));
2628        assert!(
2629            matches!(&internal[1], MenuItem::Custom { id, label } if id == "copy" && label == "Copy value")
2630        );
2631        assert!(matches!(internal[2], MenuItem::Separator));
2632    }
2633
2634    #[allow(clippy::expect_used, clippy::unwrap_used)]
2635    #[test]
2636    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2637    fn execute_custom_context_menu_action_invokes_provider() {
2638        use crate::grid::context_menu::{
2639            ContextMenuProvider, ContextMenuProviderHandle, ContextMenuRequest,
2640        };
2641        use std::sync::{Arc, Mutex};
2642
2643        #[derive(Default)]
2644        struct TestProvider {
2645            last_action: Arc<Mutex<Option<String>>>,
2646        }
2647        impl ContextMenuProvider for TestProvider {
2648            fn menu_items(&self, _request: &ContextMenuRequest) -> Vec<ContextMenuItem> {
2649                vec![ContextMenuItem::action("test", "Test")]
2650            }
2651            fn on_action(
2652                &self,
2653                action_id: &str,
2654                _request: &ContextMenuRequest,
2655                _state: &mut GridState,
2656                _cx: &mut gpui::App,
2657            ) {
2658                *self.last_action.lock().unwrap() = Some(action_id.to_string());
2659            }
2660        }
2661
2662        gpui::Application::new().run(|cx| {
2663            let focus = cx.focus_handle();
2664            let mut state = GridState::new(
2665                GridData::new(
2666                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
2667                    vec![vec![CellValue::Integer(1)]],
2668                )
2669                .expect("rectangular"),
2670                crate::config::GridConfig::default(),
2671                focus,
2672            );
2673
2674            let last = Arc::new(Mutex::new(None));
2675            state.context_menu_provider = Some(ContextMenuProviderHandle::new(TestProvider {
2676                last_action: last.clone(),
2677            }));
2678
2679            let target = ContextMenuTarget::Cell {
2680                display_row_index: 0,
2681                source_row_index: 0,
2682                column_index: 0,
2683            };
2684            let request = state.build_context_menu_request(target, &Selection::Cell(0, 0));
2685            state.execute_custom_context_menu_action(
2686                PendingCustomContextMenuAction {
2687                    id: "test".into(),
2688                    request,
2689                },
2690                cx,
2691            );
2692            assert_eq!(*last.lock().unwrap(), Some("test".to_string()));
2693            assert!(state.context_menu.is_none());
2694
2695            cx.quit();
2696        });
2697    }
2698
2699    #[test]
2700    fn filter_panel_to_filter_with_all_checked_has_no_value_set() {
2701        let panel = FilterPanel {
2702            col: 0,
2703            anchor: Point {
2704                x: px(0.0),
2705                y: px(0.0),
2706            },
2707            kind: ColumnKind::Text,
2708            search: TextInput::default(),
2709            op_index: 0,
2710            op_menu_open: false,
2711            operand_a: TextInput::default(),
2712            operand_b: TextInput::default(),
2713            focus: FilterInput::Search,
2714            auto_apply: true,
2715            distinct: vec![
2716                FilterValueRow {
2717                    label: "alpha".into(),
2718                    checked: true,
2719                },
2720                FilterValueRow {
2721                    label: "beta".into(),
2722                    checked: true,
2723                },
2724            ],
2725        };
2726        let f = panel.to_filter();
2727        assert!(f.values.is_none(), "all checked => no value allow-list");
2728        assert!(
2729            !f.is_active(),
2730            "default predicate + all checked => inactive"
2731        );
2732    }
2733
2734    #[test]
2735    fn filter_panel_to_filter_with_unchecked_value_builds_allow_set() {
2736        let panel = FilterPanel {
2737            col: 0,
2738            anchor: Point {
2739                x: px(0.0),
2740                y: px(0.0),
2741            },
2742            kind: ColumnKind::Text,
2743            search: TextInput::default(),
2744            op_index: 0,
2745            op_menu_open: false,
2746            operand_a: TextInput::default(),
2747            operand_b: TextInput::default(),
2748            focus: FilterInput::Search,
2749            auto_apply: true,
2750            distinct: vec![
2751                FilterValueRow {
2752                    label: "alpha".into(),
2753                    checked: true,
2754                },
2755                FilterValueRow {
2756                    label: "beta".into(),
2757                    checked: false,
2758                },
2759            ],
2760        };
2761        let f = panel.to_filter();
2762        assert!(f.is_active(), "unchecked value => active filter");
2763        let set = f.values.expect("should have a value set");
2764        assert!(set.contains("alpha"));
2765        assert!(!set.contains("beta"));
2766    }
2767
2768    #[test]
2769    fn filter_panel_visible_indices_respects_search() {
2770        let panel = FilterPanel {
2771            col: 0,
2772            anchor: Point {
2773                x: px(0.0),
2774                y: px(0.0),
2775            },
2776            kind: ColumnKind::Text,
2777            search: TextInput::new("al".into()),
2778            op_index: 0,
2779            op_menu_open: false,
2780            operand_a: TextInput::default(),
2781            operand_b: TextInput::default(),
2782            focus: FilterInput::Search,
2783            auto_apply: true,
2784            distinct: vec![
2785                FilterValueRow {
2786                    label: "alpha".into(),
2787                    checked: true,
2788                },
2789                FilterValueRow {
2790                    label: "beta".into(),
2791                    checked: true,
2792                },
2793                FilterValueRow {
2794                    label: "gamma".into(),
2795                    checked: true,
2796                },
2797            ],
2798        };
2799        let vis = panel.visible_indices();
2800        assert_eq!(vis, vec![0], "search 'al' matches only alpha");
2801    }
2802
2803    #[test]
2804    fn filter_panel_all_checked_ignores_search() {
2805        let mut panel = FilterPanel {
2806            col: 0,
2807            anchor: Point {
2808                x: px(0.0),
2809                y: px(0.0),
2810            },
2811            kind: ColumnKind::Text,
2812            search: TextInput::new("al".into()),
2813            op_index: 0,
2814            op_menu_open: false,
2815            operand_a: TextInput::default(),
2816            operand_b: TextInput::default(),
2817            focus: FilterInput::Search,
2818            auto_apply: true,
2819            distinct: vec![
2820                FilterValueRow {
2821                    label: "alpha".into(),
2822                    checked: true,
2823                },
2824                FilterValueRow {
2825                    label: "beta".into(),
2826                    checked: false,
2827                },
2828                FilterValueRow {
2829                    label: "gamma".into(),
2830                    checked: true,
2831                },
2832            ],
2833        };
2834        // Even though the search "al" hides beta (unchecked), "(Select All)"
2835        // reflects the GLOBAL checked state, so it must be false.
2836        assert!(
2837            !panel.all_checked(),
2838            "beta is unchecked, so not all values are checked (search is irrelevant)"
2839        );
2840
2841        // A search that matches nothing must not flip "(Select All)".
2842        panel.search = TextInput::new("zzz".into());
2843        for row in &mut panel.distinct {
2844            row.checked = true;
2845        }
2846        assert!(
2847            panel.all_checked(),
2848            "all values checked -> Select All stays checked regardless of empty search"
2849        );
2850    }
2851
2852    #[allow(clippy::expect_used, clippy::unwrap_used)]
2853    #[test]
2854    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
2855    fn filter_panel_open_apply_clear_state_flow() {
2856        gpui::Application::new().run(|cx| {
2857            let focus = cx.focus_handle();
2858            let mut state = GridState::new(
2859                GridData::new(
2860                    vec![Column::new("name", ColumnKind::Text, 100.0)],
2861                    vec![
2862                        vec![CellValue::Text("alpha".into())],
2863                        vec![CellValue::Text("beta".into())],
2864                        vec![CellValue::Text("gamma".into())],
2865                    ],
2866                )
2867                .expect("rectangular"),
2868                crate::config::GridConfig::default(),
2869                focus,
2870            );
2871
2872            // Open filter panel for column 0 with an explicit anchor.
2873            let anchor = Point {
2874                x: px(50.0),
2875                y: px(20.0),
2876            };
2877            state.open_filter_panel(0, Some(anchor));
2878            let panel = state.filter_panel.as_ref().expect("panel should be open");
2879            assert_eq!(panel.col, 0);
2880            assert_eq!(panel.anchor, anchor);
2881            assert_eq!(panel.distinct.len(), 3);
2882            assert!(
2883                panel.distinct.iter().all(|r| r.checked),
2884                "all checked by default"
2885            );
2886            assert!(panel.auto_apply, "auto_apply defaults to true");
2887            assert_eq!(panel.kind, ColumnKind::Text);
2888
2889            // Uncheck "beta" (index 1) and apply.
2890            state.toggle_filter_value(1);
2891            state.apply_filter_panel();
2892            assert_eq!(
2893                state.display_indices.as_slice(),
2894                &[0, 2],
2895                "beta should be filtered out"
2896            );
2897
2898            // Clear the filter panel.
2899            state.clear_filter_panel();
2900            assert_eq!(
2901                state.display_indices.as_slice(),
2902                &[0, 1, 2],
2903                "all rows visible after clear"
2904            );
2905            assert!(
2906                state.filters[0] == ColumnFilter::default(),
2907                "filter reset to default"
2908            );
2909
2910            // Open with a text "contains" predicate.
2911            state.open_filter_panel(0, Some(anchor));
2912            let panel = state.filter_panel.as_mut().expect("panel open");
2913            panel.op_index = 1; // "contains"
2914            panel.operand_a = TextInput::new("a".into());
2915            state.apply_filter_panel();
2916            assert_eq!(
2917                state.display_indices.as_slice(),
2918                &[0, 2],
2919                "contains 'a' matches alpha and gamma"
2920            );
2921
2922            // Clear and verify restored.
2923            state.clear_filter_panel();
2924            assert_eq!(state.display_indices.as_slice(), &[0, 1, 2]);
2925
2926            cx.quit();
2927        });
2928    }
2929}