Skip to main content

i_slint_core/textlayout/sharedparley/
selection.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4// cSpell: ignore bidi
5
6//! Selection geometry, resolved once per draw into per-line horizontal spans.
7//!
8//! The same rounded span edges fill the highlight and clip the glyph runs, so the two can't
9//! disagree -- see [`SelectionSpan`].
10
11use super::*;
12
13/// One contiguous run of selected text on a single line.
14///
15/// Selection is deliberately *not* expressed as a text style. A style can only ever recolor a
16/// whole glyph, but a selection boundary may fall in the middle of one: with an `fi` ligature,
17/// selecting just the `i` leaves parley with a single glyph whose style comes from the cluster's
18/// first character (`Glyph::style_index` is `char_infos[cluster_id]`), so the whole ligature would
19/// be painted unselected while the highlight covers only its right half. Instead the spans below
20/// are used twice — to fill the highlight background, and to clip the glyph runs that straddle a
21/// boundary so each half is drawn in its own color.
22#[derive(Clone, Debug)]
23pub(super) struct SelectionSpan {
24    /// Index into `Layout::paragraphs`.
25    pub(super) paragraph: usize,
26    /// Line within that paragraph.
27    pub(super) line: usize,
28    /// Highlight rectangle in item coordinates, ready to fill. Its horizontal edges are snapped to
29    /// whole device pixels where they are computed, and [`Self::x`] hands the very same edges to
30    /// the glyph clip -- so the highlight edge and the clip edge cannot disagree and leave a sliver
31    /// of wrongly-colored glyph on top of the highlight.
32    background: PhysicalRect,
33}
34
35impl SelectionSpan {
36    /// Horizontal extent of the highlight, in the same coordinate space as `GlyphRun::offset()`.
37    pub(super) fn x(&self) -> Range<f32> {
38        self.background.min_x()..self.background.max_x()
39    }
40}
41
42/// Sorted by `(paragraph, line, x.start)`: the spans belonging to one line form a contiguous slice,
43/// and within that slice they run left to right. Both halves are load-bearing -- see
44/// [`Self::for_line`] and the segment walk in `draw_glyph_run_with_selection`.
45#[derive(Clone, Debug, Default)]
46pub(super) struct SelectionSpans(pub(super) Vec<SelectionSpan>);
47
48impl SelectionSpans {
49    pub(super) fn is_empty(&self) -> bool {
50        self.0.is_empty()
51    }
52
53    pub(super) fn backgrounds(&self) -> impl Iterator<Item = PhysicalRect> + '_ {
54        self.0.iter().map(|span| span.background)
55    }
56
57    /// The spans covering one line. Both the stored spans and the draw loop walk paragraphs and
58    /// lines in order, but a binary search keeps this independent of that ordering.
59    pub(super) fn for_line(&self, paragraph: usize, line: usize) -> &[SelectionSpan] {
60        let key = (paragraph, line);
61        let start = self.0.partition_point(|span| (span.paragraph, span.line) < key);
62        let len = self.0[start..].partition_point(|span| (span.paragraph, span.line) == key);
63        &self.0[start..start + len]
64    }
65}
66
67/// How selected glyphs are painted, resolved once per draw call.
68pub(super) struct SelectionRendering<'a, R: GlyphRenderer> {
69    pub(super) spans: &'a SelectionSpans,
70    /// Forced fill for selected glyphs. It wins over `Brush::override_fill_color` and
71    /// `Brush::link_color`, so a colored span or a link inside the selection still reads as
72    /// selected.
73    pub(super) foreground: <R as GlyphRenderer>::PlatformBrush,
74}
75
76/// How much of one glyph run a selection covers.
77pub(super) enum RunCoverage {
78    /// No selected pixels: draw once, in the run's own brush.
79    Unselected,
80    /// Fully selected: draw once, in the selection foreground. No clip needed.
81    Full,
82    /// A boundary falls inside the run — possibly inside a ligature. The line's spans have to be
83    /// drawn separately, each clipped to its own horizontal band.
84    Partial,
85}
86
87/// Classifies `run_x` against the selection spans of the line it sits on.
88pub(super) fn run_coverage(run_x: &Range<f32>, spans: &[SelectionSpan]) -> RunCoverage {
89    // Empty runs (and the degenerate zero-advance runs parley emits for ligature tails) can't
90    // show a boundary.
91    if spans.is_empty() || run_x.end <= run_x.start {
92        return RunCoverage::Unselected;
93    }
94    let mut overlapping = false;
95    for span in spans {
96        let span_x = span.x();
97        if span_x.start <= run_x.start && span_x.end >= run_x.end {
98            return RunCoverage::Full;
99        }
100        overlapping |= span_x.start < run_x.end && span_x.end > run_x.start;
101    }
102    if overlapping { RunCoverage::Partial } else { RunCoverage::Unselected }
103}
104
105impl Layout {
106    /// Resolves `selection_range` into per-line horizontal spans, for the lines whose boxes may
107    /// intersect `visible_band` (a physical y range in item coordinates; spans feed drawing only,
108    /// so off-screen lines need none). Pass an unbounded band to resolve everything.
109    ///
110    /// Parley already splits a ligature into one cluster per character and apportions the
111    /// advance between them, so the geometry it reports is accurate to sub-glyph precision --
112    /// selecting the `i` of an `fi` ligature yields exactly the ligature's right half. That
113    /// precision is what makes clip-based selection drawing possible; see [`SelectionSpan`].
114    pub(super) fn selection_geometry(
115        &self,
116        selection_range: Range<usize>,
117        visible_band: &Range<PhysicalLength>,
118    ) -> SelectionSpans {
119        let mut spans = Vec::new();
120
121        for (paragraph_index, paragraph) in self.visible_paragraphs().iter().enumerate() {
122            // Like the draw cull, padded by an (average) line height for ink that overhangs its
123            // line box: such a line still draws, so it still needs its spans.
124            let paragraph_top = self.y_offset + paragraph.y;
125            let paragraph_height = PhysicalLength::new(paragraph.layout.height());
126            let line_pad = paragraph_height / paragraph.layout.lines().len().max(1) as f32;
127            if paragraph_top + paragraph_height + line_pad < visible_band.start
128                || paragraph_top - line_pad > visible_band.end
129            {
130                continue;
131            }
132
133            let selection_start = selection_range.start.max(paragraph.range.start);
134            let selection_end = selection_range.end.min(paragraph.range.end);
135
136            if selection_start >= selection_end {
137                continue;
138            }
139
140            let local_start = selection_start - paragraph.range.start;
141            let local_end = selection_end - paragraph.range.start;
142
143            let selection = parley::editing::Selection::new(
144                parley::editing::Cursor::from_byte_index(
145                    &paragraph.layout,
146                    local_start,
147                    Default::default(),
148                ),
149                parley::editing::Cursor::from_byte_index(
150                    &paragraph.layout,
151                    local_end,
152                    Default::default(),
153                ),
154            );
155
156            selection.geometry_with(&paragraph.layout, |rect, line| {
157                // Snap the horizontal edges to device pixels once, here, so that the highlight
158                // rectangle and the glyph clip derived from the same span are pixel-identical.
159                let x = (rect.x0 as f32).round()..(rect.x1 as f32).round();
160                if x.end <= x.start {
161                    return;
162                }
163                // A giant wrapped paragraph passes the paragraph test above with all its lines;
164                // keep only the ones that can reach the band.
165                let top = PhysicalLength::new(rect.y0 as _) + paragraph_top;
166                let bottom = PhysicalLength::new(rect.y1 as _) + paragraph_top;
167                if bottom + line_pad < visible_band.start || top - line_pad > visible_band.end {
168                    return;
169                }
170                let background = PhysicalRect::new(
171                    PhysicalPoint::from_lengths(
172                        PhysicalLength::new(x.start),
173                        PhysicalLength::new(rect.y0 as _) + paragraph_top,
174                    ),
175                    PhysicalSize::new(x.end - x.start, rect.height() as _),
176                );
177                spans.push(SelectionSpan { paragraph: paragraph_index, line, background });
178            });
179        }
180
181        // Already in this order: paragraphs are visited in order, `geometry_with` walks a
182        // paragraph's lines in order, and within a line it accumulates x left to right over
183        // visually reordered items -- so even a bidi line yields ascending spans. Sort defensively
184        // anyway, since both consumers depend on it and neither would fail loudly: `for_line`
185        // needs the `(paragraph, line)` grouping, and the segment walk in
186        // `draw_glyph_run_with_selection` needs ascending x within a line.
187        spans.sort_by(|a, b| {
188            (a.paragraph, a.line)
189                .cmp(&(b.paragraph, b.line))
190                .then_with(|| a.x().start.total_cmp(&b.x().start))
191        });
192
193        SelectionSpans(spans)
194    }
195}