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:
parent
feabb748ec
commit
b34d8b6716
2 changed files with 193 additions and 32 deletions
|
|
@ -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()),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue