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