Skip to main content

sqlly_datatable/grid/
selection.rs

1//! Pure selection / hit-testing types and helpers. Kept separate from the
2//! stateful widget so paint, input, and copy code can all use the same
3//! predicates without circular dependencies.
4
5use gpui::Point;
6
7/// What is currently selected. Stores display-row indices; after a sort the
8/// "same row" might live at a different position, so callers needing stable
9/// identities should track source rows separately.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum Selection {
12    None,
13    Cell(usize, usize),
14    Row(usize),
15    Column(usize),
16    /// Inclusive `(r1, c1)` to `(r2, c2)`. Always `r1 <= r2 && c1 <= c2`.
17    CellRange(usize, usize, usize, usize),
18    /// Inclusive `[r1, r2]`.
19    RowRange(usize, usize),
20}
21
22impl Selection {
23    /// Returns `(min_row, min_col, max_row, max_col)`. `Selection::None`
24    /// returns `None`.
25    #[must_use]
26    pub fn normalized_bounds(&self) -> Option<(usize, usize, usize, usize)> {
27        match *self {
28            Selection::None => None,
29            Selection::Cell(r, c) => Some((r, c, r, c)),
30            Selection::Row(r) => Some((r, 0, r, usize::MAX)),
31            Selection::Column(c) => Some((0, c, usize::MAX, c)),
32            Selection::CellRange(r1, c1, r2, c2) => {
33                Some((r1.min(r2), c1.min(c2), r1.max(r2), c1.max(c2)))
34            }
35            Selection::RowRange(r1, r2) => Some((r1.min(r2), 0, r1.max(r2), usize::MAX)),
36        }
37    }
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum SortDirection {
42    Ascending,
43    Descending,
44}
45
46/// What a mouse hit-test resolved to.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum HitResult {
49    None,
50    ColumnHeader(usize),
51    SortButton(usize),
52    ColumnBorder(usize),
53    RowHeader(usize),
54    Cell(usize, usize),
55    Corner,
56    ContextMenuItem(usize),
57    VerticalScrollbar,
58    HorizontalScrollbar,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum ScrollbarAxis {
63    Vertical,
64    Horizontal,
65}
66
67/// `true` if the selection visually highlights the given cell.
68#[must_use]
69pub fn is_cell_selected(sel: &Selection, row: usize, col: usize) -> bool {
70    match *sel {
71        Selection::None => false,
72        Selection::Cell(r, c) => r == row && c == col,
73        Selection::CellRange(r1, c1, r2, c2) => {
74            let (rmin, cmin, rmax, cmax) = (r1.min(r2), c1.min(c2), r1.max(r2), c1.max(c2));
75            row >= rmin && row <= rmax && col >= cmin && col <= cmax
76        }
77        Selection::Row(r) => r == row,
78        Selection::RowRange(r1, r2) => {
79            let (rmin, rmax) = (r1.min(r2), r1.max(r2));
80            row >= rmin && row <= rmax
81        }
82        Selection::Column(c) => c == col,
83    }
84}
85
86#[must_use]
87pub fn is_row_selected(sel: &Selection, row: usize) -> bool {
88    match *sel {
89        Selection::Row(r) => r == row,
90        Selection::RowRange(r1, r2) => {
91            let (rmin, rmax) = (r1.min(r2), r1.max(r2));
92            row >= rmin && row <= rmax
93        }
94        _ => false,
95    }
96}
97
98#[must_use]
99pub fn is_column_selected(sel: &Selection, col: usize) -> bool {
100    matches!(*sel, Selection::Column(c) if c == col)
101}
102
103/// Convert a screen pointer (in window coordinates) to its corresponding
104/// content-space (i.e. bounds-relative plus scroll offset) coordinates.
105#[must_use]
106pub fn screen_to_content(
107    pos: Point<gpui::Pixels>,
108    bounds_origin: Point<gpui::Pixels>,
109    scroll: Point<gpui::Pixels>,
110) -> (f32, f32) {
111    let sx: f32 = scroll.x.into();
112    let sy: f32 = scroll.y.into();
113    let ox: f32 = bounds_origin.x.into();
114    let oy: f32 = bounds_origin.y.into();
115    let px: f32 = pos.x.into();
116    let py: f32 = pos.y.into();
117    (px - ox + sx, py - oy + sy)
118}
119
120/// Translate an absolute window-space pointer into the grid's OWN coordinate
121/// frame by subtracting the grid's painted `bounds.origin`. Every pointer
122/// value the widget hands to [`GridState`] is normalized through this at the
123/// event boundary, so all stored positions (`click_pos`, `drag_start`,
124/// `last_mouse_pos`, menu/prompt anchors) live in one consistent grid-relative
125/// frame regardless of where the widget is nested in the window. A grid at
126/// window origin (as in the sample app and older tests) is the identity case.
127#[must_use]
128pub fn to_grid_relative(
129    pos: Point<gpui::Pixels>,
130    bounds_origin: Point<gpui::Pixels>,
131) -> Point<gpui::Pixels> {
132    Point {
133        x: pos.x - bounds_origin.x,
134        y: pos.y - bounds_origin.y,
135    }
136}
137
138#[cfg(test)]
139#[allow(
140    clippy::unwrap_used,
141    clippy::expect_used,
142    clippy::field_reassign_with_default
143)]
144mod tests {
145    use super::*;
146    use gpui::{px, Pixels};
147
148    fn p(x: f32, y: f32) -> Point<Pixels> {
149        Point { x: px(x), y: px(y) }
150    }
151
152    #[test]
153    fn normalized_bounds_none_is_none() {
154        assert_eq!(Selection::None.normalized_bounds(), None);
155    }
156
157    #[test]
158    fn normalized_bounds_cell_folds_to_single_point() {
159        assert_eq!(
160            Selection::Cell(2, 3).normalized_bounds(),
161            Some((2, 3, 2, 3))
162        );
163    }
164
165    #[test]
166    fn normalized_bounds_row_spans_all_columns() {
167        let (r0, c0, r1, c1) = Selection::Row(4).normalized_bounds().unwrap();
168        assert_eq!(r0, 4);
169        assert_eq!(r1, 4);
170        assert_eq!(c0, 0);
171        assert_eq!(c1, usize::MAX);
172    }
173
174    #[test]
175    fn normalized_bounds_column_spans_all_rows() {
176        let (r0, c0, r1, c1) = Selection::Column(5).normalized_bounds().unwrap();
177        assert_eq!(r0, 0);
178        assert_eq!(r1, usize::MAX);
179        assert_eq!(c0, 5);
180        assert_eq!(c1, 5);
181    }
182
183    #[test]
184    fn normalized_bounds_cell_range_handles_reversed() {
185        assert_eq!(
186            Selection::CellRange(5, 4, 1, 2).normalized_bounds(),
187            Some((1, 2, 5, 4)),
188        );
189    }
190
191    #[test]
192    fn normalized_bounds_row_range_handles_reversed() {
193        let (r0, _c0, r1, c1) = Selection::RowRange(9, 3).normalized_bounds().unwrap();
194        assert_eq!(r0, 3);
195        assert_eq!(r1, 9);
196        assert_eq!(c1, usize::MAX);
197    }
198
199    #[test]
200    fn is_cell_selected_for_all_variants() {
201        assert!(!is_cell_selected(&Selection::None, 0, 0));
202        assert!(is_cell_selected(&Selection::Cell(2, 3), 2, 3));
203        assert!(!is_cell_selected(&Selection::Cell(2, 3), 3, 2));
204
205        assert!(is_cell_selected(&Selection::CellRange(1, 1, 3, 3), 2, 2));
206        assert!(is_cell_selected(&Selection::CellRange(3, 3, 1, 1), 2, 2));
207        assert!(!is_cell_selected(&Selection::CellRange(1, 1, 3, 3), 4, 4));
208
209        assert!(is_cell_selected(&Selection::Row(2), 2, 0));
210        assert!(is_cell_selected(&Selection::Row(2), 2, 99));
211        assert!(!is_cell_selected(&Selection::Row(2), 3, 0));
212
213        assert!(is_cell_selected(&Selection::RowRange(1, 3), 2, 5));
214        assert!(!is_cell_selected(&Selection::RowRange(1, 3), 4, 5));
215        assert!(is_cell_selected(&Selection::RowRange(3, 1), 2, 0));
216
217        assert!(is_cell_selected(&Selection::Column(5), 0, 5));
218        assert!(is_cell_selected(&Selection::Column(5), 99, 5));
219        assert!(!is_cell_selected(&Selection::Column(5), 0, 4));
220    }
221
222    #[test]
223    fn is_row_selected_only_for_row_and_row_range() {
224        assert!(is_row_selected(&Selection::Row(3), 3));
225        assert!(!is_row_selected(&Selection::Row(3), 4));
226        assert!(is_row_selected(&Selection::RowRange(2, 5), 4));
227        assert!(is_row_selected(&Selection::RowRange(5, 2), 4));
228        assert!(!is_row_selected(&Selection::RowRange(2, 5), 6));
229
230        assert!(!is_row_selected(&Selection::Cell(1, 2), 1));
231        assert!(!is_row_selected(&Selection::CellRange(0, 0, 9, 9), 5));
232        assert!(!is_row_selected(&Selection::Column(0), 5));
233        assert!(!is_row_selected(&Selection::None, 0));
234    }
235
236    #[test]
237    fn is_column_selected_only_for_column_variant() {
238        assert!(is_column_selected(&Selection::Column(7), 7));
239        assert!(!is_column_selected(&Selection::Column(7), 8));
240        assert!(!is_column_selected(&Selection::Row(0), 0));
241        assert!(!is_column_selected(&Selection::None, 0));
242        assert!(!is_column_selected(&Selection::CellRange(0, 2, 9, 2), 2));
243    }
244
245    #[test]
246    fn screen_to_content_applies_origin_and_scroll() {
247        let pos = p(50.0, 60.0);
248        let origin = p(10.0, 20.0);
249        let scroll = p(5.0, 7.0);
250        let (cx, cy) = screen_to_content(pos, origin, scroll);
251        assert_eq!(cx, 45.0);
252        assert_eq!(cy, 47.0);
253    }
254
255    #[test]
256    fn screen_to_content_no_offset() {
257        let (cx, cy) = screen_to_content(p(0.0, 0.0), p(0.0, 0.0), p(0.0, 0.0));
258        assert_eq!(cx, 0.0);
259        assert_eq!(cy, 0.0);
260    }
261
262    #[test]
263    fn screen_to_content_handles_negative_above_origin() {
264        // Above-origin and negative-axis positions happen during drag-scroll
265        // and should not panic.
266        let (_, _) = screen_to_content(p(-30.0, -30.0), p(0.0, 0.0), p(0.0, 0.0));
267    }
268}