Skip to main content

gpui_component/
label.rs

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/// Represents the type of match for highlighting text in a label.
15#[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/// A text label element with optional secondary text, masking, and highlighting capabilities.
54#[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    /// Create a new label with the main label.
65    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    /// Set the secondary text for the label,
77    /// the secondary text will be displayed after the label text with `muted` color.
78    pub fn secondary(mut self, secondary: impl Into<SharedString>) -> Self {
79        self.secondary = Some(secondary.into());
80        self
81    }
82
83    /// Set whether to mask the label text.
84    pub fn masked(mut self, masked: bool) -> Self {
85        self.masked = masked;
86        self
87    }
88
89    /// Set for matching text to highlight in the label.
90    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                    // For prefix matching, only check if the text starts with the search term
119                    if full_text_lower.starts_with(&search_lower) {
120                        ranges.push(0..matched_str.len());
121                    }
122                } else {
123                    // For full matching, find all occurrences
124                    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        // Root starts each frame; this window has none.
277        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        // Basic functionality
296
297        // No highlights
298        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        // Secondary text ranges only
303        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); // "Hello"
308        assert_eq!(result[1], 5..11); // " World"
309
310        // Text highlighting
311
312        // Single match with case insensitive
313        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); // "World"
317
318        // Multiple matches
319        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); // First "Hello"
323        assert_eq!(result[1], 6..11); // Second "Hello"
324        assert_eq!(result[2], 12..17); // Third "Hello"
325
326        // No match and empty search
327        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        // Combined functionality
336
337        // Secondary + highlights in main text
338        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); // Main text range
343        assert_eq!(result[1], 5..11); // Secondary text range
344        assert_eq!(result[2], 2..5); // "llo" in main text
345
346        // Highlight in secondary text
347        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); // Main text range
352        assert_eq!(result[1], 5..11); // Secondary text range
353        assert_eq!(result[2], 6..11); // "World" in secondary text
354
355        // Cross-boundary highlight
356        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); // Main text range
361        assert_eq!(result[1], 5..11); // Secondary text range
362        assert_eq!(result[2], 4..7); // "o W" across boundary
363
364        // Edge cases
365
366        // Overlapping matches
367        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); // First "aa"
371        assert_eq!(result[1], 1..3); // Overlapping "aa"
372
373        // Unicode text
374        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        // Test prefix match - should only match the first occurrence
386        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); // Only first "aa"
390
391        // Test prefix vs full match behavior
392        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); // Both "Hello" matches
396
397        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); // Only first "Hello"
401        assert_eq!(result_prefix[0], 0..5);
402
403        // Test prefix with case insensitive matching
404        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); // First "Hello" (case insensitive)
409
410        // Test prefix with no match
411        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        // Test prefix with empty string
416        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        // Test prefix with secondary text - match in main text
421        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); // 2 for secondary + 1 for prefix match
427        assert_eq!(result[0], 0..5); // Main text range
428        assert_eq!(result[1], 5..17); // Secondary text range
429        assert_eq!(result[2], 0..5); // First "Hello" prefix match in main text
430
431        // Test prefix with secondary text - match spans boundary (now no match since "abc" is not at start of full text)
432        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); // 2 for secondary + 1 for prefix match
438        assert_eq!(result[0], 0..3); // Main text range
439        assert_eq!(result[1], 3..15); // Secondary text range
440        assert_eq!(result[2], 0..3); // "abc" matches at start of full text
441
442        // Test prefix with Unicode characters
443        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); // First "你好" (6 bytes in UTF-8)
447
448        // Test prefix with overlapping pattern
449        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); // First "abab" only
453
454        // Test prefix match at different positions (now no match since "Hello" is not at start)
455        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); // No match since "Hello" is not at the beginning
459
460        // Test is_prefix method
461        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        // Test as_str method for prefix
467        let prefix_match = HighlightsMatch::Prefix("test".into());
468        assert_eq!(prefix_match.as_str(), "test");
469    }
470}