Skip to main content

sqlly_datatable/grid/
state.rs

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