diff --git a/crates/story/src/label_story.rs b/crates/story/src/label_story.rs index 119a6d89..ec539b99 100644 --- a/crates/story/src/label_story.rs +++ b/crates/story/src/label_story.rs @@ -1,11 +1,12 @@ use gpui::{ div, px, rems, App, AppContext, Context, Entity, Focusable, IntoElement, ParentElement, Render, - Styled, Window, + SharedString, Styled, Subscription, Window, }; use gpui_component::{ button::{Button, ButtonVariant, ButtonVariants as _}, green_500, h_flex, + input::{InputEvent, InputState, TextInput}, label::Label, v_flex, IconName, StyledExt, }; @@ -15,6 +16,9 @@ use crate::section; pub struct LabelStory { focus_handle: gpui::FocusHandle, masked: bool, + highlights_text: SharedString, + highlights_input: Entity, + _subscriptions: Vec, } impl super::Story for LabelStory { @@ -32,10 +36,29 @@ impl super::Story for LabelStory { } impl LabelStory { - pub(crate) fn new(_: &mut Window, cx: &mut App) -> Self { + pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { + let highlights_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Enter text to highlight in the label") + .clean_on_escape() + }); + + let _subscriptions = + vec![ + cx.subscribe(&highlights_input, |this, _, e: &InputEvent, cx| { + if let InputEvent::Change(v) = e { + this.highlights_text = v.clone(); + cx.notify(); + } + }), + ]; + Self { focus_handle: cx.focus_handle(), masked: false, + highlights_text: Default::default(), + highlights_input, + _subscriptions, } } @@ -57,39 +80,57 @@ impl Render for LabelStory { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_6() + .child(TextInput::new(&self.highlights_input).cleanable().w_1_3()) .child( - section("Label") - .max_w_md() - .items_start() - .child(Label::new("This is a label")), + 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( section("Label with secondary text") .max_w_md() .items_start() - .child(Label::new("Company Address").secondary("(optional)")), + .child( + Label::new("Company Address") + .secondary("(optional)") + .highlights(&self.highlights_text), + ), ) .child( section("Alignment").max_w_md().child( v_flex() .w_full() .gap_4() - .child(Label::new("Text align left")) - .child(Label::new("Text align center").text_center()) - .child(Label::new("Text align right").text_right()), + .child(Label::new("Text align left").highlights(&self.highlights_text)) + .child( + Label::new("Text align center") + .text_center() + .highlights(&self.highlights_text), + ) + .child( + Label::new("Text align right") + .text_right() + .highlights(&self.highlights_text), + ), ), ) .child( - section("Label with color") - .max_w_md() - .child(Label::new("Color Label").text_color(green_500())), + section("Label with color").max_w_md().child( + Label::new("Color Label") + .text_color(green_500()) + .highlights(&self.highlights_text), + ), ) .child( section("Font Size").max_w_md().child( Label::new("Font Size Label") .text_size(px(20.)) .font_semibold() - .line_height(rems(1.8)), + .line_height(rems(1.8)) + .highlights(&self.highlights_text), ), ) .child( @@ -101,7 +142,8 @@ impl Render for LabelStory { "Label should support text wrap in default, \ if the text is too long, it should wrap to the next line.", ) - .line_height(rems(1.8)), + .line_height(rems(1.8)) + .highlights(&self.highlights_text), ), ), ) @@ -112,7 +154,12 @@ impl Render for LabelStory { .gap_4() .child( h_flex() - .child(Label::new("9,182,1 USD").text_2xl().masked(self.masked)) + .child( + Label::new("9,182,1 USD") + .text_2xl() + .masked(self.masked) + .highlights(&self.highlights_text), + ) .child( Button::new("btn-mask") .with_variant(ButtonVariant::Ghost) @@ -126,7 +173,12 @@ impl Render for LabelStory { })), ), ) - .child(Label::new("500 USD").text_xl().masked(self.masked)), + .child( + Label::new("500 USD") + .text_xl() + .masked(self.masked) + .highlights(&self.highlights_text), + ), ), ) } diff --git a/crates/ui/src/label.rs b/crates/ui/src/label.rs index 3cfbe4c3..b97ffc5b 100644 --- a/crates/ui/src/label.rs +++ b/crates/ui/src/label.rs @@ -1,6 +1,8 @@ +use std::ops::Range; + use gpui::{ - div, rems, App, HighlightStyle, IntoElement, ParentElement, RenderOnce, SharedString, - StyleRefinement, Styled, StyledText, Window, + div, prelude::FluentBuilder, rems, App, HighlightStyle, IntoElement, ParentElement, RenderOnce, + SharedString, StyleRefinement, Styled, StyledText, Window, }; use crate::{ActiveTheme, StyledExt}; @@ -13,6 +15,7 @@ pub struct Label { label: SharedString, secondary: Option, masked: bool, + highlights_text: Option, } impl Label { @@ -23,6 +26,7 @@ impl Label { label, secondary: None, masked: false, + highlights_text: None, } } @@ -37,6 +41,96 @@ impl Label { 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 { + if !matched.is_empty() { + let search_lower = matched.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); + } + + 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 { @@ -47,30 +141,145 @@ impl Styled for Label { impl RenderOnce for Label { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let mut text = match &self.secondary { - Some(secondary) => format!("{} {}", self.label, secondary).into(), - None => self.label.clone(), - }; + let mut text = self.full_text(); let chars_count = text.chars().count(); + if self.masked { text = SharedString::from(MASKED.repeat(chars_count)) }; - let mut highlights = vec![(0..self.label.len(), HighlightStyle::default())]; - if self.secondary.is_some() { - highlights.push(( - self.label.len()..text.len(), - HighlightStyle { - color: Some(cx.theme().muted_foreground), - ..Default::default() - }, - )); - } + 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).with_highlights(highlights)) + .child( + StyledText::new(&text).when_some(highlights, |this, hl| this.with_highlights(hl)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_no_highlights() { + let label = Label::new("Hello World"); + let result = label.highlight_ranges("Hello World".len()); + assert_eq!(result, Vec::>::new()); + } + + #[test] + fn test_secondary_text_ranges() { + 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" + } + + #[test] + fn test_highlights_text_single_match() { + 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" + } + + #[test] + fn test_highlights_text_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" + } + + #[test] + fn test_highlights_text_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" + } + + #[test] + fn test_highlights_text_no_match() { + let label = Label::new("Hello World").highlights("xyz"); + let result = label.highlight_ranges("Hello World".len()); + + assert_eq!(result, Vec::>::new()); + } + + #[test] + fn test_highlights_text_empty_search() { + let label = Label::new("Hello World").highlights(""); + let result = label.highlight_ranges("Hello World".len()); + + assert_eq!(result, Vec::>::new()); + } + + #[test] + fn test_both_secondary_and_highlights() { + 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 "Hello" + } + + #[test] + fn test_highlights_text_boundary() { + let label = Label::new("Hello World Hello").highlights("Hello"); + let result = label.highlight_ranges("Hello World Hello".len()); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], 0..5); // Start of "Hello" + assert_eq!(result[1], 12..17); // End of "Hello" + } + + #[test] + fn test_highlights_text_overlapping_match() { + 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" + if result.len() >= 3 { + assert_eq!(result[2], 2..4); // Third "aa" + } + } + + #[test] + fn test_partial_word_highlight() { + let label = Label::new("JavaScript is great").highlights("Script"); + let result = label.highlight_ranges("JavaScript is great".len()); + + assert_eq!(result.len(), 1); + assert_eq!(result[0], 4..10); // "Script" in "JavaScript" + } + + #[test] + fn test_unicode_text_highlight() { + 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); } }