label: Add support for highlighting specified text (#1073)

<img width="1712" height="1312" alt="SCR-20250718-ogsp"
src="https://github.com/user-attachments/assets/5f0043ef-77c7-4d91-ad99-4061b14497ff"
/>
<img width="1712" height="1312" alt="SCR-20250718-oguh"
src="https://github.com/user-attachments/assets/ed0bf114-37d0-458d-9321-82766fe75076"
/>
This commit is contained in:
Floyd Wang 2025-07-18 16:33:36 +08:00 committed by GitHub
parent 7f3f2a3940
commit b553ca8dcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 295 additions and 34 deletions

View file

@ -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<InputState>,
_subscriptions: Vec<Subscription>,
}
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>) -> 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<Self>) -> 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),
),
),
)
}

View file

@ -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<SharedString>,
masked: bool,
highlights_text: Option<SharedString>,
}
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<SharedString>) -> 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<Range<usize>> {
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<Vec<(Range<usize>, 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::<Range<usize>>::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::<Range<usize>>::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::<Range<usize>>::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);
}
}