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:
parent
7f3f2a3940
commit
b553ca8dcc
2 changed files with 295 additions and 34 deletions
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue