use std::ops::Range; use gpui::{ div, prelude::FluentBuilder, rems, App, HighlightStyle, IntoElement, ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, StyledText, Window, }; use crate::{ActiveTheme, StyledExt}; const MASKED: &'static str = "•"; #[derive(Clone)] pub enum HighlightsMatch { Prefix(SharedString), Full(SharedString), } impl HighlightsMatch { pub fn as_str(&self) -> &str { match self { Self::Prefix(s) => s.as_str(), Self::Full(s) => s.as_str(), } } #[inline] pub fn is_prefix(&self) -> bool { matches!(self, Self::Prefix(_)) } } impl From<&str> for HighlightsMatch { fn from(value: &str) -> Self { Self::Full(value.to_string().into()) } } impl From for HighlightsMatch { fn from(value: String) -> Self { Self::Full(value.into()) } } impl From for HighlightsMatch { fn from(value: SharedString) -> Self { Self::Full(value) } } #[derive(IntoElement)] pub struct Label { style: StyleRefinement, label: SharedString, secondary: Option, masked: bool, highlights_text: Option, } impl Label { pub fn new(label: impl Into) -> Self { let label: SharedString = label.into(); Self { style: Default::default(), label, secondary: None, masked: false, highlights_text: None, } } /// Set the secondary text for the label, /// the secondary text will be displayed after the label text with `muted` color. pub fn secondary(mut self, secondary: impl Into) -> Self { self.secondary = Some(secondary.into()); self } pub fn masked(mut self, masked: bool) -> Self { self.masked = masked; self } pub fn highlights(mut self, text: impl Into) -> Self { self.highlights_text = Some(text.into()); self } fn full_text(&self) -> SharedString { match &self.secondary { Some(secondary) => format!("{} {}", self.label, secondary).into(), None => self.label.clone(), } } fn highlight_ranges(&self, total_length: usize) -> Vec> { let mut ranges = Vec::new(); let full_text = self.full_text(); if self.secondary.is_some() { ranges.push(0..self.label.len()); ranges.push(self.label.len()..total_length); } if let Some(matched) = &self.highlights_text { let matched_str = matched.as_str(); if !matched_str.is_empty() { let search_lower = matched_str.to_lowercase(); let full_text_lower = full_text.to_lowercase(); if matched.is_prefix() { // For prefix matching, only check if the text starts with the search term if full_text_lower.starts_with(&search_lower) { ranges.push(0..matched_str.len()); } } else { // For full matching, find all occurrences let mut search_start = 0; while let Some(pos) = full_text_lower[search_start..].find(&search_lower) { let match_start = search_start + pos; let match_end = match_start + matched_str.len(); if match_end <= full_text.len() { ranges.push(match_start..match_end); } search_start = match_start + 1; while !full_text.is_char_boundary(search_start) && search_start < full_text.len() { search_start += 1; } if search_start >= full_text.len() { break; } } } } } ranges } fn measure_highlights( &self, length: usize, cx: &mut App, ) -> Option, HighlightStyle)>> { let ranges = self.highlight_ranges(length); if ranges.is_empty() { return None; } let mut highlights = Vec::new(); let mut highlight_ranges_added = 0; if self.secondary.is_some() { highlights.push((ranges[0].clone(), HighlightStyle::default())); highlights.push(( ranges[1].clone(), HighlightStyle { color: Some(cx.theme().muted_foreground), ..Default::default() }, )); highlight_ranges_added = 2; } for range in ranges.iter().skip(highlight_ranges_added) { highlights.push(( range.clone(), HighlightStyle { color: Some(cx.theme().blue), ..Default::default() }, )); } Some(highlights) } } impl Styled for Label { fn style(&mut self) -> &mut gpui::StyleRefinement { &mut self.style } } impl RenderOnce for Label { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let mut text = self.full_text(); let chars_count = text.chars().count(); if self.masked { text = SharedString::from(MASKED.repeat(chars_count)) }; let highlights = self.measure_highlights(text.len(), cx); div() .line_height(rems(1.25)) .text_color(cx.theme().foreground) .refine_style(&self.style) .child( StyledText::new(&text).when_some(highlights, |this, hl| this.with_highlights(hl)), ) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_highlight_ranges() { // Basic functionality // No highlights let label = Label::new("Hello World"); let result = label.highlight_ranges("Hello World".len()); assert_eq!(result, Vec::>::new()); // Secondary text ranges only let label = Label::new("Hello").secondary("World"); let total_length = "Hello World".len(); let result = label.highlight_ranges(total_length); assert_eq!(result.len(), 2); assert_eq!(result[0], 0..5); // "Hello" assert_eq!(result[1], 5..11); // " World" // Text highlighting // Single match with case insensitive let label = Label::new("Hello World").highlights("WORLD"); let result = label.highlight_ranges("Hello World".len()); assert_eq!(result.len(), 1); assert_eq!(result[0], 6..11); // "World" // Multiple matches let label = Label::new("Hello Hello Hello").highlights("Hello"); let result = label.highlight_ranges("Hello Hello Hello".len()); assert_eq!(result.len(), 3); assert_eq!(result[0], 0..5); // First "Hello" assert_eq!(result[1], 6..11); // Second "Hello" assert_eq!(result[2], 12..17); // Third "Hello" // No match and empty search let label = Label::new("Hello World").highlights("xyz"); let result = label.highlight_ranges("Hello World".len()); assert_eq!(result, Vec::>::new()); let label = Label::new("Hello World").highlights(""); let result = label.highlight_ranges("Hello World".len()); assert_eq!(result, Vec::>::new()); // Combined functionality // Secondary + highlights in main text let label = Label::new("Hello").secondary("World").highlights("llo"); let total_length = "Hello World".len(); let result = label.highlight_ranges(total_length); assert_eq!(result.len(), 3); assert_eq!(result[0], 0..5); // Main text range assert_eq!(result[1], 5..11); // Secondary text range assert_eq!(result[2], 2..5); // "llo" in main text // Highlight in secondary text let label = Label::new("Hello").secondary("World").highlights("World"); let total_length = "Hello World".len(); let result = label.highlight_ranges(total_length); assert_eq!(result.len(), 3); assert_eq!(result[0], 0..5); // Main text range assert_eq!(result[1], 5..11); // Secondary text range assert_eq!(result[2], 6..11); // "World" in secondary text // Cross-boundary highlight let label = Label::new("Hello").secondary("World").highlights("o W"); let total_length = "Hello World".len(); let result = label.highlight_ranges(total_length); assert_eq!(result.len(), 3); assert_eq!(result[0], 0..5); // Main text range assert_eq!(result[1], 5..11); // Secondary text range assert_eq!(result[2], 4..7); // "o W" across boundary // Edge cases // Overlapping matches let label = Label::new("aaaa").highlights("aa"); let result = label.highlight_ranges("aaaa".len()); assert!(result.len() >= 2); assert_eq!(result[0], 0..2); // First "aa" assert_eq!(result[1], 1..3); // Overlapping "aa" // Unicode text let label = Label::new("你好世界,Hello World").highlights("世界"); let result = label.highlight_ranges("你好世界,Hello World".len()); assert_eq!(result.len(), 1); let text = "你好世界,Hello World"; let start = text.find("世界").unwrap(); let end = start + "世界".len(); assert_eq!(result[0], start..end); } #[test] fn test_highlight_ranges_prefix() { // Test prefix match - should only match the first occurrence let label = Label::new("aaaa").highlights(HighlightsMatch::Prefix("aa".into())); let result = label.highlight_ranges("aaaa".len()); assert_eq!(result.len(), 1); assert_eq!(result[0], 0..2); // Only first "aa" // Test prefix vs full match behavior let label_full = Label::new("Hello Hello").highlights(HighlightsMatch::Full("Hello".into())); let result_full = label_full.highlight_ranges("Hello Hello".len()); assert_eq!(result_full.len(), 2); // Both "Hello" matches let label_prefix = Label::new("Hello Hello").highlights(HighlightsMatch::Prefix("Hello".into())); let result_prefix = label_prefix.highlight_ranges("Hello Hello".len()); assert_eq!(result_prefix.len(), 1); // Only first "Hello" assert_eq!(result_prefix[0], 0..5); // Test prefix with case insensitive matching let label = Label::new("Hello hello HELLO").highlights(HighlightsMatch::Prefix("hello".into())); let result = label.highlight_ranges("Hello hello HELLO".len()); assert_eq!(result.len(), 1); assert_eq!(result[0], 0..5); // First "Hello" (case insensitive) // Test prefix with no match let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("xyz".into())); let result = label.highlight_ranges("Hello World".len()); assert_eq!(result.len(), 0); // Test prefix with empty string let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("".into())); let result = label.highlight_ranges("Hello World".len()); assert_eq!(result.len(), 0); // Test prefix with secondary text - match in main text let label = Label::new("Hello") .secondary("Hello World") .highlights(HighlightsMatch::Prefix("Hello".into())); let total_length = "Hello Hello World".len(); let result = label.highlight_ranges(total_length); assert_eq!(result.len(), 3); // 2 for secondary + 1 for prefix match assert_eq!(result[0], 0..5); // Main text range assert_eq!(result[1], 5..17); // Secondary text range assert_eq!(result[2], 0..5); // First "Hello" prefix match in main text // Test prefix with secondary text - match spans boundary (now no match since "abc" is not at start of full text) let label = Label::new("abc") .secondary("def abc def") .highlights(HighlightsMatch::Prefix("abc".into())); let total_length = "abc def abc def".len(); let result = label.highlight_ranges(total_length); assert_eq!(result.len(), 3); // 2 for secondary + 1 for prefix match assert_eq!(result[0], 0..3); // Main text range assert_eq!(result[1], 3..15); // Secondary text range assert_eq!(result[2], 0..3); // "abc" matches at start of full text // Test prefix with Unicode characters let label = Label::new("你好世界你好").highlights(HighlightsMatch::Prefix("你好".into())); let result = label.highlight_ranges("你好世界你好".len()); assert_eq!(result.len(), 1); assert_eq!(result[0], 0..6); // First "你好" (6 bytes in UTF-8) // Test prefix with overlapping pattern let label = Label::new("abababab").highlights(HighlightsMatch::Prefix("abab".into())); let result = label.highlight_ranges("abababab".len()); assert_eq!(result.len(), 1); assert_eq!(result[0], 0..4); // First "abab" only // Test prefix match at different positions (now no match since "Hello" is not at start) let label = Label::new("xyz Hello abc Hello").highlights(HighlightsMatch::Prefix("Hello".into())); let result = label.highlight_ranges("xyz Hello abc Hello".len()); assert_eq!(result.len(), 0); // No match since "Hello" is not at the beginning // Test is_prefix method let prefix_match = HighlightsMatch::Prefix("test".into()); let full_match = HighlightsMatch::Full("test".into()); assert!(prefix_match.is_prefix()); assert!(!full_match.is_prefix()); // Test as_str method for prefix let prefix_match = HighlightsMatch::Prefix("test".into()); assert_eq!(prefix_match.as_str(), "test"); } }