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 crate::{ActiveTheme, StyledExt};
9
10const MASKED: &'static str = "•";
11
12/// Represents the type of match for highlighting text in a label.
13#[derive(Clone)]
14pub enum HighlightsMatch {
15    Prefix(SharedString),
16    Full(SharedString),
17}
18
19impl HighlightsMatch {
20    pub fn as_str(&self) -> &str {
21        match self {
22            Self::Prefix(s) => s.as_str(),
23            Self::Full(s) => s.as_str(),
24        }
25    }
26
27    #[inline]
28    pub fn is_prefix(&self) -> bool {
29        matches!(self, Self::Prefix(_))
30    }
31}
32
33impl From<&str> for HighlightsMatch {
34    fn from(value: &str) -> Self {
35        Self::Full(value.to_string().into())
36    }
37}
38
39impl From<String> for HighlightsMatch {
40    fn from(value: String) -> Self {
41        Self::Full(value.into())
42    }
43}
44
45impl From<SharedString> for HighlightsMatch {
46    fn from(value: SharedString) -> Self {
47        Self::Full(value)
48    }
49}
50
51/// A text label element with optional secondary text, masking, and highlighting capabilities.
52#[derive(IntoElement)]
53pub struct Label {
54    style: StyleRefinement,
55    label: SharedString,
56    secondary: Option<SharedString>,
57    masked: bool,
58    highlights_text: Option<HighlightsMatch>,
59}
60
61impl Label {
62    /// Create a new label with the main label.
63    pub fn new(label: impl Into<SharedString>) -> Self {
64        let label: SharedString = label.into();
65        Self {
66            style: Default::default(),
67            label,
68            secondary: None,
69            masked: false,
70            highlights_text: None,
71        }
72    }
73
74    /// Set the secondary text for the label,
75    /// the secondary text will be displayed after the label text with `muted` color.
76    pub fn secondary(mut self, secondary: impl Into<SharedString>) -> Self {
77        self.secondary = Some(secondary.into());
78        self
79    }
80
81    /// Set whether to mask the label text.
82    pub fn masked(mut self, masked: bool) -> Self {
83        self.masked = masked;
84        self
85    }
86
87    /// Set for matching text to highlight in the label.
88    pub fn highlights(mut self, text: impl Into<HighlightsMatch>) -> Self {
89        self.highlights_text = Some(text.into());
90        self
91    }
92
93    fn full_text(&self) -> SharedString {
94        match &self.secondary {
95            Some(secondary) => format!("{} {}", self.label, secondary).into(),
96            None => self.label.clone(),
97        }
98    }
99
100    fn highlight_ranges(&self, total_length: usize) -> Vec<Range<usize>> {
101        let mut ranges = Vec::new();
102        let full_text = self.full_text();
103
104        if self.secondary.is_some() {
105            ranges.push(0..self.label.len());
106            ranges.push(self.label.len()..total_length);
107        }
108
109        if let Some(matched) = &self.highlights_text {
110            let matched_str = matched.as_str();
111            if !matched_str.is_empty() {
112                let search_lower = matched_str.to_lowercase();
113                let full_text_lower = full_text.to_lowercase();
114
115                if matched.is_prefix() {
116                    // For prefix matching, only check if the text starts with the search term
117                    if full_text_lower.starts_with(&search_lower) {
118                        ranges.push(0..matched_str.len());
119                    }
120                } else {
121                    // For full matching, find all occurrences
122                    let mut search_start = 0;
123                    while let Some(pos) = full_text_lower[search_start..].find(&search_lower) {
124                        let match_start = search_start + pos;
125                        let match_end = match_start + matched_str.len();
126
127                        if match_end <= full_text.len() {
128                            ranges.push(match_start..match_end);
129                        }
130
131                        search_start = match_start + 1;
132                        while !full_text.is_char_boundary(search_start)
133                            && search_start < full_text.len()
134                        {
135                            search_start += 1;
136                        }
137
138                        if search_start >= full_text.len() {
139                            break;
140                        }
141                    }
142                }
143            }
144        }
145
146        ranges
147    }
148
149    fn measure_highlights(
150        &self,
151        length: usize,
152        cx: &mut App,
153    ) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
154        if self.masked {
155            return None;
156        }
157
158        let ranges = self.highlight_ranges(length);
159        if ranges.is_empty() {
160            return None;
161        }
162
163        let mut highlights = Vec::new();
164        let mut highlight_ranges_added = 0;
165
166        if self.secondary.is_some() {
167            highlights.push((ranges[0].clone(), HighlightStyle::default()));
168            highlights.push((
169                ranges[1].clone(),
170                HighlightStyle {
171                    color: Some(cx.theme().muted_foreground),
172                    ..Default::default()
173                },
174            ));
175            highlight_ranges_added = 2;
176        }
177
178        for range in ranges.iter().skip(highlight_ranges_added) {
179            highlights.push((
180                range.clone(),
181                HighlightStyle {
182                    color: Some(cx.theme().blue),
183                    ..Default::default()
184                },
185            ));
186        }
187
188        Some(gpui::combine_highlights(vec![], highlights).collect())
189    }
190}
191
192impl Styled for Label {
193    fn style(&mut self) -> &mut gpui::StyleRefinement {
194        &mut self.style
195    }
196}
197
198impl RenderOnce for Label {
199    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
200        let mut text = self.full_text();
201        let chars_count = text.chars().count();
202
203        if self.masked {
204            text = SharedString::from(MASKED.repeat(chars_count))
205        };
206
207        let highlights = self.measure_highlights(text.len(), cx);
208
209        div()
210            .line_height(rems(1.25))
211            .text_color(cx.theme().foreground)
212            .refine_style(&self.style)
213            .child(
214                StyledText::new(&text).when_some(highlights, |this, hl| this.with_highlights(hl)),
215            )
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    struct MaskedLabels;
224
225    impl gpui::Render for MaskedLabels {
226        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
227            div()
228                .child(Label::new("Hello").secondary("World").masked(true))
229                .child(Label::new("Hello").highlights("ell").masked(true))
230                .child(
231                    Label::new("é🙂")
232                        .secondary("世界")
233                        .highlights("🙂 世")
234                        .masked(true),
235                )
236        }
237    }
238
239    #[gpui::test]
240    fn masked_secondary_text_and_highlights_render(cx: &mut gpui::TestAppContext) {
241        cx.update(crate::init);
242        cx.update(|cx| {
243            let label = Label::new("é🙂")
244                .secondary("世界")
245                .highlights("🙂 世")
246                .masked(true);
247            assert!(
248                label
249                    .measure_highlights(label.full_text().len(), cx)
250                    .is_none()
251            );
252        });
253        let (_, cx) = cx.add_window_view(|_, _| MaskedLabels);
254        cx.update(|window, cx| window.draw(cx).clear(cx));
255    }
256
257    #[test]
258    fn test_highlight_ranges() {
259        // Basic functionality
260
261        // No highlights
262        let label = Label::new("Hello World");
263        let result = label.highlight_ranges("Hello World".len());
264        assert_eq!(result, Vec::<Range<usize>>::new());
265
266        // Secondary text ranges only
267        let label = Label::new("Hello").secondary("World");
268        let total_length = "Hello World".len();
269        let result = label.highlight_ranges(total_length);
270        assert_eq!(result.len(), 2);
271        assert_eq!(result[0], 0..5); // "Hello"
272        assert_eq!(result[1], 5..11); // " World"
273
274        // Text highlighting
275
276        // Single match with case insensitive
277        let label = Label::new("Hello World").highlights("WORLD");
278        let result = label.highlight_ranges("Hello World".len());
279        assert_eq!(result.len(), 1);
280        assert_eq!(result[0], 6..11); // "World"
281
282        // Multiple matches
283        let label = Label::new("Hello Hello Hello").highlights("Hello");
284        let result = label.highlight_ranges("Hello Hello Hello".len());
285        assert_eq!(result.len(), 3);
286        assert_eq!(result[0], 0..5); // First "Hello"
287        assert_eq!(result[1], 6..11); // Second "Hello"
288        assert_eq!(result[2], 12..17); // Third "Hello"
289
290        // No match and empty search
291        let label = Label::new("Hello World").highlights("xyz");
292        let result = label.highlight_ranges("Hello World".len());
293        assert_eq!(result, Vec::<Range<usize>>::new());
294
295        let label = Label::new("Hello World").highlights("");
296        let result = label.highlight_ranges("Hello World".len());
297        assert_eq!(result, Vec::<Range<usize>>::new());
298
299        // Combined functionality
300
301        // Secondary + highlights in main text
302        let label = Label::new("Hello").secondary("World").highlights("llo");
303        let total_length = "Hello World".len();
304        let result = label.highlight_ranges(total_length);
305        assert_eq!(result.len(), 3);
306        assert_eq!(result[0], 0..5); // Main text range
307        assert_eq!(result[1], 5..11); // Secondary text range
308        assert_eq!(result[2], 2..5); // "llo" in main text
309
310        // Highlight in secondary text
311        let label = Label::new("Hello").secondary("World").highlights("World");
312        let total_length = "Hello World".len();
313        let result = label.highlight_ranges(total_length);
314        assert_eq!(result.len(), 3);
315        assert_eq!(result[0], 0..5); // Main text range
316        assert_eq!(result[1], 5..11); // Secondary text range
317        assert_eq!(result[2], 6..11); // "World" in secondary text
318
319        // Cross-boundary highlight
320        let label = Label::new("Hello").secondary("World").highlights("o W");
321        let total_length = "Hello World".len();
322        let result = label.highlight_ranges(total_length);
323        assert_eq!(result.len(), 3);
324        assert_eq!(result[0], 0..5); // Main text range
325        assert_eq!(result[1], 5..11); // Secondary text range
326        assert_eq!(result[2], 4..7); // "o W" across boundary
327
328        // Edge cases
329
330        // Overlapping matches
331        let label = Label::new("aaaa").highlights("aa");
332        let result = label.highlight_ranges("aaaa".len());
333        assert!(result.len() >= 2);
334        assert_eq!(result[0], 0..2); // First "aa"
335        assert_eq!(result[1], 1..3); // Overlapping "aa"
336
337        // Unicode text
338        let label = Label::new("你好世界,Hello World").highlights("世界");
339        let result = label.highlight_ranges("你好世界,Hello World".len());
340        assert_eq!(result.len(), 1);
341        let text = "你好世界,Hello World";
342        let start = text.find("世界").unwrap();
343        let end = start + "世界".len();
344        assert_eq!(result[0], start..end);
345    }
346
347    #[test]
348    fn test_highlight_ranges_prefix() {
349        // Test prefix match - should only match the first occurrence
350        let label = Label::new("aaaa").highlights(HighlightsMatch::Prefix("aa".into()));
351        let result = label.highlight_ranges("aaaa".len());
352        assert_eq!(result.len(), 1);
353        assert_eq!(result[0], 0..2); // Only first "aa"
354
355        // Test prefix vs full match behavior
356        let label_full =
357            Label::new("Hello Hello").highlights(HighlightsMatch::Full("Hello".into()));
358        let result_full = label_full.highlight_ranges("Hello Hello".len());
359        assert_eq!(result_full.len(), 2); // Both "Hello" matches
360
361        let label_prefix =
362            Label::new("Hello Hello").highlights(HighlightsMatch::Prefix("Hello".into()));
363        let result_prefix = label_prefix.highlight_ranges("Hello Hello".len());
364        assert_eq!(result_prefix.len(), 1); // Only first "Hello"
365        assert_eq!(result_prefix[0], 0..5);
366
367        // Test prefix with case insensitive matching
368        let label =
369            Label::new("Hello hello HELLO").highlights(HighlightsMatch::Prefix("hello".into()));
370        let result = label.highlight_ranges("Hello hello HELLO".len());
371        assert_eq!(result.len(), 1);
372        assert_eq!(result[0], 0..5); // First "Hello" (case insensitive)
373
374        // Test prefix with no match
375        let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("xyz".into()));
376        let result = label.highlight_ranges("Hello World".len());
377        assert_eq!(result.len(), 0);
378
379        // Test prefix with empty string
380        let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("".into()));
381        let result = label.highlight_ranges("Hello World".len());
382        assert_eq!(result.len(), 0);
383
384        // Test prefix with secondary text - match in main text
385        let label = Label::new("Hello")
386            .secondary("Hello World")
387            .highlights(HighlightsMatch::Prefix("Hello".into()));
388        let total_length = "Hello Hello World".len();
389        let result = label.highlight_ranges(total_length);
390        assert_eq!(result.len(), 3); // 2 for secondary + 1 for prefix match
391        assert_eq!(result[0], 0..5); // Main text range
392        assert_eq!(result[1], 5..17); // Secondary text range
393        assert_eq!(result[2], 0..5); // First "Hello" prefix match in main text
394
395        // Test prefix with secondary text - match spans boundary (now no match since "abc" is not at start of full text)
396        let label = Label::new("abc")
397            .secondary("def abc def")
398            .highlights(HighlightsMatch::Prefix("abc".into()));
399        let total_length = "abc def abc def".len();
400        let result = label.highlight_ranges(total_length);
401        assert_eq!(result.len(), 3); // 2 for secondary + 1 for prefix match
402        assert_eq!(result[0], 0..3); // Main text range
403        assert_eq!(result[1], 3..15); // Secondary text range
404        assert_eq!(result[2], 0..3); // "abc" matches at start of full text
405
406        // Test prefix with Unicode characters
407        let label = Label::new("你好世界你好").highlights(HighlightsMatch::Prefix("你好".into()));
408        let result = label.highlight_ranges("你好世界你好".len());
409        assert_eq!(result.len(), 1);
410        assert_eq!(result[0], 0..6); // First "你好" (6 bytes in UTF-8)
411
412        // Test prefix with overlapping pattern
413        let label = Label::new("abababab").highlights(HighlightsMatch::Prefix("abab".into()));
414        let result = label.highlight_ranges("abababab".len());
415        assert_eq!(result.len(), 1);
416        assert_eq!(result[0], 0..4); // First "abab" only
417
418        // Test prefix match at different positions (now no match since "Hello" is not at start)
419        let label =
420            Label::new("xyz Hello abc Hello").highlights(HighlightsMatch::Prefix("Hello".into()));
421        let result = label.highlight_ranges("xyz Hello abc Hello".len());
422        assert_eq!(result.len(), 0); // No match since "Hello" is not at the beginning
423
424        // Test is_prefix method
425        let prefix_match = HighlightsMatch::Prefix("test".into());
426        let full_match = HighlightsMatch::Full("test".into());
427        assert!(prefix_match.is_prefix());
428        assert!(!full_match.is_prefix());
429
430        // Test as_str method for prefix
431        let prefix_match = HighlightsMatch::Prefix("test".into());
432        assert_eq!(prefix_match.as_str(), "test");
433    }
434}