use std::ops::Range;
use gpui::{
App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Radius};
use crate::foundation::{Ident, StyledExt};
#[derive(Debug, IntoElement)]
pub struct HighlightedText {
ident: Option<Ident>,
text: SharedString,
hits: Vec<Range<usize>>,
current: Option<usize>,
monospace: bool,
}
impl HighlightedText {
pub fn new(text: impl Into<SharedString>) -> Self {
Self {
ident: None,
text: text.into(),
hits: Vec::new(),
current: None,
monospace: false,
}
}
pub fn id(mut self, ident: impl Into<Ident>) -> Self {
self.ident = Some(ident.into());
self
}
pub fn hits(mut self, hits: impl IntoIterator<Item = Range<usize>>) -> Self {
self.hits = hits.into_iter().collect();
self
}
pub fn current(mut self, index: usize) -> Self {
self.current = Some(index);
self
}
pub fn monospace(mut self, monospace: bool) -> Self {
self.monospace = monospace;
self
}
pub fn published_hits(&self) -> usize {
segments(self.text.as_ref(), &self.hits)
.iter()
.filter(|segment| segment.hit.is_some())
.count()
}
}
struct Segment {
text: String,
hit: Option<usize>,
}
fn segments(text: &str, hits: &[Range<usize>]) -> Vec<Segment> {
let mut out: Vec<Segment> = Vec::new();
let mut cut = 0usize;
for (index, range) in hits.iter().enumerate() {
if range.start < cut || range.start >= range.end {
continue;
}
let (Some(before), Some(inside)) =
(text.get(cut..range.start), text.get(range.start..range.end))
else {
continue;
};
if !before.is_empty() {
out.push(Segment {
text: before.to_string(),
hit: None,
});
}
out.push(Segment {
text: inside.to_string(),
hit: Some(index),
});
cut = range.end;
}
if let Some(rest) = text.get(cut..)
&& !rest.is_empty()
{
out.push(Segment {
text: rest.to_string(),
hit: None,
});
}
out
}
impl RenderOnce for HighlightedText {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let current = self.current;
let drawn = self.published_hits();
let runs = segments(self.text.as_ref(), &self.hits)
.into_iter()
.map(|segment| {
let is_current = segment.hit.is_some() && segment.hit == current;
div()
.when(segment.hit.is_some() && !is_current, |element| {
element
.bg(theme
.colors
.accent
.opacity(theme.effects.selected_ring_alpha))
.radius(&theme, Radius::Small)
})
.when(is_current, |element| {
element
.bg(theme.colors.accent)
.text_color(theme.colors.text_on_accent)
.radius(&theme, Radius::Small)
})
.child(SharedString::from(segment.text))
});
let element = div()
.flex()
.flex_row()
.flex_wrap()
.when(self.monospace, |element| {
element
.font_family(theme.typography.mono.clone())
.text_size(px(theme.typography.code.size))
.line_height(px(theme.typography.code.line_height))
})
.children(runs);
match self.ident {
Some(ident) => {
element
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Text)
.text(self.text.clone())
.value(drawn.to_string()),
)
.into_any_element()
}
None => element.into_any_element(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_range_outside_the_text_is_skipped_rather_than_drawn() {
let text = HighlightedText::new("abcdef").hits([0..3, 10..20]);
assert_eq!(text.published_hits(), 1);
}
#[test]
fn a_range_that_runs_back_over_an_earlier_one_is_skipped() {
let text = HighlightedText::new("abcdef").hits([2..4, 1..3]);
assert_eq!(text.published_hits(), 1);
}
#[test]
fn a_range_cutting_a_character_in_half_is_skipped() {
let text = HighlightedText::new("éx").hits([0..1, 0..2]);
assert_eq!(text.published_hits(), 1);
}
#[test]
fn the_whole_text_survives_being_cut_up() {
let joined: String = segments("hello world", &[0..5, 6..11])
.into_iter()
.map(|segment| segment.text)
.collect();
assert_eq!(joined, "hello world");
}
}