Skip to main content

gpui_base/
touch_selection.rs

1//! Touch selection geometry shared by the input engine and window text
2//! selection.
3//!
4//! A touch selection is one made by a long press. It differs from a pointer
5//! selection in what the user needs afterwards: a grab handle at each end to
6//! adjust it, since a finger cannot hover an I-beam, and an edit menu next to
7//! it, since there is no right click. Base owns the gesture, the handle drag
8//! and the menu lifecycle; this module carries what a presentation layer needs
9//! to draw them. The handles and the menu themselves are drawn by the styled
10//! layer, which also decides how large a handle's touch target is.
11
12use gpui::{Bounds, Hsla, Pixels, Point, Window, fill, point, px, size};
13
14/// One end of a selection, as a touch handle grabs it.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum SelectionEdge {
17    /// The end before the first selected character.
18    Start,
19    /// The end after the last selected character.
20    End,
21}
22
23impl SelectionEdge {
24    /// The end that stays put while this one is dragged.
25    pub const fn opposite(self) -> Self {
26        match self {
27            Self::Start => Self::End,
28            Self::End => Self::Start,
29        }
30    }
31}
32
33/// Where a touch selection's ends are laid out this frame, and what the
34/// gesture is doing to them.
35///
36/// Each end is the caret line box at that end in window coordinates: zero
37/// width, one line tall, at the character boundary. An empty selection has
38/// both ends at the caret. Built only by Base; a presentation layer reads it
39/// to draw the handles and place the edit menu.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub struct TouchSelectionSnapshot {
42    start: Bounds<Pixels>,
43    end: Bounds<Pixels>,
44    /// Whether each end is inside its owner's viewport. An end scrolled out
45    /// of view keeps its geometry, for the drag that holds the other end,
46    /// but gets no handle.
47    start_visible: bool,
48    end_visible: bool,
49    menu_open: bool,
50    dragging: Option<SelectionEdge>,
51}
52
53impl TouchSelectionSnapshot {
54    pub(crate) const fn new(start: Bounds<Pixels>, end: Bounds<Pixels>) -> Self {
55        Self {
56            start,
57            end,
58            start_visible: true,
59            end_visible: true,
60            menu_open: false,
61            dragging: None,
62        }
63    }
64
65    pub(crate) const fn with_edge_visible(mut self, edge: SelectionEdge, visible: bool) -> Self {
66        match edge {
67            SelectionEdge::Start => self.start_visible = visible,
68            SelectionEdge::End => self.end_visible = visible,
69        }
70        self
71    }
72
73    pub(crate) const fn with_menu_open(mut self, menu_open: bool) -> Self {
74        self.menu_open = menu_open;
75        self
76    }
77
78    pub(crate) const fn with_dragging(mut self, dragging: Option<SelectionEdge>) -> Self {
79        self.dragging = dragging;
80        self
81    }
82
83    /// The caret line box before the first selected character.
84    pub const fn start(&self) -> Bounds<Pixels> {
85        self.start
86    }
87
88    /// The caret line box after the last selected character.
89    pub const fn end(&self) -> Bounds<Pixels> {
90        self.end
91    }
92
93    /// The caret line box at the given end.
94    pub const fn edge(&self, edge: SelectionEdge) -> Bounds<Pixels> {
95        match edge {
96            SelectionEdge::Start => self.start,
97            SelectionEdge::End => self.end,
98        }
99    }
100
101    /// Whether the given end is in view. A handle is drawn only for an end
102    /// that is; the menu anchors to the ends that are.
103    pub const fn is_edge_visible(&self, edge: SelectionEdge) -> bool {
104        match edge {
105            SelectionEdge::Start => self.start_visible,
106            SelectionEdge::End => self.end_visible,
107        }
108    }
109
110    /// Whether the selection is a bare caret, which gets a menu but no handles.
111    pub fn is_empty(&self) -> bool {
112        self.start == self.end
113    }
114
115    /// The smallest box holding the ends in view, for anchoring the edit
116    /// menu. `None` when the whole selection is scrolled away.
117    pub fn bounds(&self) -> Option<Bounds<Pixels>> {
118        match (self.start_visible, self.end_visible) {
119            (true, true) => Some(self.start.union(&self.end)),
120            (true, false) => Some(self.start),
121            (false, true) => Some(self.end),
122            (false, false) => None,
123        }
124    }
125
126    /// Whether the edit menu is open. It closes while a handle is dragged and
127    /// reopens when the drag ends.
128    pub const fn is_menu_open(&self) -> bool {
129        self.menu_open
130    }
131
132    /// The end currently being dragged by its handle.
133    pub const fn dragging(&self) -> Option<SelectionEdge> {
134        self.dragging
135    }
136}
137
138/// The grab handle at one end of a touch selection: a bar down the caret
139/// line with a knob at its outer end, the start's above and the end's below.
140///
141/// Base paints the handle where it belongs in the paint order — inside the
142/// text that owns the selection, so that whatever covers the text covers
143/// the handle — and only with the selection's own color. Its shape is the
144/// one every platform draws; a styled layer supplies the color.
145pub struct TouchHandle;
146
147impl TouchHandle {
148    /// How wide the finger may miss the knob and still take it.
149    pub const HIT_SIZE: Pixels = px(44.);
150    /// The knob's diameter.
151    pub const KNOB_SIZE: Pixels = px(10.);
152    /// The bar down the caret line.
153    pub const BAR_WIDTH: Pixels = px(2.);
154    /// Room the knob takes beyond the line, which an edit menu keeps clear.
155    pub const EXTENT: Pixels = px(12.);
156
157    /// The touch target, centered on the caret and reaching out past the knob.
158    pub fn hit_bounds(edge: SelectionEdge, caret: Bounds<Pixels>) -> Bounds<Pixels> {
159        let top = match edge {
160            SelectionEdge::Start => caret.top() - Self::EXTENT,
161            SelectionEdge::End => caret.top(),
162        };
163        Bounds::new(
164            point(caret.left() - Self::HIT_SIZE / 2., top),
165            size(Self::HIT_SIZE, caret.size.height + Self::EXTENT),
166        )
167    }
168
169    /// Paints the handle for `edge` on the caret line box `caret`.
170    pub fn paint(edge: SelectionEdge, caret: Bounds<Pixels>, color: Hsla, window: &mut Window) {
171        let bar = Bounds::new(
172            point(caret.left() - Self::BAR_WIDTH / 2., caret.top()),
173            size(Self::BAR_WIDTH, caret.size.height),
174        );
175        let knob_top = match edge {
176            SelectionEdge::Start => caret.top() - Self::KNOB_SIZE,
177            SelectionEdge::End => caret.bottom(),
178        };
179        let knob = Bounds::new(
180            point(caret.left() - Self::KNOB_SIZE / 2., knob_top),
181            size(Self::KNOB_SIZE, Self::KNOB_SIZE),
182        );
183        window.paint_quad(fill(bar, color));
184        window.paint_quad(fill(knob, color).corner_radii(Self::KNOB_SIZE / 2.));
185    }
186}
187
188/// The caret line box at `position`, for reporting a selection end.
189pub(crate) fn caret_line_box(position: Point<Pixels>, line_height: Pixels) -> Bounds<Pixels> {
190    Bounds::new(position, size(px(0.), line_height))
191}
192
193/// Whether a caret line box shows inside `viewport`: its line overlaps the
194/// viewport vertically and its x lies within it. A zero-width box never
195/// intersects anything, so this is not `Bounds::intersects`.
196pub(crate) fn caret_in_view(caret: Bounds<Pixels>, viewport: Bounds<Pixels>) -> bool {
197    caret.bottom() > viewport.top()
198        && caret.top() < viewport.bottom()
199        && caret.left() >= viewport.left()
200        && caret.left() <= viewport.right()
201}
202
203/// Maps a finger to the text position a handle drag selects.
204///
205/// The knob a finger holds sits above or below the line, so the finger itself
206/// is never over the text it moves. The offset from the finger to the caret
207/// box is captured when the drag begins and kept for the rest of it; the
208/// mapped point then picks lines exactly as a pointer does, crossing into the
209/// line above or below at its edge.
210#[derive(Clone, Copy, Debug, PartialEq)]
211pub(crate) struct EdgeDrag {
212    edge: SelectionEdge,
213    offset: Point<Pixels>,
214}
215
216impl EdgeDrag {
217    pub(crate) fn begin(edge: SelectionEdge, caret: Bounds<Pixels>, finger: Point<Pixels>) -> Self {
218        Self {
219            edge,
220            offset: caret.center() - finger,
221        }
222    }
223
224    pub(crate) const fn edge(&self) -> SelectionEdge {
225        self.edge
226    }
227
228    /// The finger dragged its end past the other one; it now holds that one.
229    pub(crate) fn set_edge(&mut self, edge: SelectionEdge) {
230        self.edge = edge;
231    }
232
233    /// The text position the finger points at.
234    pub(crate) fn text_position(&self, finger: Point<Pixels>) -> Point<Pixels> {
235        point(finger.x + self.offset.x, finger.y + self.offset.y)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn snapshot_reports_bounds_and_edges() {
245        let start = caret_line_box(point(px(10.), px(20.)), px(16.));
246        let end = caret_line_box(point(px(80.), px(52.)), px(16.));
247        let snapshot = TouchSelectionSnapshot::new(start, end)
248            .with_menu_open(true)
249            .with_dragging(Some(SelectionEdge::End));
250
251        assert_eq!(snapshot.edge(SelectionEdge::Start), start);
252        assert_eq!(snapshot.edge(SelectionEdge::End), end);
253        assert!(!snapshot.is_empty());
254        assert!(snapshot.is_menu_open());
255        assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
256        assert_eq!(
257            snapshot.bounds(),
258            Some(Bounds::from_corners(
259                point(px(10.), px(20.)),
260                point(px(80.), px(68.))
261            ))
262        );
263
264        let caret = TouchSelectionSnapshot::new(start, start);
265        assert!(caret.is_empty());
266    }
267
268    #[test]
269    fn a_scrolled_away_end_keeps_its_geometry_but_no_handle() {
270        let start = caret_line_box(point(px(10.), px(-30.)), px(16.));
271        let end = caret_line_box(point(px(80.), px(52.)), px(16.));
272        let viewport = Bounds::new(point(px(0.), px(0.)), size(px(200.), px(100.)));
273        assert!(!caret_in_view(start, viewport));
274        assert!(caret_in_view(end, viewport));
275
276        let snapshot =
277            TouchSelectionSnapshot::new(start, end).with_edge_visible(SelectionEdge::Start, false);
278        assert!(!snapshot.is_edge_visible(SelectionEdge::Start));
279        assert_eq!(snapshot.edge(SelectionEdge::Start), start);
280        assert_eq!(snapshot.bounds(), Some(end));
281
282        let gone = snapshot.with_edge_visible(SelectionEdge::End, false);
283        assert_eq!(gone.bounds(), None);
284    }
285
286    #[test]
287    fn edge_drag_keeps_the_finger_offset() {
288        let caret = caret_line_box(point(px(100.), px(40.)), px(20.));
289        let drag = EdgeDrag::begin(SelectionEdge::End, caret, point(px(102.), px(72.)));
290        assert_eq!(drag.edge(), SelectionEdge::End);
291        // The finger started 22px below the caret's center; it stays there.
292        assert_eq!(
293            drag.text_position(point(px(150.), px(90.))),
294            point(px(148.), px(68.))
295        );
296    }
297}