1use std::ops::Range;
2
3use gpui::{
4 App, HighlightStyle, IntoElement, ParentElement, RenderOnce, SharedString, StyleRefinement,
5 Styled, StyledText, Window, div, prelude::FluentBuilder, rems,
6};
7
8use gpui_base::TestTextExt as _;
9
10use crate::{ActiveTheme, StyledExt};
11
12const MASKED: &'static str = "•";
13
14#[derive(Clone)]
16pub enum HighlightsMatch {
17 Prefix(SharedString),
18 Full(SharedString),
19}
20
21impl HighlightsMatch {
22 pub fn as_str(&self) -> &str {
23 match self {
24 Self::Prefix(s) => s.as_str(),
25 Self::Full(s) => s.as_str(),
26 }
27 }
28
29 #[inline]
30 pub fn is_prefix(&self) -> bool {
31 matches!(self, Self::Prefix(_))
32 }
33}
34
35impl From<&str> for HighlightsMatch {
36 fn from(value: &str) -> Self {
37 Self::Full(value.to_string().into())
38 }
39}
40
41impl From<String> for HighlightsMatch {
42 fn from(value: String) -> Self {
43 Self::Full(value.into())
44 }
45}
46
47impl From<SharedString> for HighlightsMatch {
48 fn from(value: SharedString) -> Self {
49 Self::Full(value)
50 }
51}
52
53#[derive(IntoElement)]
55pub struct Label {
56 style: StyleRefinement,
57 label: SharedString,
58 secondary: Option<SharedString>,
59 masked: bool,
60 highlights_text: Option<HighlightsMatch>,
61}
62
63impl Label {
64 pub fn new(label: impl Into<SharedString>) -> Self {
66 let label: SharedString = label.into();
67 Self {
68 style: Default::default(),
69 label,
70 secondary: None,
71 masked: false,
72 highlights_text: None,
73 }
74 }
75
76 pub fn secondary(mut self, secondary: impl Into<SharedString>) -> Self {
79 self.secondary = Some(secondary.into());
80 self
81 }
82
83 pub fn masked(mut self, masked: bool) -> Self {
85 self.masked = masked;
86 self
87 }
88
89 pub fn highlights(mut self, text: impl Into<HighlightsMatch>) -> Self {
91 self.highlights_text = Some(text.into());
92 self
93 }
94
95 fn full_text(&self) -> SharedString {
96 match &self.secondary {
97 Some(secondary) => format!("{} {}", self.label, secondary).into(),
98 None => self.label.clone(),
99 }
100 }
101
102 fn highlight_ranges(&self, total_length: usize) -> Vec<Range<usize>> {
103 let mut ranges = Vec::new();
104 let full_text = self.full_text();
105
106 if self.secondary.is_some() {
107 ranges.push(0..self.label.len());
108 ranges.push(self.label.len()..total_length);
109 }
110
111 if let Some(matched) = &self.highlights_text {
112 let matched_str = matched.as_str();
113 if !matched_str.is_empty() {
114 let search_lower = matched_str.to_lowercase();
115 let full_text_lower = full_text.to_lowercase();
116
117 if matched.is_prefix() {
118 if full_text_lower.starts_with(&search_lower) {
120 ranges.push(0..matched_str.len());
121 }
122 } else {
123 let mut search_start = 0;
125 while let Some(pos) = full_text_lower[search_start..].find(&search_lower) {
126 let match_start = search_start + pos;
127 let match_end = match_start + matched_str.len();
128
129 if match_end <= full_text.len() {
130 ranges.push(match_start..match_end);
131 }
132
133 search_start = match_start + 1;
134 while !full_text.is_char_boundary(search_start)
135 && search_start < full_text.len()
136 {
137 search_start += 1;
138 }
139
140 if search_start >= full_text.len() {
141 break;
142 }
143 }
144 }
145 }
146 }
147
148 ranges
149 }
150
151 fn measure_highlights(
152 &self,
153 length: usize,
154 cx: &mut App,
155 ) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
156 if self.masked {
157 return None;
158 }
159
160 let ranges = self.highlight_ranges(length);
161 if ranges.is_empty() {
162 return None;
163 }
164
165 let mut highlights = Vec::new();
166 let mut highlight_ranges_added = 0;
167
168 if self.secondary.is_some() {
169 highlights.push((ranges[0].clone(), HighlightStyle::default()));
170 highlights.push((
171 ranges[1].clone(),
172 HighlightStyle {
173 color: Some(cx.theme().muted_foreground),
174 ..Default::default()
175 },
176 ));
177 highlight_ranges_added = 2;
178 }
179
180 for range in ranges.iter().skip(highlight_ranges_added) {
181 highlights.push((
182 range.clone(),
183 HighlightStyle {
184 color: Some(cx.theme().blue),
185 ..Default::default()
186 },
187 ));
188 }
189
190 Some(gpui::combine_highlights(vec![], highlights).collect())
191 }
192}
193
194impl Styled for Label {
195 fn style(&mut self) -> &mut gpui::StyleRefinement {
196 &mut self.style
197 }
198}
199
200impl RenderOnce for Label {
201 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
202 let mut text = self.full_text();
203 let chars_count = text.chars().count();
204
205 if self.masked {
206 text = SharedString::from(MASKED.repeat(chars_count))
207 };
208
209 let highlights = self.measure_highlights(text.len(), cx);
210
211 div()
212 .line_height(rems(1.25))
213 .text_color(cx.theme().foreground)
214 .refine_style(&self.style)
215 .child(
216 StyledText::new(&text).when_some(highlights, |this, hl| this.with_highlights(hl)),
217 )
218 .test_text(text)
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 struct MaskedLabels;
227
228 impl gpui::Render for MaskedLabels {
229 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
230 div()
231 .child(Label::new("Hello").secondary("World").masked(true))
232 .child(Label::new("Hello").highlights("ell").masked(true))
233 .child(
234 Label::new("é🙂")
235 .secondary("世界")
236 .highlights("🙂 世")
237 .masked(true),
238 )
239 }
240 }
241
242 #[gpui::test]
243 fn masked_secondary_text_and_highlights_render(cx: &mut gpui::TestAppContext) {
244 cx.update(crate::init);
245 cx.update(|cx| {
246 let label = Label::new("é🙂")
247 .secondary("世界")
248 .highlights("🙂 世")
249 .masked(true);
250 assert!(
251 label
252 .measure_highlights(label.full_text().len(), cx)
253 .is_none()
254 );
255 });
256 let (_, cx) = cx.add_window_view(|_, _| MaskedLabels);
257 cx.update(|window, cx| window.draw(cx).clear(cx));
258 }
259
260 #[cfg(feature = "test-support")]
261 #[gpui::test]
262 fn records_displayed_text_under_its_identified_parent(cx: &mut gpui::TestAppContext) {
263 use gpui::InteractiveElement as _;
264
265 struct Labels;
266 impl gpui::Render for Labels {
267 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
268 div()
269 .id("box")
270 .child(Label::new("Hello"))
271 .child(Label::new("secret").masked(true))
272 }
273 }
274 cx.update(crate::init);
275 let (_, cx) = cx.add_window_view(|_, _| Labels);
276 cx.update(|window, cx| {
278 gpui_base::test_support::begin_frame(window);
279 window.draw(cx).clear(cx)
280 });
281 cx.update(|window, _| {
282 let texts: Vec<_> = gpui_base::test_support::texts(window)
283 .iter()
284 .map(|text| {
285 assert!(text.path().contains(&"box".into()), "{:?}", text.path());
286 text.text().to_owned()
287 })
288 .collect();
289 assert_eq!(texts, ["Hello", "••••••"]);
290 });
291 }
292
293 #[test]
294 fn test_highlight_ranges() {
295 let label = Label::new("Hello World");
299 let result = label.highlight_ranges("Hello World".len());
300 assert_eq!(result, Vec::<Range<usize>>::new());
301
302 let label = Label::new("Hello").secondary("World");
304 let total_length = "Hello World".len();
305 let result = label.highlight_ranges(total_length);
306 assert_eq!(result.len(), 2);
307 assert_eq!(result[0], 0..5); assert_eq!(result[1], 5..11); let label = Label::new("Hello World").highlights("WORLD");
314 let result = label.highlight_ranges("Hello World".len());
315 assert_eq!(result.len(), 1);
316 assert_eq!(result[0], 6..11); let label = Label::new("Hello Hello Hello").highlights("Hello");
320 let result = label.highlight_ranges("Hello Hello Hello".len());
321 assert_eq!(result.len(), 3);
322 assert_eq!(result[0], 0..5); assert_eq!(result[1], 6..11); assert_eq!(result[2], 12..17); let label = Label::new("Hello World").highlights("xyz");
328 let result = label.highlight_ranges("Hello World".len());
329 assert_eq!(result, Vec::<Range<usize>>::new());
330
331 let label = Label::new("Hello World").highlights("");
332 let result = label.highlight_ranges("Hello World".len());
333 assert_eq!(result, Vec::<Range<usize>>::new());
334
335 let label = Label::new("Hello").secondary("World").highlights("llo");
339 let total_length = "Hello World".len();
340 let result = label.highlight_ranges(total_length);
341 assert_eq!(result.len(), 3);
342 assert_eq!(result[0], 0..5); assert_eq!(result[1], 5..11); assert_eq!(result[2], 2..5); let label = Label::new("Hello").secondary("World").highlights("World");
348 let total_length = "Hello World".len();
349 let result = label.highlight_ranges(total_length);
350 assert_eq!(result.len(), 3);
351 assert_eq!(result[0], 0..5); assert_eq!(result[1], 5..11); assert_eq!(result[2], 6..11); let label = Label::new("Hello").secondary("World").highlights("o W");
357 let total_length = "Hello World".len();
358 let result = label.highlight_ranges(total_length);
359 assert_eq!(result.len(), 3);
360 assert_eq!(result[0], 0..5); assert_eq!(result[1], 5..11); assert_eq!(result[2], 4..7); let label = Label::new("aaaa").highlights("aa");
368 let result = label.highlight_ranges("aaaa".len());
369 assert!(result.len() >= 2);
370 assert_eq!(result[0], 0..2); assert_eq!(result[1], 1..3); let label = Label::new("你好世界,Hello World").highlights("世界");
375 let result = label.highlight_ranges("你好世界,Hello World".len());
376 assert_eq!(result.len(), 1);
377 let text = "你好世界,Hello World";
378 let start = text.find("世界").unwrap();
379 let end = start + "世界".len();
380 assert_eq!(result[0], start..end);
381 }
382
383 #[test]
384 fn test_highlight_ranges_prefix() {
385 let label = Label::new("aaaa").highlights(HighlightsMatch::Prefix("aa".into()));
387 let result = label.highlight_ranges("aaaa".len());
388 assert_eq!(result.len(), 1);
389 assert_eq!(result[0], 0..2); let label_full =
393 Label::new("Hello Hello").highlights(HighlightsMatch::Full("Hello".into()));
394 let result_full = label_full.highlight_ranges("Hello Hello".len());
395 assert_eq!(result_full.len(), 2); let label_prefix =
398 Label::new("Hello Hello").highlights(HighlightsMatch::Prefix("Hello".into()));
399 let result_prefix = label_prefix.highlight_ranges("Hello Hello".len());
400 assert_eq!(result_prefix.len(), 1); assert_eq!(result_prefix[0], 0..5);
402
403 let label =
405 Label::new("Hello hello HELLO").highlights(HighlightsMatch::Prefix("hello".into()));
406 let result = label.highlight_ranges("Hello hello HELLO".len());
407 assert_eq!(result.len(), 1);
408 assert_eq!(result[0], 0..5); let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("xyz".into()));
412 let result = label.highlight_ranges("Hello World".len());
413 assert_eq!(result.len(), 0);
414
415 let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("".into()));
417 let result = label.highlight_ranges("Hello World".len());
418 assert_eq!(result.len(), 0);
419
420 let label = Label::new("Hello")
422 .secondary("Hello World")
423 .highlights(HighlightsMatch::Prefix("Hello".into()));
424 let total_length = "Hello Hello World".len();
425 let result = label.highlight_ranges(total_length);
426 assert_eq!(result.len(), 3); assert_eq!(result[0], 0..5); assert_eq!(result[1], 5..17); assert_eq!(result[2], 0..5); let label = Label::new("abc")
433 .secondary("def abc def")
434 .highlights(HighlightsMatch::Prefix("abc".into()));
435 let total_length = "abc def abc def".len();
436 let result = label.highlight_ranges(total_length);
437 assert_eq!(result.len(), 3); assert_eq!(result[0], 0..3); assert_eq!(result[1], 3..15); assert_eq!(result[2], 0..3); let label = Label::new("你好世界你好").highlights(HighlightsMatch::Prefix("你好".into()));
444 let result = label.highlight_ranges("你好世界你好".len());
445 assert_eq!(result.len(), 1);
446 assert_eq!(result[0], 0..6); let label = Label::new("abababab").highlights(HighlightsMatch::Prefix("abab".into()));
450 let result = label.highlight_ranges("abababab".len());
451 assert_eq!(result.len(), 1);
452 assert_eq!(result[0], 0..4); let label =
456 Label::new("xyz Hello abc Hello").highlights(HighlightsMatch::Prefix("Hello".into()));
457 let result = label.highlight_ranges("xyz Hello abc Hello".len());
458 assert_eq!(result.len(), 0); let prefix_match = HighlightsMatch::Prefix("test".into());
462 let full_match = HighlightsMatch::Full("test".into());
463 assert!(prefix_match.is_prefix());
464 assert!(!full_match.is_prefix());
465
466 let prefix_match = HighlightsMatch::Prefix("test".into());
468 assert_eq!(prefix_match.as_str(), "test");
469 }
470}