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, GridData};
7use crate::format::{cell_matches_filter, format_cell};
8use crate::grid::state::state_inner::apply_edge_scroll;
9use crate::grid::theme::GridTheme;
10
11use crate::config::{GridConfig, ResolvedColumnFormat};
12use gpui::{px, App, Bounds, FocusHandle, Keystroke, MouseButton, Pixels, Point, ScrollHandle};
13
14// Pull selection / menu types into scope unqualified for this module's impl.
15use crate::grid::menu as menu_mod;
16#[allow(unused_imports)]
17pub(crate) use crate::grid::menu::{ContextMenu, MenuAction, MenuItem};
18use crate::grid::selection::{
19    screen_to_content, HitResult, ScrollbarAxis, Selection, SortDirection,
20};
21
22/// Inline constructor / state mutators used by the widget's render loop.
23/// Kept in its own submodule so this module remains the public surface while
24/// its helpers are exposed for unit tests.
25pub mod state_inner {
26    use super::{
27        format_cell, CellValue, GridState, HitResult, Pixels, Point, ResolvedColumnFormat,
28    };
29    pub use crate::grid::selection::screen_to_content;
30    use std::fmt::Write as _;
31
32    /// Returns the per-tick edge-scroll velocity in pixels (positive scrolls
33    /// the content forward; the caller applies sign).
34    pub fn edge_scroll_speed(dist_from_edge: f32) -> f32 {
35        if dist_from_edge > 150.0 {
36            return 0.0;
37        }
38        if dist_from_edge < 0.0 {
39            return (24.0 + (-dist_from_edge) * 0.6).min(80.0);
40        }
41        if dist_from_edge < 25.0 {
42            12.0
43        } else if dist_from_edge < 50.0 {
44            6.0
45        } else if dist_from_edge < 100.0 {
46            3.0
47        } else {
48            1.0
49        }
50    }
51
52    pub fn apply_edge_scroll(state: &mut GridState) -> bool {
53        if !state.is_dragging {
54            return false;
55        }
56        let Some(pos) = state.last_mouse_pos else {
57            return false;
58        };
59        let bounds = state.bounds;
60        let (x, y) = screen_to_content(pos, bounds.origin, state.scroll_handle.offset());
61        let vw: f32 = bounds.size.width.into();
62        let vh: f32 = bounds.size.height.into();
63        let right_dist = vw - x;
64        let left_dist = x - state.row_header_width;
65        let bottom_dist = vh - y;
66        let top_dist = y - state.header_height;
67        let mut dx = 0.0_f32;
68        let mut dy = 0.0_f32;
69        if right_dist < 150.0 && right_dist <= left_dist {
70            dx = edge_scroll_speed(right_dist);
71        } else if left_dist < 150.0 {
72            dx = -edge_scroll_speed(left_dist);
73        }
74        if bottom_dist < 150.0 && bottom_dist <= top_dist {
75            dy = edge_scroll_speed(bottom_dist);
76        } else if top_dist < 150.0 {
77            dy = -edge_scroll_speed(top_dist);
78        }
79        if dx == 0.0 && dy == 0.0 {
80            return false;
81        }
82        state.scroll_one_edge_tick(dx, dy);
83        if state.drag_start.is_some() {
84            state.update_drag_from_last();
85        }
86        true
87    }
88
89    #[must_use]
90    pub fn format_current_status(state: &GridState) -> String {
91        let scroll = state.scroll_handle.offset();
92        let (click_col, click_row) = col_row_from_hit(state.click_hit);
93        let (hover_col, hover_row) = col_row_from_hit(state.hover_hit);
94        let mut out = String::new();
95        let _ = write!(
96            out,
97            "Click: {}  Scroll@Click: {}  Cell: {}  |  Cur: {}  Scroll: {}  Over: {}",
98            fmt_point(state.click_pos),
99            fmt_point(state.scroll_at_click),
100            fmt_cr(click_col, click_row),
101            fmt_point(state.last_mouse_pos),
102            fmt_point(Some(scroll)),
103            fmt_cr(hover_col, hover_row),
104        );
105        out
106    }
107
108    fn col_row_from_hit(hit: Option<HitResult>) -> (Option<usize>, Option<usize>) {
109        match hit {
110            Some(HitResult::Cell(r, c)) => (Some(c), Some(r)),
111            Some(HitResult::RowHeader(r)) => (None, Some(r)),
112            Some(HitResult::ColumnHeader(c)) | Some(HitResult::SortButton(c)) => (Some(c), None),
113            _ => (None, None),
114        }
115    }
116
117    fn fmt_point(p: Option<Point<Pixels>>) -> String {
118        match p {
119            Some(p) => format!("({:.0}, {:.0})", f32::from(p.x), f32::from(p.y)),
120            None => "—".into(),
121        }
122    }
123
124    fn fmt_cr(c: Option<usize>, r: Option<usize>) -> String {
125        match (c, r) {
126            (Some(c), Some(r)) => format!("(col {c}, row {r})"),
127            (Some(c), None) => format!("(col {c})"),
128            (None, Some(r)) => format!("(row {r})"),
129            (None, None) => "—".into(),
130        }
131    }
132
133    #[must_use]
134    pub fn cell_text(cell: &CellValue, fmt: &ResolvedColumnFormat) -> String {
135        format_cell(cell, fmt).0
136    }
137}
138
139/// Width, in pixels, of vertical and horizontal scrollbar strips.
140pub const SCROLLBAR_SIZE: f32 = 20.0;
141/// Polling interval used to drive auto-scroll during drag.
142pub const EDGE_SCROLL_TICK_MS: u64 = 16;
143
144/// Complete grid state owned by a GPUI `Entity<GridState>`.
145#[derive(Debug)]
146pub struct GridState {
147    pub data: GridData,
148    pub config: GridConfig,
149    /// Cached resolved-format list, kept in sync with `data.columns` and
150    /// `config`. Paint, copy, and filter read this directly instead of
151    /// recomputing per cell.
152    pub resolved_formats: Vec<ResolvedColumnFormat>,
153    pub display_indices: Vec<usize>,
154    pub selection: Selection,
155    pub sort: Option<(usize, SortDirection)>,
156    pub filters: Vec<String>,
157    pub scroll_handle: ScrollHandle,
158    pub focus_handle: FocusHandle,
159    pub bounds: Bounds<Pixels>,
160    pub row_height: f32,
161    pub header_height: f32,
162    pub row_header_width: f32,
163    pub font_size: f32,
164    pub char_width: f32,
165    pub theme: GridTheme,
166    pub is_dragging: bool,
167    pub drag_start: Option<Point<Pixels>>,
168    pub drag_start_hit: Option<HitResult>,
169    pub scroll_at_click: Option<Point<Pixels>>,
170    pub last_mouse_pos: Option<Point<Pixels>>,
171    pub status_bar_height: f32,
172    pub click_pos: Option<Point<Pixels>>,
173    pub click_hit: Option<HitResult>,
174    pub hover_hit: Option<HitResult>,
175    pub resizing_col: Option<usize>,
176    pub resize_start_x: f32,
177    pub resize_start_width: f32,
178    pub context_menu: Option<ContextMenu>,
179    pub filter_prompt: Option<FilterPrompt>,
180    pub pending_action: Option<(MenuAction, usize)>,
181    pub scrollbar_drag: Option<ScrollbarAxis>,
182    pub scrollbar_drag_start_offset: f32,
183    pub scrollbar_drag_start_pos: f32,
184}
185
186/// Filter-prompt input. Cursor is tracked as a **char count**, not a byte
187/// offset, so multi-byte input never panics on grapheme-misaligned inserts.
188#[derive(Clone, Debug)]
189pub struct FilterPrompt {
190    pub col: usize,
191    pub anchor: Point<Pixels>,
192    pub input: String,
193    pub cursor_chars: usize,
194}
195
196impl FilterPrompt {
197    fn new(col: usize, anchor: Point<Pixels>, input: String) -> Self {
198        let cursor_chars = input.chars().count();
199        Self {
200            col,
201            anchor,
202            input,
203            cursor_chars,
204        }
205    }
206
207    fn clamp_cursor(&mut self) {
208        let total = self.input.chars().count();
209        if self.cursor_chars > total {
210            self.cursor_chars = total;
211        }
212    }
213
214    fn insert_char(&mut self, ch: char) {
215        let byte_idx = byte_index_for_char(&self.input, self.cursor_chars);
216        self.input.insert(byte_idx, ch);
217        self.cursor_chars += 1;
218    }
219
220    fn backspace(&mut self) {
221        if self.cursor_chars == 0 {
222            return;
223        }
224        let end = byte_index_for_char(&self.input, self.cursor_chars);
225        let start = byte_index_for_char(&self.input, self.cursor_chars - 1);
226        self.input.replace_range(start..end, "");
227        self.cursor_chars -= 1;
228    }
229}
230
231fn byte_index_for_char(input: &str, char_idx: usize) -> usize {
232    input
233        .char_indices()
234        .nth(char_idx)
235        .map_or(input.len(), |(idx, _)| idx)
236}
237
238impl GridState {
239    #[must_use]
240    pub fn new(data: GridData, config: GridConfig, focus_handle: FocusHandle) -> Self {
241        let resolved_formats = config.resolve_all(&data.columns);
242        let col_count = data.columns.len();
243        let display_indices = (0..data.rows.len()).collect();
244        Self {
245            data,
246            config,
247            resolved_formats,
248            display_indices,
249            selection: Selection::None,
250            sort: None,
251            filters: vec![String::new(); col_count],
252            scroll_handle: ScrollHandle::new(),
253            focus_handle,
254            bounds: Bounds::default(),
255            row_height: 24.0,
256            header_height: 32.0,
257            row_header_width: 50.0,
258            font_size: 14.0,
259            char_width: 7.6,
260            theme: GridTheme::default(),
261            is_dragging: false,
262            drag_start: None,
263            drag_start_hit: None,
264            scroll_at_click: None,
265            last_mouse_pos: None,
266            status_bar_height: 24.0,
267            click_pos: None,
268            click_hit: None,
269            hover_hit: None,
270            resizing_col: None,
271            resize_start_x: 0.0,
272            resize_start_width: 0.0,
273            context_menu: None,
274            filter_prompt: None,
275            pending_action: None,
276            scrollbar_drag: None,
277            scrollbar_drag_start_offset: 0.0,
278            scrollbar_drag_start_pos: 0.0,
279        }
280    }
281
282    pub fn set_config(&mut self, config: GridConfig) {
283        self.config = config;
284        self.rebuild_resolved_formats();
285        self.recompute();
286    }
287
288    fn rebuild_resolved_formats(&mut self) {
289        self.resolved_formats = self.config.resolve_all(&self.data.columns);
290    }
291
292    pub fn recompute(&mut self) {
293        let mut indices: Vec<usize> = (0..self.data.rows.len())
294            .filter(|&row_idx| {
295                self.data.columns.iter().enumerate().all(|(col_idx, _col)| {
296                    let filter = &self.filters[col_idx];
297                    if filter.is_empty() {
298                        return true;
299                    }
300                    let cell = &self.data.rows[row_idx][col_idx];
301                    cell_matches_filter(cell, &self.resolved_formats[col_idx], filter)
302                })
303            })
304            .collect();
305
306        if let Some((sort_col, direction)) = self.sort {
307            indices.sort_by(|&a, &b| {
308                let cell_a = &self.data.rows[a][sort_col];
309                let cell_b = &self.data.rows[b][sort_col];
310                let ord = compare_cells(cell_a, cell_b);
311                match direction {
312                    SortDirection::Ascending => ord,
313                    SortDirection::Descending => ord.reverse(),
314                }
315            });
316        }
317        self.display_indices = indices;
318    }
319
320    fn content_size(&self) -> (f32, f32) {
321        let cw: f32 = self.data.columns.iter().map(|c| c.width).sum();
322        let ch = self.display_indices.len() as f32 * self.row_height;
323        (cw, ch)
324    }
325
326    pub(crate) fn max_scroll(&self) -> (f32, f32) {
327        let (cw, ch) = self.content_size();
328        let (rw, rh) = self.scrollbar_reserved();
329        let vw: f32 = self.bounds.size.width.into();
330        let vh: f32 = self.bounds.size.height.into();
331        let vw = vw - self.row_header_width - rw;
332        let vh = vh - self.header_height - rh;
333        ((cw - vw).max(0.0), (ch - vh).max(0.0))
334    }
335
336    fn scrollbar_reserved(&self) -> (f32, f32) {
337        let (cw, ch) = self.content_size();
338        let vw: f32 = self.bounds.size.width.into();
339        let vh: f32 = self.bounds.size.height.into();
340        let vw = vw - self.row_header_width;
341        let vh = vh - self.header_height;
342        let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
343        let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
344        (reserved_w, reserved_h)
345    }
346
347    fn vbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
348        let (_, ch) = self.content_size();
349        let (_, rh) = self.scrollbar_reserved();
350        let vh: f32 = self.bounds.size.height.into();
351        let vh = vh - self.header_height - rh;
352        if ch <= vh {
353            return None;
354        }
355        let ox: f32 = self.bounds.origin.x.into();
356        let oy: f32 = self.bounds.origin.y.into();
357        let sw: f32 = self.bounds.size.width.into();
358        let sh: f32 = self.bounds.size.height.into();
359        let track_x = ox + sw - SCROLLBAR_SIZE;
360        let track_y = oy + self.header_height;
361        let track_h = sh - self.header_height - rh;
362        let thumb_h = ((track_h * (vh / ch)).max(20.0)).min(track_h);
363        Some((track_x, track_y, SCROLLBAR_SIZE, track_h, thumb_h))
364    }
365
366    fn hbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
367        let (cw, _) = self.content_size();
368        let (rw, _) = self.scrollbar_reserved();
369        let vw: f32 = self.bounds.size.width.into();
370        let vw = vw - self.row_header_width - rw;
371        if cw <= vw {
372            return None;
373        }
374        let ox: f32 = self.bounds.origin.x.into();
375        let oy: f32 = self.bounds.origin.y.into();
376        let sw: f32 = self.bounds.size.width.into();
377        let sh: f32 = self.bounds.size.height.into();
378        let track_x = ox + self.row_header_width;
379        let track_y = oy + sh - SCROLLBAR_SIZE;
380        let track_w = sw - self.row_header_width - rw;
381        let thumb_w = ((track_w * (vw / cw)).max(20.0)).min(track_w);
382        Some((track_x, track_y, track_w, SCROLLBAR_SIZE, thumb_w))
383    }
384
385    pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
386        if let Some((_, track_y, _, track_h, thumb_h)) = self.vbar_geom() {
387            let (_, max_y) = self.max_scroll();
388            let range = (track_h - thumb_h).max(0.0);
389            let rel = (mouse_y - track_y - thumb_h * 0.5).clamp(0.0, range);
390            let frac = if range > 0.0 { rel / range } else { 0.0 };
391            let new_y = frac * max_y;
392            let x = self.scroll_handle.offset().x;
393            self.scroll_handle.set_offset(Point { x, y: px(new_y) });
394        }
395    }
396
397    pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
398        if let Some((track_x, _, track_w, _, thumb_w)) = self.hbar_geom() {
399            let (max_x, _) = self.max_scroll();
400            let range = (track_w - thumb_w).max(0.0);
401            let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
402            let frac = if range > 0.0 { rel / range } else { 0.0 };
403            let new_x = frac * max_x;
404            let y = self.scroll_handle.offset().y;
405            self.scroll_handle.set_offset(Point { x: px(new_x), y });
406        }
407    }
408
409    pub(crate) fn scroll_one_edge_tick(&mut self, dx: f32, dy: f32) {
410        let (mx, my) = self.max_scroll();
411        let s = self.scroll_handle.offset();
412        let new_x: f32 = (f32::from(s.x) + dx).clamp(0.0, mx);
413        let new_y: f32 = (f32::from(s.y) + dy).clamp(0.0, my);
414        self.scroll_handle.set_offset(Point {
415            x: px(new_x),
416            y: px(new_y),
417        });
418    }
419
420    pub fn toggle_sort(&mut self, col: usize) {
421        self.sort = match self.sort {
422            Some((c, SortDirection::Ascending)) if c == col => {
423                Some((col, SortDirection::Descending))
424            }
425            Some((c, SortDirection::Descending)) if c == col => None,
426            _ => Some((col, SortDirection::Ascending)),
427        };
428        self.recompute();
429    }
430
431    pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
432        let hit = self.hit_test(pos);
433        self.click_pos = Some(pos);
434        self.click_hit = Some(hit);
435        match hit {
436            HitResult::VerticalScrollbar => {
437                self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
438                self.scroll_to_vbar(f32::from(pos.y));
439                self.clear_drag();
440            }
441            HitResult::HorizontalScrollbar => {
442                self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
443                self.scroll_to_hbar(f32::from(pos.x));
444                self.clear_drag();
445            }
446            HitResult::ColumnBorder(col) => {
447                self.resizing_col = Some(col);
448                self.resize_start_x = f32::from(pos.x);
449                self.resize_start_width = self.data.columns[col].width;
450                self.clear_drag();
451            }
452            HitResult::ColumnHeader(col) => {
453                self.selection = Selection::Column(col);
454                self.clear_drag();
455            }
456            HitResult::SortButton(col) => {
457                self.selection = Selection::Column(col);
458                self.toggle_sort(col);
459                self.clear_drag();
460            }
461            HitResult::ContextMenuItem(_) => {}
462            HitResult::RowHeader(row) => {
463                self.selection = if shift {
464                    if let Selection::Row(prev) = self.selection {
465                        let (s, e) = (prev, row);
466                        Selection::RowRange(s.min(e), s.max(e))
467                    } else {
468                        Selection::Row(row)
469                    }
470                } else {
471                    Selection::Row(row)
472                };
473                self.start_drag(pos);
474                self.drag_start_hit = Some(HitResult::RowHeader(row));
475            }
476            HitResult::Cell(row, col) => {
477                self.selection = if shift {
478                    if let Selection::Cell(pr, pc) = self.selection {
479                        Selection::CellRange(pr.min(row), pc.min(col), pr.max(row), pc.max(col))
480                    } else {
481                        Selection::Cell(row, col)
482                    }
483                } else {
484                    Selection::Cell(row, col)
485                };
486                self.start_drag(pos);
487                self.drag_start_hit = Some(HitResult::Cell(row, col));
488            }
489            HitResult::Corner | HitResult::None => {
490                self.selection = Selection::None;
491                self.context_menu = None;
492                self.filter_prompt = None;
493                self.clear_drag();
494            }
495        }
496    }
497
498    fn start_drag(&mut self, pos: Point<Pixels>) {
499        self.is_dragging = false;
500        self.drag_start = Some(pos);
501        self.scroll_at_click = Some(self.scroll_handle.offset());
502        self.last_mouse_pos = Some(pos);
503    }
504
505    pub(crate) fn open_context_menu(&mut self, col: usize, anchor: Point<Pixels>) {
506        self.context_menu = Some(menu_mod::ContextMenu::standard(col, anchor));
507        self.filter_prompt = None;
508    }
509
510    pub fn execute_action(&mut self, action: MenuAction, col: usize, cx: &mut App) {
511        match action {
512            MenuAction::SelectColumn => {
513                self.selection = Selection::Column(col);
514            }
515            MenuAction::CopyColumn => {
516                let text = self.column_text(col);
517                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
518            }
519            MenuAction::CopyColumnWithHeaders => {
520                let mut text = String::new();
521                text.push_str(&self.data.columns[col].name);
522                text.push('\n');
523                text.push_str(&self.column_text(col));
524                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
525            }
526            MenuAction::SortAscending => {
527                self.sort = Some((col, SortDirection::Ascending));
528                self.recompute();
529            }
530            MenuAction::SortDescending => {
531                self.sort = Some((col, SortDirection::Descending));
532                self.recompute();
533            }
534            MenuAction::ClearSort => {
535                self.sort = None;
536                self.recompute();
537            }
538            MenuAction::FilterPrompt => {
539                let anchor = self.last_mouse_pos.unwrap_or(Point {
540                    x: px(0.0),
541                    y: px(0.0),
542                });
543                let existing = self.filters.get(col).cloned().unwrap_or_default();
544                self.filter_prompt = Some(FilterPrompt::new(col, anchor, existing));
545            }
546            MenuAction::ClearFilter => {
547                if col < self.filters.len() {
548                    self.filters[col].clear();
549                    self.recompute();
550                }
551            }
552        }
553        self.context_menu = None;
554    }
555
556    fn column_text(&self, col: usize) -> String {
557        let mut text = String::new();
558        let fmt = &self.resolved_formats[col];
559        for &row_idx in &self.display_indices {
560            let cell = &self.data.rows[row_idx][col];
561            let (s, _) = format_cell(cell, fmt);
562            text.push_str(&s);
563            text.push('\n');
564        }
565        text
566    }
567
568    fn clear_drag(&mut self) {
569        self.is_dragging = false;
570        self.drag_start = None;
571        self.drag_start_hit = None;
572        self.scroll_at_click = None;
573    }
574
575    fn drag_world_corners(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
576        let start = self.drag_start?;
577        let mouse = self.last_mouse_pos?;
578        let click_scroll = self
579            .scroll_at_click
580            .unwrap_or_else(|| self.scroll_handle.offset());
581        let scroll = self.scroll_handle.offset();
582        let sx_click: f32 = click_scroll.x.into();
583        let sy_click: f32 = click_scroll.y.into();
584        let sx: f32 = scroll.x.into();
585        let sy: f32 = scroll.y.into();
586        let sx0: f32 = start.x.into();
587        let sy0: f32 = start.y.into();
588        let mx: f32 = mouse.x.into();
589        let my: f32 = mouse.y.into();
590        let start_world = Point {
591            x: px(sx0 + sx_click),
592            y: px(sy0 + sy_click),
593        };
594        let end_world = Point {
595            x: px(mx + sx),
596            y: px(my + sy),
597        };
598        Some((start_world, end_world))
599    }
600
601    pub fn drag_screen_rect(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
602        if !self.is_dragging {
603            return None;
604        }
605        let (start_world, end_world) = self.drag_world_corners()?;
606        let scroll = self.scroll_handle.offset();
607        let sx: f32 = scroll.x.into();
608        let sy: f32 = scroll.y.into();
609        let start_screen = Point {
610            x: px(f32::from(start_world.x) - sx),
611            y: px(f32::from(start_world.y) - sy),
612        };
613        let end_screen = Point {
614            x: px(f32::from(end_world.x) - sx),
615            y: px(f32::from(end_world.y) - sy),
616        };
617        Some((start_screen, end_screen))
618    }
619
620    fn update_drag(&mut self) {
621        let (start_world, end_world) = match self.drag_world_corners() {
622            Some(c) => c,
623            None => return,
624        };
625        if !self.is_dragging {
626            let dx = f32::from(end_world.x) - f32::from(start_world.x);
627            let dy = f32::from(end_world.y) - f32::from(start_world.y);
628            if dx * dx + dy * dy <= 400.0 {
629                return;
630            }
631            self.is_dragging = true;
632        }
633        let r1 = match self.drag_start_hit {
634            Some(h) => h,
635            None => return,
636        };
637        let ox: f32 = self.bounds.origin.x.into();
638        let oy: f32 = self.bounds.origin.y.into();
639        let r2 = self.hit_test_content(
640            f32::from(end_world.x) - ox,
641            f32::from(end_world.y) - oy,
642            0.0,
643            0.0,
644        );
645        match (r1, r2) {
646            (HitResult::Cell(r1c, c1), HitResult::Cell(r2c, c2)) => {
647                self.selection =
648                    Selection::CellRange(r1c.min(r2c), c1.min(c2), r1c.max(r2c), c1.max(c2));
649            }
650            (HitResult::RowHeader(r1r), HitResult::RowHeader(r2r)) => {
651                self.selection = Selection::RowRange(r1r.min(r2r), r1r.max(r2r));
652            }
653            _ => {}
654        }
655    }
656
657    fn update_drag_from_last(&mut self) {
658        self.update_drag();
659    }
660
661    pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, pressed_button: Option<MouseButton>) {
662        if self.is_dragging && pressed_button != Some(MouseButton::Left) {
663            self.handle_mouse_up();
664            return;
665        }
666        if let Some(col) = self.resizing_col {
667            if pressed_button != Some(MouseButton::Left) {
668                self.resizing_col = None;
669                return;
670            }
671            let new_w =
672                (self.resize_start_width + (f32::from(pos.x) - self.resize_start_x)).max(40.0);
673            self.data.columns[col].width = new_w;
674            return;
675        }
676        if let Some(axis) = self.scrollbar_drag {
677            if pressed_button != Some(MouseButton::Left) {
678                self.scrollbar_drag = None;
679                return;
680            }
681            match axis {
682                ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
683                ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
684            }
685            self.last_mouse_pos = Some(pos);
686            return;
687        }
688        self.last_mouse_pos = Some(pos);
689        if let Some(menu) = self.context_menu.clone() {
690            let cw = self.char_width;
691            let (x_rel, y_rel) =
692                screen_to_content(pos, self.bounds.origin, self.scroll_handle.offset());
693            let hovered = menu_mod::hover_at(&menu, x_rel, y_rel, cw);
694            if let Some(menu_mut) = self.context_menu.as_mut() {
695                menu_mut.hovered = hovered;
696            }
697            self.hover_hit = Some(self.hit_test(pos));
698            return;
699        }
700        self.hover_hit = Some(self.hit_test(pos));
701        if self.drag_start.is_none() {
702            return;
703        }
704        self.update_drag();
705    }
706
707    pub fn handle_scroll_drag(&mut self) {
708        if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
709            self.update_drag();
710        }
711    }
712
713    pub fn handle_mouse_up(&mut self) {
714        self.resizing_col = None;
715        self.scrollbar_drag = None;
716        self.clear_drag();
717    }
718
719    pub fn apply_edge_scroll(&mut self) -> bool {
720        apply_edge_scroll(self)
721    }
722
723    pub fn select_all(&mut self) {
724        let nrows = self.display_indices.len();
725        let ncols = self.data.columns.len();
726        if nrows > 0 && ncols > 0 {
727            self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
728        }
729    }
730
731    pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
732        let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
733            return;
734        };
735        if self.display_indices.is_empty() || self.data.columns.is_empty() {
736            return;
737        }
738        let last_row = self.display_indices.len() - 1;
739        let last_col = self.data.columns.len() - 1;
740        let r1 = raw_r1.min(last_row);
741        let r2 = raw_r2.min(last_row);
742        let c1 = raw_c1.min(last_col);
743        let c2 = raw_c2.min(last_col);
744        let mut text = String::new();
745        if with_headers {
746            for c in c1..=c2 {
747                if c > c1 {
748                    text.push('\t');
749                }
750                text.push_str(&self.data.columns[c].name);
751            }
752            text.push('\n');
753        }
754        for dr in r1..=r2 {
755            let row_idx = self.display_indices[dr];
756            for c in c1..=c2 {
757                if c > c1 {
758                    text.push('\t');
759                }
760                let cell = &self.data.rows[row_idx][c];
761                let (s, _) = format_cell(cell, &self.resolved_formats[c]);
762                text.push_str(&s);
763            }
764            text.push('\n');
765        }
766        cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
767    }
768
769    pub fn page_up(&mut self) {
770        let vh: f32 = self.bounds.size.height.into();
771        let rows = ((vh - self.header_height) / self.row_height) as i32;
772        self.move_selection(0, -rows);
773    }
774
775    pub fn page_down(&mut self) {
776        let vh: f32 = self.bounds.size.height.into();
777        let rows = ((vh - self.header_height) / self.row_height) as i32;
778        self.move_selection(0, rows);
779    }
780
781    pub fn handle_key(&mut self, keystroke: &Keystroke) {
782        if let Some(prompt) = &mut self.filter_prompt {
783            match keystroke.key.as_str() {
784                "escape" => self.filter_prompt = None,
785                "enter" => {
786                    let col = prompt.col;
787                    self.filters[col] = prompt.input.clone();
788                    self.filter_prompt = None;
789                    self.recompute();
790                }
791                "backspace" => prompt.backspace(),
792                "left" => {
793                    if prompt.cursor_chars > 0 {
794                        prompt.cursor_chars -= 1;
795                    }
796                }
797                "right" => {
798                    prompt.clamp_cursor();
799                    if prompt.cursor_chars < prompt.input.chars().count() {
800                        prompt.cursor_chars += 1;
801                    }
802                }
803                _ => {
804                    if let Some(ch) = keystroke_to_char(keystroke) {
805                        prompt.insert_char(ch);
806                    }
807                }
808            }
809            return;
810        }
811        if self.context_menu.is_some() {
812            if keystroke.key.as_str() == "escape" {
813                self.context_menu = None;
814            }
815            return;
816        }
817        match keystroke.key.as_str() {
818            "up" => self.move_selection(0, -1),
819            "down" => self.move_selection(0, 1),
820            "left" => self.move_selection(-1, 0),
821            "right" => self.move_selection(1, 0),
822            "escape" => self.selection = Selection::None,
823            _ => {}
824        }
825    }
826
827    fn move_selection(&mut self, dx: i32, dy: i32) {
828        let nrows = self.display_indices.len() as i32;
829        let ncols = self.data.columns.len() as i32;
830        if nrows == 0 || ncols == 0 {
831            return;
832        }
833        let last_row = nrows - 1;
834        let last_col = ncols - 1;
835        match self.selection {
836            Selection::Cell(row, col) => {
837                let nr = (row as i32 + dy).clamp(0, last_row) as usize;
838                let nc = (col as i32 + dx).clamp(0, last_col) as usize;
839                self.selection = Selection::Cell(nr, nc);
840            }
841            Selection::Row(row) if dy != 0 => {
842                let nr = (row as i32 + dy).clamp(0, last_row) as usize;
843                self.selection = Selection::Row(nr);
844            }
845            Selection::Column(col) if dx != 0 => {
846                let nc = (col as i32 + dx).clamp(0, last_col) as usize;
847                self.selection = Selection::Column(nc);
848            }
849            _ => self.selection = Selection::Cell(0, 0),
850        }
851    }
852
853    pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
854        let bounds = self.bounds;
855        let (sx, sy) = (
856            f32::from(self.scroll_handle.offset().x),
857            f32::from(self.scroll_handle.offset().y),
858        );
859        let bw: f32 = bounds.size.width.into();
860        let bh: f32 = bounds.size.height.into();
861        let (mx, my) = self.max_scroll();
862        if let Some(menu) = &self.context_menu {
863            let cw = self.char_width;
864            let (x_rel, y_rel) = screen_to_content(pos, bounds.origin, self.scroll_handle.offset());
865            if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
866                return HitResult::ContextMenuItem(idx);
867            }
868        }
869        if my > 0.0
870            && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
871            && f32::from(pos.y) >= self.header_height
872        {
873            return HitResult::VerticalScrollbar;
874        }
875        if mx > 0.0
876            && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
877            && f32::from(pos.x) >= self.row_header_width
878        {
879            return HitResult::HorizontalScrollbar;
880        }
881        let (cx, cy) = screen_to_content(pos, bounds.origin, self.scroll_handle.offset());
882        if cx < 0.0 || cy < 0.0 || cx > bw || cy > bh {
883            return HitResult::None;
884        }
885        self.hit_test_content(cx, cy, sx, sy)
886    }
887
888    fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
889        if y < self.header_height {
890            if x < self.row_header_width {
891                return HitResult::Corner;
892            }
893            let col_x = x - self.row_header_width + sx;
894            let mut acc = 0.0;
895            for (i, col) in self.data.columns.iter().enumerate() {
896                let right = acc + col.width;
897                if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
898                    return HitResult::ColumnBorder(i);
899                }
900                if col_x >= acc && col_x < right {
901                    if col_x >= right - 20.0 {
902                        return HitResult::SortButton(i);
903                    }
904                    return HitResult::ColumnHeader(i);
905                }
906                acc = right;
907            }
908            return HitResult::None;
909        }
910        if x < self.row_header_width {
911            let row_y = y - self.header_height + sy;
912            if row_y < 0.0 {
913                return HitResult::None;
914            }
915            let row_idx = (row_y / self.row_height) as usize;
916            if row_idx < self.display_indices.len() {
917                return HitResult::RowHeader(row_idx);
918            }
919            return HitResult::None;
920        }
921        let col_x = x - self.row_header_width + sx;
922        let row_y = y - self.header_height + sy;
923        if row_y < 0.0 {
924            return HitResult::None;
925        }
926        let row_idx = (row_y / self.row_height) as usize;
927        if row_idx >= self.display_indices.len() {
928            return HitResult::None;
929        }
930        let mut acc = 0.0;
931        for (i, col) in self.data.columns.iter().enumerate() {
932            if col_x >= acc && col_x < acc + col.width {
933                return HitResult::Cell(row_idx, i);
934            }
935            acc += col.width;
936        }
937        HitResult::None
938    }
939
940    #[must_use]
941    pub fn wants_edge_scroll_tick(&self) -> bool {
942        self.is_dragging
943    }
944}
945
946fn keystroke_to_char(k: &Keystroke) -> Option<char> {
947    if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
948        return None;
949    }
950    if let Some(key_char) = k.key_char.as_ref() {
951        return key_char.chars().next();
952    }
953    if k.key.chars().count() == 1 {
954        let c = k.key.chars().next()?;
955        if k.modifiers.shift {
956            Some(c.to_ascii_uppercase())
957        } else {
958            Some(c)
959        }
960    } else {
961        None
962    }
963}
964
965#[cfg(test)]
966#[allow(
967    clippy::unwrap_used,
968    clippy::expect_used,
969    clippy::field_reassign_with_default
970)]
971mod tests {
972    use super::*;
973    use crate::data::{CellValue, Column, ColumnKind};
974    use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
975
976    fn anchor() -> Point<Pixels> {
977        Point {
978            x: px(0.0),
979            y: px(0.0),
980        }
981    }
982
983    fn prompt_with(text: &str, cursor: usize) -> FilterPrompt {
984        let mut p = FilterPrompt::new(0, anchor(), text.to_owned());
985        p.cursor_chars = cursor;
986        p
987    }
988
989    #[test]
990    fn filter_prompt_new_cursors_at_char_count_not_bytes() {
991        // "hé🙂" is 3 chars but 7 bytes (h=1, é=2, 🙂=4).
992        let p = FilterPrompt::new(0, anchor(), "hé🙂".into());
993        assert_eq!(p.cursor_chars, 3);
994        assert_eq!(p.input.len(), 7);
995    }
996
997    #[test]
998    fn filter_prompt_insert_emoji_at_start_does_not_panic() {
999        let mut p = prompt_with("ab", 0);
1000        p.insert_char('\u{1F600}');
1001        assert_eq!(p.input, "\u{1F600}ab");
1002        assert_eq!(p.cursor_chars, 1);
1003    }
1004
1005    #[test]
1006    fn filter_prompt_insert_in_middle_keeps_cursor_at_char_position() {
1007        let mut p = prompt_with("helloworld", 5);
1008        p.insert_char(' ');
1009        assert_eq!(p.input, "hello world");
1010        assert_eq!(p.cursor_chars, 6);
1011    }
1012
1013    #[test]
1014    fn filter_prompt_backspace_at_zero_is_noop() {
1015        let mut p = prompt_with("abc", 0);
1016        p.backspace();
1017        assert_eq!(p.input, "abc");
1018        assert_eq!(p.cursor_chars, 0);
1019    }
1020
1021    #[test]
1022    fn filter_prompt_backspace_removes_one_char_value() {
1023        // Cursor sits after "hé" (2 chars); backspace should delete "é" only.
1024        let mut p = prompt_with("héx", 2);
1025        p.backspace();
1026        assert_eq!(p.input, "hx");
1027        assert_eq!(p.cursor_chars, 1);
1028    }
1029
1030    #[test]
1031    fn filter_prompt_clamp_cursor_pulls_back_past_end() {
1032        let mut p = prompt_with("abc", 99);
1033        p.clamp_cursor();
1034        assert_eq!(p.cursor_chars, 3);
1035    }
1036
1037    #[test]
1038    fn edge_scroll_speed_stops_outside_band() {
1039        assert_eq!(edge_scroll_speed(200.0), 0.0);
1040        assert_eq!(edge_scroll_speed(-100.0), 80.0); // clamps at cap
1041        assert_eq!(edge_scroll_speed(0.0), 12.0); // < 25
1042        assert_eq!(edge_scroll_speed(24.99), 12.0);
1043        assert_eq!(edge_scroll_speed(25.0), 6.0); // < 50
1044        assert_eq!(edge_scroll_speed(49.99), 6.0);
1045        assert_eq!(edge_scroll_speed(50.0), 3.0); // < 100
1046        assert_eq!(edge_scroll_speed(99.99), 3.0);
1047        assert_eq!(edge_scroll_speed(100.0), 1.0); // < 150
1048        assert_eq!(edge_scroll_speed(149.99), 1.0);
1049    }
1050
1051    #[test]
1052    fn edge_scroll_speed_caps_negative_runaway() {
1053        // -1000 should saturate to (24 + 600).min(80) = 80.
1054        assert_eq!(edge_scroll_speed(-1000.0), 80.0);
1055    }
1056
1057    /// `GridState` requires a real GPUI `FocusHandle` from
1058    /// `gpui::Application`, but `gpui::Application::new()` panics on any
1059    /// thread other than `main`. Since Rust's test runner executes on a
1060    /// worker pool, the GPUI-backed assertions cannot run alongside pure
1061    /// tests. We mark this test `#[ignore]` so `cargo test` stays green; run
1062    /// it with `cargo test -- --ignored grid_state_behavior_under_application`
1063    /// from the workspace root on the test thread observable to GPUI.
1064    #[allow(clippy::expect_used, clippy::unwrap_used)]
1065    #[test]
1066    #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1067    fn grid_state_behavior_under_application() {
1068        gpui::Application::new().run(|cx| {
1069            let focus = cx.focus_handle();
1070
1071            // format_current_status_handles_initial_state
1072            let mut state = GridState::new(
1073                GridData::new(
1074                    vec![Column::new("n", ColumnKind::Integer, 100.0)],
1075                    vec![vec![CellValue::Integer(1)]],
1076                )
1077                .expect("rectangular"),
1078                crate::config::GridConfig::default(),
1079                focus.clone(),
1080            );
1081            let _ = format_current_status(&state);
1082            assert_eq!(state.selection, Selection::None);
1083
1084            // format_current_status_replaces_with_supplied_pos
1085            state.last_mouse_pos = Some(Point {
1086                x: px(120.0),
1087                y: px(80.0),
1088            });
1089            let s = format_current_status(&state);
1090            assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
1091
1092            // recompute_filters_then_sorts_then_clears
1093            let mut state = GridState::new(
1094                GridData::new(
1095                    vec![Column::new("name", ColumnKind::Text, 100.0)],
1096                    vec![
1097                        vec![CellValue::Text("alpha".into())],
1098                        vec![CellValue::Text("beta".into())],
1099                        vec![CellValue::Text("gamma".into())],
1100                    ],
1101                )
1102                .expect("rectangular"),
1103                crate::config::GridConfig::default(),
1104                focus.clone(),
1105            );
1106            state.filters[0] = "a".into();
1107            state.toggle_sort(0);
1108            state.recompute();
1109            assert_eq!(state.display_indices, vec![0, 2]);
1110            state.toggle_sort(0);
1111            state.recompute();
1112            assert_eq!(state.display_indices, vec![2, 0]);
1113            state.filters[0].clear();
1114            state.toggle_sort(0);
1115            state.recompute();
1116            assert_eq!(state.display_indices, vec![0, 1, 2]);
1117
1118            // toggle_sort_cycles_through_three_states
1119            let mut state = GridState::new(
1120                GridData::new(
1121                    vec![Column::new("v", ColumnKind::Integer, 80.0)],
1122                    vec![vec![CellValue::Integer(1)]],
1123                )
1124                .expect("rectangular"),
1125                crate::config::GridConfig::default(),
1126                focus.clone(),
1127            );
1128            state.toggle_sort(0);
1129            assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
1130            state.toggle_sort(0);
1131            assert_eq!(state.sort, Some((0, SortDirection::Descending)));
1132            state.toggle_sort(0);
1133            assert_eq!(state.sort, None);
1134
1135            // select_all_picks_full_range_when_data_present
1136            let mut state = GridState::new(
1137                GridData::new(
1138                    vec![
1139                        Column::new("a", ColumnKind::Integer, 80.0),
1140                        Column::new("b", ColumnKind::Integer, 80.0),
1141                    ],
1142                    vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
1143                )
1144                .expect("rectangular"),
1145                crate::config::GridConfig::default(),
1146                focus.clone(),
1147            );
1148            state.select_all();
1149            assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
1150
1151            // select_all_is_noop_on_empty
1152            let mut state = GridState::new(
1153                GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
1154                    .expect("rectangular"),
1155                crate::config::GridConfig::default(),
1156                focus.clone(),
1157            );
1158            state.select_all();
1159            assert_eq!(state.selection, Selection::None);
1160
1161            // set_config_refreshes_resolved_formats
1162            let mut state = GridState::new(
1163                GridData::new(
1164                    vec![Column::new("v", ColumnKind::Decimal, 100.0)],
1165                    vec![vec![CellValue::Decimal(1.234)]],
1166                )
1167                .expect("rectangular"),
1168                crate::config::GridConfig::default(),
1169                focus.clone(),
1170            );
1171            assert_eq!(state.resolved_formats[0].number.decimals, 2);
1172            let mut cfg = crate::config::GridConfig::default();
1173            cfg.column_overrides = vec![crate::config::ColumnOverride {
1174                number: Some(crate::config::NumberFormat {
1175                    decimals: 6,
1176                    ..Default::default()
1177                }),
1178                ..Default::default()
1179            }];
1180            state.set_config(cfg);
1181            assert_eq!(state.resolved_formats[0].number.decimals, 6);
1182
1183            // wants_edge_scroll_tick_mirrors_is_dragging
1184            let mut state = GridState::new(
1185                GridData::new(
1186                    vec![Column::new("a", ColumnKind::Integer, 80.0)],
1187                    vec![vec![CellValue::Integer(1)]],
1188                )
1189                .expect("rectangular"),
1190                crate::config::GridConfig::default(),
1191                focus.clone(),
1192            );
1193            assert!(!state.wants_edge_scroll_tick());
1194            state.is_dragging = true;
1195            assert!(state.wants_edge_scroll_tick());
1196
1197            cx.quit();
1198        });
1199    }
1200}