label: Add HighlightsMatch to support prefix matching (#1243)

| Mode | |
| - | - |
| Full | <img width="480" alt="SCR-20250912-ptai"
src="https://github.com/user-attachments/assets/fbaaec1c-c376-42a0-a88c-18fc21a24ac3"
/> |
| Prefix | <img width="480" alt="SCR-20250912-ptbv"
src="https://github.com/user-attachments/assets/f68ca47f-7ad2-4214-b132-bf5abbc92764"
/> |
| | <img width="480" alt="SCR-20250912-ptef"
src="https://github.com/user-attachments/assets/1a3807cb-d2e4-4ed0-ab0b-d375deae1359"
/> |
This commit is contained in:
Floyd Wang 2025-09-12 18:35:55 +08:00 committed by GitHub
parent feabb748ec
commit b34d8b6716
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 193 additions and 32 deletions

View file

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

View file

@ -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<String> for HighlightsMatch {
fn from(value: String) -> Self {
Self::Full(value.into())
}
}
impl From<SharedString> for HighlightsMatch {
fn from(value: SharedString) -> Self {
Self::Full(value)
}
}
#[derive(IntoElement)]
pub struct Label {
style: StyleRefinement,
label: SharedString,
secondary: Option<SharedString>,
masked: bool,
highlights_text: Option<SharedString>,
highlights_text: Option<HighlightsMatch>,
}
impl Label {
@ -42,7 +80,7 @@ impl Label {
self
}
pub fn highlights(mut self, text: impl Into<SharedString>) -> Self {
pub fn highlights(mut self, text: impl Into<HighlightsMatch>) -> 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");
}
}