diff --git a/crates/story/src/label_story.rs b/crates/story/src/label_story.rs index ec539b99..9d7de948 100644 --- a/crates/story/src/label_story.rs +++ b/crates/story/src/label_story.rs @@ -5,9 +5,10 @@ use gpui::{ use gpui_component::{ button::{Button, ButtonVariant, ButtonVariants as _}, + checkbox::Checkbox, green_500, h_flex, input::{InputEvent, InputState, TextInput}, - label::Label, + label::{HighlightsMatch, Label}, v_flex, IconName, StyledExt, }; @@ -18,6 +19,7 @@ pub struct LabelStory { masked: bool, highlights_text: SharedString, highlights_input: Entity, + prefix: bool, _subscriptions: Vec, } @@ -58,6 +60,7 @@ impl LabelStory { masked: false, highlights_text: Default::default(), highlights_input, + prefix: false, _subscriptions, } } @@ -70,6 +73,14 @@ impl LabelStory { fn on_click(checked: &bool, window: &mut Window, cx: &mut App) { println!("Check value changed: {}", checked); } + + fn highlights_text(&self) -> HighlightsMatch { + if self.prefix { + HighlightsMatch::Prefix(self.highlights_text.clone()) + } else { + HighlightsMatch::Full(self.highlights_text.clone()) + } + } } impl Focusable for LabelStory { fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { @@ -78,15 +89,30 @@ impl Focusable for LabelStory { } impl Render for LabelStory { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let ht = self.highlights_text(); + v_flex() .gap_6() - .child(TextInput::new(&self.highlights_input).cleanable().w_1_3()) + .child( + h_flex() + .gap_x_3() + .child(TextInput::new(&self.highlights_input).cleanable().w_1_3()) + .child( + Checkbox::new("prefix") + .label("Prefix") + .checked(self.prefix) + .on_click(cx.listener(|view, _, _, cx| { + view.prefix = !view.prefix; + cx.notify(); + })), + ), + ) .child( section("Label").max_w_md().items_start().child( v_flex() .gap_y_4() - .child(Label::new("This is a label ").highlights(&self.highlights_text)) - .child(Label::new("这是一个标签").highlights(&self.highlights_text)), + .child(Label::new("This is a label").highlights(ht.clone())) + .child(Label::new("这是一个标签").highlights(ht.clone())), ), ) .child( @@ -96,7 +122,7 @@ impl Render for LabelStory { .child( Label::new("Company Address") .secondary("(optional)") - .highlights(&self.highlights_text), + .highlights(ht.clone()), ), ) .child( @@ -104,16 +130,16 @@ impl Render for LabelStory { v_flex() .w_full() .gap_4() - .child(Label::new("Text align left").highlights(&self.highlights_text)) + .child(Label::new("Text align left").highlights(ht.clone())) .child( Label::new("Text align center") .text_center() - .highlights(&self.highlights_text), + .highlights(ht.clone()), ) .child( Label::new("Text align right") .text_right() - .highlights(&self.highlights_text), + .highlights(ht.clone()), ), ), ) @@ -121,7 +147,7 @@ impl Render for LabelStory { section("Label with color").max_w_md().child( Label::new("Color Label") .text_color(green_500()) - .highlights(&self.highlights_text), + .highlights(ht.clone()), ), ) .child( @@ -130,7 +156,7 @@ impl Render for LabelStory { .text_size(px(20.)) .font_semibold() .line_height(rems(1.8)) - .highlights(&self.highlights_text), + .highlights(ht.clone()), ), ) .child( @@ -143,7 +169,7 @@ impl Render for LabelStory { if the text is too long, it should wrap to the next line.", ) .line_height(rems(1.8)) - .highlights(&self.highlights_text), + .highlights(ht.clone()), ), ), ) @@ -158,7 +184,7 @@ impl Render for LabelStory { Label::new("9,182,1 USD") .text_2xl() .masked(self.masked) - .highlights(&self.highlights_text), + .highlights(ht.clone()), ) .child( Button::new("btn-mask") @@ -177,7 +203,7 @@ impl Render for LabelStory { Label::new("500 USD") .text_xl() .masked(self.masked) - .highlights(&self.highlights_text), + .highlights(ht.clone()), ), ), ) diff --git a/crates/ui/src/label.rs b/crates/ui/src/label.rs index 29e5ccf6..22cb9141 100644 --- a/crates/ui/src/label.rs +++ b/crates/ui/src/label.rs @@ -9,13 +9,51 @@ 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, + highlights_text: Option, } impl Label { @@ -42,7 +80,7 @@ impl Label { self } - pub fn highlights(mut self, text: impl Into) -> Self { + pub fn highlights(mut self, text: impl Into) -> Self { self.highlights_text = Some(text.into()); self } @@ -64,28 +102,37 @@ impl Label { } if let Some(matched) = &self.highlights_text { - if !matched.is_empty() { - let search_lower = matched.to_lowercase(); + 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(); - 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.len(); - - if match_end <= full_text.len() { - ranges.push(match_start..match_end); + 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(); - search_start = match_start + 1; - while !full_text.is_char_boundary(search_start) - && search_start < full_text.len() - { - search_start += 1; - } + if match_end <= full_text.len() { + ranges.push(match_start..match_end); + } - if search_start >= full_text.len() { - break; + 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; + } } } } @@ -253,4 +300,92 @@ mod tests { 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"); + } }