396 lines
14 KiB
Rust
396 lines
14 KiB
Rust
use std::ops::Range;
|
||
|
||
use gpui::{
|
||
div, prelude::FluentBuilder, rems, App, HighlightStyle, IntoElement, ParentElement, RenderOnce,
|
||
SharedString, StyleRefinement, Styled, StyledText, Window,
|
||
};
|
||
|
||
use crate::{ActiveTheme, StyledExt};
|
||
|
||
const MASKED: &'static str = "•";
|
||
|
||
/// Represents the type of match for highlighting text in a label.
|
||
#[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)
|
||
}
|
||
}
|
||
|
||
/// A text label element with optional secondary text, masking, and highlighting capabilities.
|
||
#[derive(IntoElement)]
|
||
pub struct Label {
|
||
style: StyleRefinement,
|
||
label: SharedString,
|
||
secondary: Option<SharedString>,
|
||
masked: bool,
|
||
highlights_text: Option<HighlightsMatch>,
|
||
}
|
||
|
||
impl Label {
|
||
/// Create a new label with the main label.
|
||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||
let label: SharedString = label.into();
|
||
Self {
|
||
style: Default::default(),
|
||
label,
|
||
secondary: None,
|
||
masked: false,
|
||
highlights_text: None,
|
||
}
|
||
}
|
||
|
||
/// Set the secondary text for the label,
|
||
/// the secondary text will be displayed after the label text with `muted` color.
|
||
pub fn secondary(mut self, secondary: impl Into<SharedString>) -> Self {
|
||
self.secondary = Some(secondary.into());
|
||
self
|
||
}
|
||
|
||
/// Set whether to mask the label text.
|
||
pub fn masked(mut self, masked: bool) -> Self {
|
||
self.masked = masked;
|
||
self
|
||
}
|
||
|
||
/// Set for matching text to highlight in the label.
|
||
pub fn highlights(mut self, text: impl Into<HighlightsMatch>) -> 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 {
|
||
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();
|
||
|
||
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();
|
||
|
||
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 {
|
||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||
&mut self.style
|
||
}
|
||
}
|
||
|
||
impl RenderOnce for Label {
|
||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||
let mut text = self.full_text();
|
||
let chars_count = text.chars().count();
|
||
|
||
if self.masked {
|
||
text = SharedString::from(MASKED.repeat(chars_count))
|
||
};
|
||
|
||
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).when_some(highlights, |this, hl| this.with_highlights(hl)),
|
||
)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_highlight_ranges() {
|
||
// Basic functionality
|
||
|
||
// No highlights
|
||
let label = Label::new("Hello World");
|
||
let result = label.highlight_ranges("Hello World".len());
|
||
assert_eq!(result, Vec::<Range<usize>>::new());
|
||
|
||
// Secondary text ranges only
|
||
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"
|
||
|
||
// Text highlighting
|
||
|
||
// Single match with 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"
|
||
|
||
// 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"
|
||
|
||
// No match and empty search
|
||
let label = Label::new("Hello World").highlights("xyz");
|
||
let result = label.highlight_ranges("Hello World".len());
|
||
assert_eq!(result, Vec::<Range<usize>>::new());
|
||
|
||
let label = Label::new("Hello World").highlights("");
|
||
let result = label.highlight_ranges("Hello World".len());
|
||
assert_eq!(result, Vec::<Range<usize>>::new());
|
||
|
||
// Combined functionality
|
||
|
||
// Secondary + highlights in main text
|
||
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 main text
|
||
|
||
// Highlight in secondary text
|
||
let label = Label::new("Hello").secondary("World").highlights("World");
|
||
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], 6..11); // "World" in secondary text
|
||
|
||
// Cross-boundary highlight
|
||
let label = Label::new("Hello").secondary("World").highlights("o W");
|
||
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], 4..7); // "o W" across boundary
|
||
|
||
// Edge cases
|
||
|
||
// Overlapping matches
|
||
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"
|
||
|
||
// Unicode text
|
||
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);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|