Skip to main content

densa_paragraph/
selection.rs

1use std::ops::Range;
2
3use densa_core::{Color, Point, Rect};
4use parley::{BoundingBox, Cursor, Layout, Selection};
5
6use crate::Paragraph;
7
8/// A way to move a caret or extend a selection.
9///
10/// `Left`/`Right` and their word forms move on screen (arrow keys): in right-to-left text
11/// `Left` moves forward through the text. `Previous`/`Next` and their word forms move in text
12/// order, as deleting does (Backspace removes the logically previous character, wherever it
13/// is drawn).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum Motion {
17    /// One character left on screen.
18    Left,
19    /// One character right on screen.
20    Right,
21    /// To the next word boundary left on screen.
22    WordLeft,
23    /// To the next word boundary right on screen.
24    WordRight,
25    /// One character back in text order.
26    Previous,
27    /// One character forward in text order.
28    Next,
29    /// To the previous word boundary in text order.
30    PreviousWord,
31    /// To the next word boundary in text order.
32    NextWord,
33    /// One line up, keeping the horizontal position (the start of the text from the first
34    /// line).
35    Up,
36    /// One line down, keeping the horizontal position (the end of the text from the last
37    /// line).
38    Down,
39    /// To the start of the visual line.
40    LineStart,
41    /// To the end of the visual line.
42    LineEnd,
43    /// To the start of the text.
44    TextStart,
45    /// To the end of the text.
46    TextEnd,
47}
48
49/// Which character a caret at a position belongs to, where that matters: at a line wrap
50/// (end of one line or start of the next) and at a change of text direction.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
52#[non_exhaustive]
53pub enum Affinity {
54    /// With the character after the position (start of the next line at a wrap).
55    #[default]
56    Downstream,
57    /// With the character before the position (end of the line at a wrap): where a caret
58    /// stays after typing at the end of a wrapped line.
59    Upstream,
60}
61
62impl From<Affinity> for parley::Affinity {
63    fn from(affinity: Affinity) -> Self {
64        match affinity {
65            Affinity::Downstream => parley::Affinity::Downstream,
66            Affinity::Upstream => parley::Affinity::Upstream,
67        }
68    }
69}
70
71fn from_parley(affinity: parley::Affinity) -> Affinity {
72    match affinity {
73        parley::Affinity::Upstream => Affinity::Upstream,
74        parley::Affinity::Downstream => Affinity::Downstream,
75    }
76}
77
78/// A caret (collapsed) or a selected range within a [`Paragraph`].
79///
80/// Positions are byte indices into the paragraph's text, on character (grapheme cluster)
81/// boundaries. A selection belongs to the paragraph it was made from: indices read from it
82/// ([`range`](Self::range), [`anchor`](Self::anchor), [`focus`](Self::focus)) are
83/// meaningless for another text, and after [`Paragraph::reflow`] (or to carry positions to a
84/// new paragraph) call [`refreshed`](Self::refreshed). Everything here is allocation-free.
85///
86/// Equality compares anchor and focus positions with their affinities; the sticky
87/// horizontal position used by `Up`/`Down` and the word/line granularity of a drag are not
88/// compared.
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
90pub struct TextSelection {
91    inner: Selection,
92}
93
94impl TextSelection {
95    /// A caret before the character at byte `index` (clamped to the text).
96    pub fn caret_at(paragraph: &Paragraph, index: usize) -> Self {
97        Self::caret_with(paragraph, index, Affinity::Downstream)
98    }
99
100    /// A caret at byte `index` (clamped to the text) with the given `affinity`.
101    pub fn caret_with(paragraph: &Paragraph, index: usize, affinity: Affinity) -> Self {
102        let cursor = cursor(paragraph, index, affinity);
103        Self {
104            inner: Selection::new(cursor, cursor),
105        }
106    }
107
108    /// The range from `anchor` to `focus` (byte indices, clamped to the text; `focus` may
109    /// come first): a programmatic selection, such as a search result or an undo restore.
110    pub fn from_range(paragraph: &Paragraph, anchor: usize, focus: usize) -> Self {
111        let anchor = cursor(paragraph, anchor, Affinity::Downstream);
112        let focus = cursor(paragraph, focus, Affinity::Upstream);
113        Self {
114            inner: Selection::new(anchor, focus),
115        }
116    }
117
118    /// The whole text, focus at the end.
119    pub fn select_all(paragraph: &Paragraph) -> Self {
120        Self::from_range(paragraph, 0, paragraph.text_len())
121    }
122
123    /// A caret at the text position nearest `point` (relative to the paragraph's top-left
124    /// corner): what a click places.
125    pub fn at_point(paragraph: &Paragraph, point: Point) -> Self {
126        Self {
127            inner: Selection::from_point(paragraph.layout(), point.x, point.y),
128        }
129    }
130
131    /// The word under `point` (a double click).
132    pub fn word_at_point(paragraph: &Paragraph, point: Point) -> Self {
133        Self {
134            inner: Selection::word_from_point(paragraph.layout(), point.x, point.y),
135        }
136    }
137
138    /// The visual line under `point` (a triple click).
139    pub fn line_at_point(paragraph: &Paragraph, point: Point) -> Self {
140        Self {
141            inner: Selection::line_from_point(paragraph.layout(), point.x, point.y),
142        }
143    }
144
145    /// The selection with its focus dragged to the position nearest `point`. A selection
146    /// begun by word or line keeps extending by whole words or lines.
147    #[must_use]
148    pub fn extend_to_point(self, paragraph: &Paragraph, point: Point) -> Self {
149        Self {
150            inner: self
151                .inner
152                .extend_to_point(paragraph.layout(), point.x, point.y),
153        }
154    }
155
156    /// The selection extended to `point` by a shift+click: the focus lands where clicked,
157    /// and a word or line selection keeps the far side of its word or line as the anchor.
158    #[must_use]
159    pub fn shift_click(self, paragraph: &Paragraph, point: Point) -> Self {
160        Self {
161            inner: self
162                .inner
163                .shift_click_extension(paragraph.layout(), point.x, point.y),
164        }
165    }
166
167    /// The selection after `motion`: the caret moves, or with `extend` the focus moves and
168    /// the anchor stays (shift + navigation keys).
169    #[must_use]
170    pub fn moved(self, paragraph: &Paragraph, motion: Motion, extend: bool) -> Self {
171        let layout = paragraph.layout();
172        let s = self.inner;
173        let inner = match motion {
174            Motion::Left => s.previous_visual(layout, extend),
175            Motion::Right => s.next_visual(layout, extend),
176            Motion::WordLeft => s.previous_visual_word(layout, extend),
177            Motion::WordRight => s.next_visual_word(layout, extend),
178            Motion::Up => s.previous_line(layout, extend),
179            Motion::Down => s.next_line(layout, extend),
180            Motion::LineStart => s.line_start(layout, extend),
181            Motion::LineEnd => s.line_end(layout, extend),
182            Motion::Previous => to(s, previous_logical(layout, s.focus()), extend),
183            Motion::Next => to(s, next_logical(layout, s.focus()), extend),
184            Motion::PreviousWord => to(s, s.focus().previous_logical_word(layout), extend),
185            Motion::NextWord => to(s, s.focus().next_logical_word(layout), extend),
186            Motion::TextStart => to(s, cursor(paragraph, 0, Affinity::Downstream), extend),
187            Motion::TextEnd => {
188                let end = cursor(paragraph, paragraph.text_len(), Affinity::Downstream);
189                to(s, end, extend)
190            }
191        };
192        Self { inner }
193    }
194
195    /// The same anchor and focus (clamped to `paragraph`'s text), with everything derived
196    /// from the layout recomputed: call after [`Paragraph::reflow`] or to carry a selection to
197    /// a new paragraph. Pixel-based state (the sticky column of `Up`/`Down`, a word or line
198    /// drag's granularity) is dropped, since the lines it was measured on are gone.
199    #[must_use]
200    pub fn refreshed(self, paragraph: &Paragraph) -> Self {
201        let clamp = |c: Cursor| cursor(paragraph, c.index(), from_parley(c.affinity()));
202        let anchor = clamp(self.inner.anchor());
203        let focus = clamp(self.inner.focus());
204        Self {
205            inner: Selection::new(anchor, focus).refresh(paragraph.layout()),
206        }
207    }
208
209    /// A caret at the focus.
210    #[must_use]
211    pub fn collapsed(self) -> Self {
212        Self {
213            inner: self.inner.collapse(),
214        }
215    }
216
217    /// Whether nothing is selected (a caret).
218    pub fn is_caret(&self) -> bool {
219        self.inner.is_collapsed()
220    }
221
222    /// The selected byte range, in text order.
223    pub fn range(&self) -> Range<usize> {
224        self.inner.text_range()
225    }
226
227    /// Where the selection started (stays put while extending).
228    pub fn anchor(&self) -> usize {
229        self.inner.anchor().index()
230    }
231
232    /// Where the selection ends: the caret's position.
233    pub fn focus(&self) -> usize {
234        self.inner.focus().index()
235    }
236
237    /// The caret's affinity at the focus.
238    pub fn focus_affinity(&self) -> Affinity {
239        from_parley(self.inner.focus().affinity())
240    }
241
242    /// The caret's rectangle at the focus, `width` pixels wide, relative to the paragraph's
243    /// top-left corner.
244    pub fn caret_rect(&self, paragraph: &Paragraph, width: f32) -> Rect {
245        to_rect(self.inner.focus().geometry(paragraph.layout(), width))
246    }
247
248    /// Calls `f` with each rectangle covering the selection (one or more per line), relative
249    /// to the paragraph's top-left corner. Nothing for a caret.
250    pub fn for_each_rect(&self, paragraph: &Paragraph, mut f: impl FnMut(Rect)) {
251        self.inner
252            .geometry_with(paragraph.layout(), |bounds, _line| f(to_rect(bounds)));
253    }
254}
255
256/// Where Backspace with the caret at byte `index` starts deleting: the previous character
257/// (code point), so a combining accent comes off its letter as in other editors, except that
258/// a line break or an emoji is deleted whole.
259pub(crate) fn backspace_start(paragraph: &Paragraph, text: &str, index: usize) -> usize {
260    let at = cursor(paragraph, index, Affinity::Downstream);
261    let [upstream, _] = at.logical_clusters(paragraph.layout());
262    let Some(cluster) = upstream else {
263        return index;
264    };
265    if cluster.is_hard_line_break() || cluster.is_emoji() {
266        return cluster.text_range().start;
267    }
268    text.get(..index)
269        .and_then(|before| before.char_indices().next_back())
270        .map_or(index, |(start, _)| start)
271}
272
273/// A cursor at byte `index` clamped to the text.
274fn cursor(paragraph: &Paragraph, index: usize, affinity: Affinity) -> Cursor {
275    let index = index.min(paragraph.text_len());
276    Cursor::from_byte_index(paragraph.layout(), index, affinity.into())
277}
278
279/// `selection` moved to `target`: a caret there, or the focus there if `extend`.
280fn to(selection: Selection, target: Cursor, extend: bool) -> Selection {
281    if extend {
282        selection.extend(target)
283    } else {
284        Selection::new(target, target)
285    }
286}
287
288/// The start of the character (grapheme cluster) logically before `at`.
289fn previous_logical(layout: &Layout<Color>, at: Cursor) -> Cursor {
290    let [upstream, _] = at.logical_clusters(layout);
291    upstream.map_or(at, |cluster| {
292        let start = cluster.text_range().start;
293        Cursor::from_byte_index(layout, start, parley::Affinity::Downstream)
294    })
295}
296
297/// The end of the character (grapheme cluster) logically after `at`.
298fn next_logical(layout: &Layout<Color>, at: Cursor) -> Cursor {
299    let [_, downstream] = at.logical_clusters(layout);
300    downstream.map_or(at, |cluster| {
301        let end = cluster.text_range().end;
302        Cursor::from_byte_index(layout, end, parley::Affinity::Upstream)
303    })
304}
305
306fn to_rect(b: BoundingBox) -> Rect {
307    // Narrowing: parley widens the f32 layout coordinates to f64; converting back is exact
308    // for those values.
309    Rect::from_edges(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32)
310}