feat: Rich Text Rendering (#13)

* add text

* fix avatar is not show

* refactor chats

* improve rich text

* add benchmark for text

* update
This commit is contained in:
reya
2025-03-28 09:49:07 +07:00
committed by GitHub
parent 42d6328d82
commit cfc2300c0c
23 changed files with 1180 additions and 489 deletions

View File

@@ -5,6 +5,9 @@ edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true
serde.workspace = true
@@ -20,3 +23,11 @@ unicode-segmentation = "1.12.0"
uuid = "1.10"
once_cell = "1.19.0"
image = "0.25.1"
linkify = "0.10.0"
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "text_benchmark"
harness = false

View File

@@ -0,0 +1,142 @@
use common::profile::NostrProfile;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use gpui::SharedString;
use nostr_sdk::prelude::*;
use ui::text::render_plain_text_mut;
fn create_test_profiles() -> Vec<NostrProfile> {
let mut profiles = Vec::new();
// Create a few test profiles
for i in 0..5 {
let keypair = Keys::generate();
let profile = NostrProfile {
public_key: keypair.public_key(),
name: SharedString::from(format!("user{}", i)),
avatar: SharedString::from(format!("avatar{}", i)),
// Add other required fields based on NostrProfile definition
// This is a simplified version - adjust based on your actual NostrProfile struct
};
profiles.push(profile);
}
profiles
}
fn benchmark_plain_text(c: &mut Criterion) {
let profiles = create_test_profiles();
// Simple text without any links or entities
let simple_text = "This is a simple text message without any links or entities.";
// Text with URLs
let text_with_urls =
"Check out https://example.com and https://nostr.com for more information.";
// Text with nostr entities
let text_with_nostr = "I found this note nostr:note1qw5uy7hsqs4jcsvmjc2rj5t6f5uuenwg3yapm5l58srprspvshlspr4mh3 from npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft";
// Mixed content with urls and nostr entities
let mixed_content = "Check out https://example.com and my profile nostr:npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft along with this event nevent1qw5uy7hsqs4jcsvmjc2rj5t6f5uuenwg3yapm5l58srprspvshlspr4mh3";
// Long text with multiple links and entities
let long_text = "Here's a long message with multiple links like https://example1.com, https://example2.com, and https://example3.com. It also has nostr entities like npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft, note1qw5uy7hsqs4jcsvmjc2rj5t6f5uuenwg3yapm5l58srprspvshlspr4mh3, and nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuerpd46hxtnfdupzp8xummjw3exgcnqvmpw35xjueqvdnyqystngfxk5hsnfd9h8jtr8a4klacnp".repeat(3);
// Benchmark with simple text
c.bench_function("render_plain_text_simple", |b| {
b.iter(|| {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
black_box(simple_text),
black_box(&profiles),
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
)
})
});
// Benchmark with URLs
c.bench_function("render_plain_text_urls", |b| {
b.iter(|| {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
black_box(text_with_urls),
black_box(&profiles),
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
)
})
});
// Benchmark with nostr entities
c.bench_function("render_plain_text_nostr", |b| {
b.iter(|| {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
black_box(text_with_nostr),
black_box(&profiles),
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
)
})
});
// Benchmark with mixed content
c.bench_function("render_plain_text_mixed", |b| {
b.iter(|| {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
black_box(mixed_content),
black_box(&profiles),
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
)
})
});
// Benchmark with long text
c.bench_function("render_plain_text_long", |b| {
b.iter(|| {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
black_box(&long_text),
black_box(&profiles),
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
)
})
});
}
criterion_group!(benches, benchmark_plain_text);
criterion_main!(benches);

View File

@@ -22,6 +22,7 @@ pub mod scroll;
pub mod skeleton;
pub mod switch;
pub mod tab;
pub mod text;
pub mod theme;
pub mod tooltip;

View File

@@ -1,7 +1,7 @@
use gpui::{
px, relative, App, Axis, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId,
GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window,
px, relative, App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId,
EntityId, GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels,
Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window,
};
use crate::AxisExt;
@@ -111,6 +111,7 @@ impl Element for ScrollableMask {
bounds,
border_widths: Edges::all(px(1.0)),
border_color: color,
border_style: BorderStyle::Solid,
background: gpui::transparent_white().into(),
corner_radii: Corners::all(px(0.)),
});

View File

@@ -1,7 +1,7 @@
use gpui::{
fill, point, px, relative, App, Bounds, ContentMask, CursorStyle, Edges, Element, EntityId,
Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels,
Point, Position, ScrollHandle, ScrollWheelEvent, UniformListScrollHandle, Window,
fill, point, px, relative, App, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element,
EntityId, Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, UniformListScrollHandle, Window,
};
use serde::{Deserialize, Serialize};
use std::{
@@ -657,7 +657,7 @@ impl Element for Scrollbar {
let margin_end = state.margin_end;
let is_vertical = axis.is_vertical();
window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox);
window.set_cursor_style(CursorStyle::default(), Some(&state.bar_hitbox));
window.paint_layer(hitbox_bounds, |cx| {
cx.paint_quad(fill(state.bounds, state.bg));
@@ -682,6 +682,7 @@ impl Element for Scrollbar {
}
},
border_color: state.border,
border_style: BorderStyle::Solid,
});
cx.paint_quad(

379
crates/ui/src/text.rs Normal file
View File

@@ -0,0 +1,379 @@
use common::profile::NostrProfile;
use gpui::{
AnyElement, AnyView, App, ElementId, FontWeight, HighlightStyle, InteractiveText, IntoElement,
SharedString, StyledText, UnderlineStyle, Window,
};
use linkify::{LinkFinder, LinkKind};
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use regex::Regex;
use std::{collections::HashMap, ops::Range, sync::Arc};
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
static NOSTR_URI_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
static BECH32_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+\b").unwrap());
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Highlight(HighlightStyle),
Mention,
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Highlight(style)
}
}
type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[derive(Clone, Default)]
pub struct RichText {
pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>,
pub link_ranges: Vec<Range<usize>>,
pub link_urls: Arc<[String]>,
pub custom_ranges: Vec<Range<usize>>,
custom_ranges_tooltip_fn: CustomRangeTooltipFn,
}
impl RichText {
pub fn new(content: String, profiles: &[NostrProfile]) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
&content,
profiles,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
);
text.truncate(text.trim_end().len());
RichText {
text: SharedString::from(text),
link_urls: link_urls.into(),
link_ranges,
highlights,
custom_ranges: Vec::new(),
custom_ranges_tooltip_fn: None,
}
}
pub fn set_tooltip_builder_for_custom_ranges(
&mut self,
f: impl Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
) {
self.custom_ranges_tooltip_fn = Some(Arc::new(f));
}
pub fn element(&self, id: ElementId, window: &mut Window, cx: &App) -> AnyElement {
let link_color = cx.theme().accent.step(cx, ColorScaleStep::ELEVEN);
InteractiveText::new(
id,
StyledText::new(self.text.clone()).with_default_highlights(
&window.text_style(),
self.highlights.iter().map(|(range, highlight)| {
(
range.clone(),
match highlight {
Highlight::Highlight(highlight) => {
// Check if this is a link highlight by seeing if it has an underline
if highlight.underline.is_some() {
// It's a link, so apply the link color
let mut link_style = *highlight;
link_style.color = Some(link_color);
link_style
} else {
*highlight
}
}
Highlight::Mention => HighlightStyle {
color: Some(link_color),
font_weight: Some(FontWeight::MEDIUM),
..Default::default()
},
},
)
}),
),
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, _, cx| {
let url = &link_urls[ix];
if url.starts_with("http") {
cx.open_url(url);
}
// Handle mention URLs
else if url.starts_with("mention:") {
// Handle mention clicks
// For example: cx.emit_custom_event(MentionClicked(url.strip_prefix("mention:").unwrap().to_string()));
}
}
})
.tooltip({
let link_ranges = self.link_ranges.clone();
let link_urls = self.link_urls.clone();
let custom_tooltip_ranges = self.custom_ranges.clone();
let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone();
move |idx, window, cx| {
for (ix, range) in link_ranges.iter().enumerate() {
if range.contains(&idx) {
let url = &link_urls[ix];
if url.starts_with("http") {
// return Some(LinkPreview::new(url, cx));
}
// You can add custom tooltip handling for mentions here
}
}
for range in &custom_tooltip_ranges {
if range.contains(&idx) {
if let Some(f) = &custom_tooltip_fn {
return f(idx, range.clone(), window, cx);
}
}
}
None
}
})
.into_any_element()
}
}
pub fn render_plain_text_mut(
content: &str,
profiles: &[NostrProfile],
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
// Copy the content directly
text.push_str(content);
// Create a profile lookup using PublicKey directly
let profile_lookup: HashMap<&PublicKey, &NostrProfile> = profiles
.iter()
.map(|profile| (&profile.public_key, profile))
.collect();
// Process regular URLs using linkify
let mut finder = LinkFinder::new();
finder.kinds(&[LinkKind::Url]);
// Collect all URLs
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
for link in finder.links(content) {
let start = link.start();
let end = link.end();
let range = start..end;
let url = link.as_str().to_string();
url_matches.push((range, url));
}
// Process nostr entities with nostr: prefix
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
let start = nostr_match.start();
let end = nostr_match.end();
let range = start..end;
let nostr_uri = nostr_match.as_str().to_string();
// Check if this nostr URI overlaps with any already processed URL
if !url_matches
.iter()
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
{
nostr_matches.push((range, nostr_uri));
}
}
// Process raw bech32 entities (without nostr: prefix)
let mut bech32_matches: Vec<(Range<usize>, String)> = Vec::new();
for bech32_match in BECH32_REGEX.find_iter(content) {
let start = bech32_match.start();
let end = bech32_match.end();
let range = start..end;
let bech32_entity = bech32_match.as_str().to_string();
// Check if this entity overlaps with any already processed matches
let overlaps_with_url = url_matches
.iter()
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end);
let overlaps_with_nostr = nostr_matches
.iter()
.any(|(nostr_range, _)| nostr_range.start < range.end && range.start < nostr_range.end);
if !overlaps_with_url && !overlaps_with_nostr {
bech32_matches.push((range, bech32_entity));
}
}
// Combine all matches for processing from end to start
let mut all_matches = Vec::new();
all_matches.extend(url_matches);
all_matches.extend(nostr_matches);
all_matches.extend(bech32_matches);
// Sort by position (end to start) to avoid changing positions when replacing text
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
// Process all matches
for (range, entity) in all_matches {
if entity.starts_with("http") {
// Regular URL
highlights.push((
range.clone(),
Highlight::Highlight(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
));
link_ranges.push(range);
link_urls.push(entity);
} else {
let entity_without_prefix = if entity.starts_with("nostr:") {
entity.strip_prefix("nostr:").unwrap_or(&entity)
} else {
&entity
};
// Try to find a matching profile if this is npub or nprofile
let profile_match = if entity_without_prefix.starts_with("npub") {
PublicKey::from_bech32(entity_without_prefix)
.ok()
.and_then(|pubkey| profile_lookup.get(&pubkey).copied())
} else if entity_without_prefix.starts_with("nprofile") {
Nip19Profile::from_bech32(entity_without_prefix)
.ok()
.and_then(|profile| profile_lookup.get(&profile.public_key).copied())
} else {
None
};
if let Some(profile) = profile_match {
// Profile found - create a mention
let display_name = format!("@{}", profile.name);
// Replace mention with profile name
text.replace_range(range.clone(), &display_name);
// Adjust ranges
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add highlight for the profile name
highlights.push((new_range.clone(), Highlight::Mention));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(format!("mention:{}", entity_without_prefix));
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
} else {
// No profile match or not a profile entity - create njump.me link
let njump_url = format!("https://njump.me/{}", entity_without_prefix);
// Create a shortened display format for the URL
let shortened_entity = format_shortened_entity(entity_without_prefix);
let display_text = format!("https://njump.me/{}", shortened_entity);
// Replace the original entity with the shortened display version
text.replace_range(range.clone(), &display_text);
// Adjust the ranges
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add underline highlight
highlights.push((
new_range.clone(),
Highlight::Highlight(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(njump_url);
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
}
}
}
/// Format a bech32 entity with ellipsis and last 4 characters
fn format_shortened_entity(entity: &str) -> String {
let prefix_end = entity.find('1').unwrap_or(0);
if prefix_end > 0 && entity.len() > prefix_end + 5 {
let prefix = &entity[0..=prefix_end]; // Include the '1'
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
format!("{}...{}", prefix, suffix)
} else {
entity.to_string()
}
}
// Helper function to adjust ranges when text length changes
fn adjust_ranges(
highlights: &mut [(Range<usize>, Highlight)],
link_ranges: &mut [Range<usize>],
position: usize,
length_diff: isize,
) {
// Adjust highlight ranges
for (range, _) in highlights.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
}
// Adjust link ranges
for range in link_ranges.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
}
}

View File

@@ -104,7 +104,7 @@ impl RenderOnce for WindowBorder {
CursorStyle::ResizeUpRightDownLeft
}
},
&hitbox,
Some(&hitbox),
);
},
)