Skip to main content

gpui_kit/display/
highlight.rs

1//! Marking ranges of text that somebody else owns.
2//!
3//! # The boundary, and what the caller owes
4//!
5//! This crate does not search text. Deciding that a run of characters answers
6//! a query is the same kind of judgement as deciding that a word is a keyword:
7//! it needs to know about case folding, word boundaries, normalisation forms,
8//! whether the query is a regular expression, and what the host considers one
9//! document — none of which a product-neutral component can answer, and all of
10//! which the application that owns the text already has.
11//!
12//! So the component takes **ranges**, not a query. The caller owes:
13//!
14//! 1. the exact [`SharedString`] it wants marked, handed to this component;
15//! 2. byte offsets into *that* string, on character boundaries;
16//! 3. ranges sorted ascending and not overlapping one another;
17//! 4. which of them, by index, is the current one — or none.
18//!
19//! A range that breaks any of those is skipped rather than drawn wrongly and
20//! rather than panicking, because a highlight is decoration over text that is
21//! still readable without it: losing a mark is recoverable, losing the line is
22//! not. [`HighlightedText::published_hits`] reports how many were actually
23//! drawn, so a caller that got the offsets wrong can see it.
24//!
25//! The same boundary serves the read-only code view, which takes
26//! pre-classified [`CodeSpan`](crate::content::CodeSpan)s for exactly the same
27//! reason.
28
29use std::ops::Range;
30
31use gpui::{
32    App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
33    prelude::FluentBuilder, px,
34};
35use gpui_kit_semantics::{NodeSpec, Role, Semantic};
36use gpui_kit_theme::{ActiveTheme, Radius};
37
38use crate::foundation::{Ident, StyledExt};
39
40/// A run of text with some of it marked.
41///
42/// The current mark is a different treatment from the rest, not a stronger
43/// one: "which hit am I on" and "where are the other hits" are two questions,
44/// and one shade of the same colour answers neither clearly.
45#[derive(Debug, IntoElement)]
46pub struct HighlightedText {
47    ident: Option<Ident>,
48    text: SharedString,
49    hits: Vec<Range<usize>>,
50    current: Option<usize>,
51    monospace: bool,
52}
53
54impl HighlightedText {
55    pub fn new(text: impl Into<SharedString>) -> Self {
56        Self {
57            ident: None,
58            text: text.into(),
59            hits: Vec::new(),
60            current: None,
61            monospace: false,
62        }
63    }
64
65    /// Publishes a node under this id. Without one nothing is published, the
66    /// way a decorative `Badge` publishes nothing.
67    pub fn id(mut self, ident: impl Into<Ident>) -> Self {
68        self.ident = Some(ident.into());
69        self
70    }
71
72    /// The ranges to mark, in byte offsets into the text this was given.
73    pub fn hits(mut self, hits: impl IntoIterator<Item = Range<usize>>) -> Self {
74        self.hits = hits.into_iter().collect();
75        self
76    }
77
78    /// Which hit, by index into [`HighlightedText::hits`], is the current one.
79    pub fn current(mut self, index: usize) -> Self {
80        self.current = Some(index);
81        self
82    }
83
84    pub fn monospace(mut self, monospace: bool) -> Self {
85        self.monospace = monospace;
86        self
87    }
88
89    /// How many of the given ranges name text this string actually holds.
90    ///
91    /// A caller whose offsets are wrong gets a smaller number than it handed
92    /// in, which is how a broken boundary is noticed rather than guessed at.
93    pub fn published_hits(&self) -> usize {
94        segments(self.text.as_ref(), &self.hits)
95            .iter()
96            .filter(|segment| segment.hit.is_some())
97            .count()
98    }
99}
100
101/// One run of the text: either plain, or the `hit`th mark.
102struct Segment {
103    text: String,
104    hit: Option<usize>,
105}
106
107/// Cuts the text at the range boundaries, skipping anything that does not name
108/// a real slice or that runs backwards over what came before.
109fn segments(text: &str, hits: &[Range<usize>]) -> Vec<Segment> {
110    let mut out: Vec<Segment> = Vec::new();
111    let mut cut = 0usize;
112    for (index, range) in hits.iter().enumerate() {
113        if range.start < cut || range.start >= range.end {
114            continue;
115        }
116        let (Some(before), Some(inside)) =
117            (text.get(cut..range.start), text.get(range.start..range.end))
118        else {
119            continue;
120        };
121        if !before.is_empty() {
122            out.push(Segment {
123                text: before.to_string(),
124                hit: None,
125            });
126        }
127        out.push(Segment {
128            text: inside.to_string(),
129            hit: Some(index),
130        });
131        cut = range.end;
132    }
133    if let Some(rest) = text.get(cut..)
134        && !rest.is_empty()
135    {
136        out.push(Segment {
137            text: rest.to_string(),
138            hit: None,
139        });
140    }
141    out
142}
143
144impl RenderOnce for HighlightedText {
145    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
146        let theme = cx.theme().clone();
147        let current = self.current;
148        // What was drawn, not what was asked for: a range naming no slice of
149        // this string produced no mark, and publishing the request would make
150        // the tree claim a highlight nobody can see.
151        let drawn = self.published_hits();
152        let runs = segments(self.text.as_ref(), &self.hits)
153            .into_iter()
154            .map(|segment| {
155                let is_current = segment.hit.is_some() && segment.hit == current;
156                div()
157                    .when(segment.hit.is_some() && !is_current, |element| {
158                        element
159                            .bg(theme
160                                .colors
161                                .accent
162                                .opacity(theme.effects.selected_ring_alpha))
163                            .radius(&theme, Radius::Small)
164                    })
165                    // The current hit takes the accent outright rather than a
166                    // stronger tint of the same wash, so "this one" and "one
167                    // of those" cannot be mistaken for each other.
168                    .when(is_current, |element| {
169                        element
170                            .bg(theme.colors.accent)
171                            .text_color(theme.colors.text_on_accent)
172                            .radius(&theme, Radius::Small)
173                    })
174                    .child(SharedString::from(segment.text))
175            });
176
177        let element = div()
178            .flex()
179            .flex_row()
180            .flex_wrap()
181            .when(self.monospace, |element| {
182                element
183                    .font_family(theme.typography.mono.clone())
184                    .text_size(px(theme.typography.code.size))
185                    .line_height(px(theme.typography.code.line_height))
186            })
187            .children(runs);
188        match self.ident {
189            Some(ident) => {
190                // The text itself is the caller's content and is published as
191                // it is, the way a Markdown paragraph is; the count of marks
192                // is the fact this component adds.
193                element
194                    .semantic_in(
195                        cx,
196                        NodeSpec::new(ident.semantic_id(), Role::Text)
197                            .text(self.text.clone())
198                            .value(drawn.to_string()),
199                    )
200                    .into_any_element()
201            }
202            None => element.into_any_element(),
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn a_range_outside_the_text_is_skipped_rather_than_drawn() {
213        let text = HighlightedText::new("abcdef").hits([0..3, 10..20]);
214        assert_eq!(text.published_hits(), 1);
215    }
216
217    #[test]
218    fn a_range_that_runs_back_over_an_earlier_one_is_skipped() {
219        let text = HighlightedText::new("abcdef").hits([2..4, 1..3]);
220        assert_eq!(text.published_hits(), 1);
221    }
222
223    #[test]
224    fn a_range_cutting_a_character_in_half_is_skipped() {
225        // "é" is two bytes wide, so 0..1 cuts it in half and names no slice
226        // this string holds; 0..2 names the whole character and does.
227        let text = HighlightedText::new("éx").hits([0..1, 0..2]);
228        assert_eq!(text.published_hits(), 1);
229    }
230
231    #[test]
232    fn the_whole_text_survives_being_cut_up() {
233        let joined: String = segments("hello world", &[0..5, 6..11])
234            .into_iter()
235            .map(|segment| segment.text)
236            .collect();
237        assert_eq!(joined, "hello world");
238    }
239}