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}