gpui_component/
label.rs

1use std::ops::Range;
2
3use gpui::{
4    div, prelude::FluentBuilder, rems, App, HighlightStyle, IntoElement, ParentElement, RenderOnce,
5    SharedString, StyleRefinement, Styled, StyledText, Window,
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        let ranges = self.highlight_ranges(length);
155        if ranges.is_empty() {
156            return None;
157        }
158
159        let mut highlights = Vec::new();
160        let mut highlight_ranges_added = 0;
161
162        if self.secondary.is_some() {
163            highlights.push((ranges[0].clone(), HighlightStyle::default()));
164            highlights.push((
165                ranges[1].clone(),
166                HighlightStyle {
167                    color: Some(cx.theme().muted_foreground),
168                    ..Default::default()
169                },
170            ));
171            highlight_ranges_added = 2;
172        }
173
174        for range in ranges.iter().skip(highlight_ranges_added) {
175            highlights.push((
176                range.clone(),
177                HighlightStyle {
178                    color: Some(cx.theme().blue),
179                    ..Default::default()
180                },
181            ));
182        }
183
184        Some(gpui::combine_highlights(vec![], highlights).collect())
185    }
186}
187
188impl Styled for Label {
189    fn style(&mut self) -> &mut gpui::StyleRefinement {
190        &mut self.style
191    }
192}
193
194impl RenderOnce for Label {
195    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
196        let mut text = self.full_text();
197        let chars_count = text.chars().count();
198
199        if self.masked {
200            text = SharedString::from(MASKED.repeat(chars_count))
201        };
202
203        let highlights = self.measure_highlights(text.len(), cx);
204
205        div()
206            .line_height(rems(1.25))
207            .text_color(cx.theme().foreground)
208            .refine_style(&self.style)
209            .child(
210                StyledText::new(&text).when_some(highlights, |this, hl| this.with_highlights(hl)),
211            )
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_highlight_ranges() {
221        // Basic functionality
222
223        // No highlights
224        let label = Label::new("Hello World");
225        let result = label.highlight_ranges("Hello World".len());
226        assert_eq!(result, Vec::<Range<usize>>::new());
227
228        // Secondary text ranges only
229        let label = Label::new("Hello").secondary("World");
230        let total_length = "Hello World".len();
231        let result = label.highlight_ranges(total_length);
232        assert_eq!(result.len(), 2);
233        assert_eq!(result[0], 0..5); // "Hello"
234        assert_eq!(result[1], 5..11); // " World"
235
236        // Text highlighting
237
238        // Single match with case insensitive
239        let label = Label::new("Hello World").highlights("WORLD");
240        let result = label.highlight_ranges("Hello World".len());
241        assert_eq!(result.len(), 1);
242        assert_eq!(result[0], 6..11); // "World"
243
244        // Multiple matches
245        let label = Label::new("Hello Hello Hello").highlights("Hello");
246        let result = label.highlight_ranges("Hello Hello Hello".len());
247        assert_eq!(result.len(), 3);
248        assert_eq!(result[0], 0..5); // First "Hello"
249        assert_eq!(result[1], 6..11); // Second "Hello"
250        assert_eq!(result[2], 12..17); // Third "Hello"
251
252        // No match and empty search
253        let label = Label::new("Hello World").highlights("xyz");
254        let result = label.highlight_ranges("Hello World".len());
255        assert_eq!(result, Vec::<Range<usize>>::new());
256
257        let label = Label::new("Hello World").highlights("");
258        let result = label.highlight_ranges("Hello World".len());
259        assert_eq!(result, Vec::<Range<usize>>::new());
260
261        // Combined functionality
262
263        // Secondary + highlights in main text
264        let label = Label::new("Hello").secondary("World").highlights("llo");
265        let total_length = "Hello World".len();
266        let result = label.highlight_ranges(total_length);
267        assert_eq!(result.len(), 3);
268        assert_eq!(result[0], 0..5); // Main text range
269        assert_eq!(result[1], 5..11); // Secondary text range
270        assert_eq!(result[2], 2..5); // "llo" in main text
271
272        // Highlight in secondary text
273        let label = Label::new("Hello").secondary("World").highlights("World");
274        let total_length = "Hello World".len();
275        let result = label.highlight_ranges(total_length);
276        assert_eq!(result.len(), 3);
277        assert_eq!(result[0], 0..5); // Main text range
278        assert_eq!(result[1], 5..11); // Secondary text range
279        assert_eq!(result[2], 6..11); // "World" in secondary text
280
281        // Cross-boundary highlight
282        let label = Label::new("Hello").secondary("World").highlights("o W");
283        let total_length = "Hello World".len();
284        let result = label.highlight_ranges(total_length);
285        assert_eq!(result.len(), 3);
286        assert_eq!(result[0], 0..5); // Main text range
287        assert_eq!(result[1], 5..11); // Secondary text range
288        assert_eq!(result[2], 4..7); // "o W" across boundary
289
290        // Edge cases
291
292        // Overlapping matches
293        let label = Label::new("aaaa").highlights("aa");
294        let result = label.highlight_ranges("aaaa".len());
295        assert!(result.len() >= 2);
296        assert_eq!(result[0], 0..2); // First "aa"
297        assert_eq!(result[1], 1..3); // Overlapping "aa"
298
299        // Unicode text
300        let label = Label::new("你好世界,Hello World").highlights("世界");
301        let result = label.highlight_ranges("你好世界,Hello World".len());
302        assert_eq!(result.len(), 1);
303        let text = "你好世界,Hello World";
304        let start = text.find("世界").unwrap();
305        let end = start + "世界".len();
306        assert_eq!(result[0], start..end);
307    }
308
309    #[test]
310    fn test_highlight_ranges_prefix() {
311        // Test prefix match - should only match the first occurrence
312        let label = Label::new("aaaa").highlights(HighlightsMatch::Prefix("aa".into()));
313        let result = label.highlight_ranges("aaaa".len());
314        assert_eq!(result.len(), 1);
315        assert_eq!(result[0], 0..2); // Only first "aa"
316
317        // Test prefix vs full match behavior
318        let label_full =
319            Label::new("Hello Hello").highlights(HighlightsMatch::Full("Hello".into()));
320        let result_full = label_full.highlight_ranges("Hello Hello".len());
321        assert_eq!(result_full.len(), 2); // Both "Hello" matches
322
323        let label_prefix =
324            Label::new("Hello Hello").highlights(HighlightsMatch::Prefix("Hello".into()));
325        let result_prefix = label_prefix.highlight_ranges("Hello Hello".len());
326        assert_eq!(result_prefix.len(), 1); // Only first "Hello"
327        assert_eq!(result_prefix[0], 0..5);
328
329        // Test prefix with case insensitive matching
330        let label =
331            Label::new("Hello hello HELLO").highlights(HighlightsMatch::Prefix("hello".into()));
332        let result = label.highlight_ranges("Hello hello HELLO".len());
333        assert_eq!(result.len(), 1);
334        assert_eq!(result[0], 0..5); // First "Hello" (case insensitive)
335
336        // Test prefix with no match
337        let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("xyz".into()));
338        let result = label.highlight_ranges("Hello World".len());
339        assert_eq!(result.len(), 0);
340
341        // Test prefix with empty string
342        let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("".into()));
343        let result = label.highlight_ranges("Hello World".len());
344        assert_eq!(result.len(), 0);
345
346        // Test prefix with secondary text - match in main text
347        let label = Label::new("Hello")
348            .secondary("Hello World")
349            .highlights(HighlightsMatch::Prefix("Hello".into()));
350        let total_length = "Hello Hello World".len();
351        let result = label.highlight_ranges(total_length);
352        assert_eq!(result.len(), 3); // 2 for secondary + 1 for prefix match
353        assert_eq!(result[0], 0..5); // Main text range
354        assert_eq!(result[1], 5..17); // Secondary text range
355        assert_eq!(result[2], 0..5); // First "Hello" prefix match in main text
356
357        // Test prefix with secondary text - match spans boundary (now no match since "abc" is not at start of full text)
358        let label = Label::new("abc")
359            .secondary("def abc def")
360            .highlights(HighlightsMatch::Prefix("abc".into()));
361        let total_length = "abc def abc def".len();
362        let result = label.highlight_ranges(total_length);
363        assert_eq!(result.len(), 3); // 2 for secondary + 1 for prefix match
364        assert_eq!(result[0], 0..3); // Main text range
365        assert_eq!(result[1], 3..15); // Secondary text range
366        assert_eq!(result[2], 0..3); // "abc" matches at start of full text
367
368        // Test prefix with Unicode characters
369        let label = Label::new("你好世界你好").highlights(HighlightsMatch::Prefix("你好".into()));
370        let result = label.highlight_ranges("你好世界你好".len());
371        assert_eq!(result.len(), 1);
372        assert_eq!(result[0], 0..6); // First "你好" (6 bytes in UTF-8)
373
374        // Test prefix with overlapping pattern
375        let label = Label::new("abababab").highlights(HighlightsMatch::Prefix("abab".into()));
376        let result = label.highlight_ranges("abababab".len());
377        assert_eq!(result.len(), 1);
378        assert_eq!(result[0], 0..4); // First "abab" only
379
380        // Test prefix match at different positions (now no match since "Hello" is not at start)
381        let label =
382            Label::new("xyz Hello abc Hello").highlights(HighlightsMatch::Prefix("Hello".into()));
383        let result = label.highlight_ranges("xyz Hello abc Hello".len());
384        assert_eq!(result.len(), 0); // No match since "Hello" is not at the beginning
385
386        // Test is_prefix method
387        let prefix_match = HighlightsMatch::Prefix("test".into());
388        let full_match = HighlightsMatch::Full("test".into());
389        assert!(prefix_match.is_prefix());
390        assert!(!full_match.is_prefix());
391
392        // Test as_str method for prefix
393        let prefix_match = HighlightsMatch::Prefix("test".into());
394        assert_eq!(prefix_match.as_str(), "test");
395    }
396}