From 93d9360cb7306aa2ee96b375ab55f8c5d0b7e2e2 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 25 Sep 2026 09:24:30 +0700 Subject: [PATCH] render markdown message in community --- Cargo.lock | 5 +- crates/chat_ui/Cargo.toml | 3 - crates/chat_ui/src/lib.rs | 2 +- crates/chat_ui/src/text.rs | 504 ++--------------------------- crates/community_ui/src/lib.rs | 60 ++-- crates/community_ui/src/message.rs | 23 +- crates/ui/Cargo.toml | 4 + crates/ui/src/lib.rs | 1 + crates/ui/src/markdown.rs | 491 ++++++++++++++++++++++++++++ 9 files changed, 573 insertions(+), 520 deletions(-) create mode 100644 crates/ui/src/markdown.rs diff --git a/Cargo.lock b/Cargo.lock index ae99581b..ff03bcb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,11 +1095,9 @@ dependencies = [ "futures", "gpui-pre", "itertools 0.13.0", - "linkify", "log", "nostr-sdk", "person", - "pulldown-cmark", "regex", "serde", "settings", @@ -8016,7 +8014,10 @@ dependencies = [ "gpui-pre", "instant", "itertools 0.13.0", + "linkify", "log", + "pulldown-cmark", + "regex", "serde", "smallvec", "smol", diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index 23c2f285..2eb07f81 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -23,7 +23,4 @@ futures.workspace = true log.workspace = true serde.workspace = true -linkify = "0.10.0" -pulldown-cmark = "0.13.1" regex = "1" - diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 5d985f57..a2c876d2 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1176,7 +1176,7 @@ impl ChatPanel { .rendered_texts_by_id .entry(message.id) .or_insert_with(|| { - RenderedText::new(&message.content, &message.mentions, &persons, true, cx) + text::rendered_text(&message.content, &message.mentions, &persons, true, cx) }) .element(ix.into(), window, cx); diff --git a/crates/chat_ui/src/text.rs b/crates/chat_ui/src/text.rs index 2f90e75d..d206482c 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -1,488 +1,28 @@ -use std::ops::Range; -use std::sync::{Arc, LazyLock}; - use chat::Mention; -use gpui::{ - AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText, - IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window, -}; +use gpui::{App, Entity}; use person::PersonRegistry; -use regex::Regex; -use theme::ActiveTheme; +use ui::markdown::InlineReplacement; +pub use ui::markdown::RenderedText; -/// Matches `http://` and `https://` URLs. Only these are treated as clickable links. -static WEB_URL: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap()); - -#[allow(clippy::enum_variant_names)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Highlight { - Code, - InlineCode(bool), - Highlight(HighlightStyle), - Mention, -} - -impl From for Highlight { - fn from(style: HighlightStyle) -> Self { - Self::Highlight(style) - } -} - -#[derive(Default)] -pub struct RenderedText { - pub text: SharedString, - pub highlights: Vec<(Range, Highlight)>, - pub link_ranges: Vec>, - pub link_urls: Arc<[String]>, -} - -impl RenderedText { - pub fn new( - content: &str, - mentions: &[Mention], - persons: &Entity, - markdown: bool, - cx: &App, - ) -> Self { - Self::render(content, mentions, markdown, |mention| { - format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()) - }) - } - - fn render( - content: &str, - mentions: &[Mention], - markdown: bool, - resolve_mention: impl Fn(&Mention) -> String, - ) -> Self { - let mut text = String::new(); - let mut highlights = Vec::new(); - let mut link_ranges = Vec::new(); - let mut link_urls = Vec::new(); - - render_text_mut( - content, - mentions, - &mut text, - &mut highlights, - &mut link_ranges, - &mut link_urls, - markdown, - resolve_mention, - ); - - // Trim trailing whitespace and adjust highlight and link ranges. - let trimmed_len = text.trim_end().len(); - - // Retain highlights and link ranges that are within the trimmed text. - if trimmed_len < text.len() { - highlights.retain_mut(|(range, _)| { - range.end = range.end.min(trimmed_len); - range.start < range.end - }); - - let mut ix = 0; - - while ix < link_ranges.len() { - let range = &mut link_ranges[ix]; - range.end = range.end.min(trimmed_len); - if range.start < range.end { - ix += 1; - } else { - link_ranges.remove(ix); - link_urls.remove(ix); - } - } - - text.truncate(trimmed_len); - } - - RenderedText { - text: SharedString::from(text), - link_urls: link_urls.into(), - link_ranges, - highlights, - } - } - - pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement { - let code_background = cx.theme().elevated_surface_background; - let color = cx.theme().text_accent; - let code_font = if cfg!(target_os = "macos") { - "Menlo" - } else if cfg!(target_os = "windows") { - "Consolas" - } else { - "monospace" - }; - - 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::Code => HighlightStyle { - background_color: Some(code_background), - ..Default::default() - }, - Highlight::InlineCode(link) => { - if *link { - HighlightStyle { - background_color: Some(code_background), - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - } - } else { - HighlightStyle { - background_color: Some(code_background), - ..Default::default() - } - } - } - Highlight::Mention => HighlightStyle { - color: Some(color), - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - }, - Highlight::Highlight(highlight) => *highlight, - }, - ) - }), - ) - .with_font_family_overrides(self.highlights.iter().filter_map( - |(range, highlight)| match highlight { - Highlight::Code | Highlight::InlineCode(_) => { - Some((range.clone(), code_font.into())) - } - _ => None, - }, - )), - ) - .on_click(self.link_ranges.clone(), { - let link_urls = self.link_urls.clone(); - move |ix, _, cx| { - let url = &link_urls[ix]; - if WEB_URL.is_match(url) { - cx.open_url(url); - } - } - }) - .into_any_element() - } -} - -#[allow(clippy::too_many_arguments)] -fn render_text_mut( - block: &str, - mut mentions: &[Mention], - text: &mut String, - highlights: &mut Vec<(Range, Highlight)>, - link_ranges: &mut Vec>, - link_urls: &mut Vec, +/// Render message `content` to text, replacing mentions with their display names. +/// +/// When `markdown` is set the content is parsed as markdown. +pub fn rendered_text( + content: &str, + mentions: &[Mention], + persons: &Entity, markdown: bool, - resolve_mention: impl Fn(&Mention) -> String, -) { - use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + cx: &App, +) -> RenderedText { + let replacements = mentions + .iter() + .map(|mention| { + InlineReplacement::new( + mention.range.clone(), + format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()), + ) + }) + .collect::>(); - let mut bold_depth = 0; - let mut italic_depth = 0; - let mut strikethrough_depth = 0; - let mut link_url = None; - let mut list_stack = Vec::new(); - let mut code_block = false; - - // Only enable the extensions that make sense for chat messages. Notably this leaves - // out smart punctuation, tables, math and footnotes: they rewrite or swallow text. - let events: Box, Range)> + '_> = if markdown { - Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter()) - } else { - Box::new(std::iter::once((Event::Text(block.into()), 0..block.len()))) - }; - - for (event, source_range) in events { - let prev_len = text.len(); - - match event { - Event::Text(t) => { - if code_block { - text.push_str(t.as_ref()); - highlights.push((prev_len..text.len(), Highlight::Code)); - continue; - } - - let t_str = t.as_ref(); - let mut last_processed = 0; - - while let Some(mention) = mentions.first() { - if mention.range.start >= source_range.end { - break; - } - - mentions = &mentions[1..]; - if mention.range.start < source_range.start - || mention.range.end > source_range.end - { - continue; - } - - let Some(token) = block.get(mention.range.clone()) else { - continue; - }; - - let Some(offset) = t_str[last_processed..].find(token) else { - continue; - }; - - let mention_start_in_text = last_processed + offset; - let mention_end_in_text = mention_start_in_text + token.len(); - - // Add text before this mention - if mention_start_in_text > last_processed { - let before_mention = &t_str[last_processed..mention_start_in_text]; - process_text_segment( - before_mention, - bold_depth, - italic_depth, - strikethrough_depth, - link_url.clone(), - text, - highlights, - link_ranges, - link_urls, - ); - } - - // Process the mention replacement - let replacement_text = resolve_mention(mention); - let replacement_start = text.len(); - text.push_str(&replacement_text); - let replacement_end = text.len(); - - highlights.push((replacement_start..replacement_end, Highlight::Mention)); - - last_processed = mention_end_in_text; - } - - // Add any remaining text after the last mention - if last_processed < t_str.len() { - let remaining_text = &t_str[last_processed..]; - process_text_segment( - remaining_text, - bold_depth, - italic_depth, - strikethrough_depth, - link_url.clone(), - text, - highlights, - link_ranges, - link_urls, - ); - } - } - Event::Code(t) => { - text.push_str(t.as_ref()); - let is_link = link_url.is_some(); - - if let Some(link_url) = link_url.clone() { - link_ranges.push(prev_len..text.len()); - link_urls.push(link_url); - } - - highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link))) - } - Event::Start(tag) => match tag { - Tag::Paragraph => new_paragraph(text, &mut list_stack), - Tag::Heading { .. } => { - new_paragraph(text, &mut list_stack); - bold_depth += 1; - } - Tag::CodeBlock(_kind) => { - new_paragraph(text, &mut list_stack); - code_block = true; - } - Tag::Emphasis => italic_depth += 1, - Tag::Strong => bold_depth += 1, - Tag::Strikethrough => strikethrough_depth += 1, - Tag::Link { dest_url, .. } => { - link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string()); - } - Tag::List(number) => { - list_stack.push((number, false)); - } - Tag::Item => { - let len = list_stack.len(); - if let Some((list_number, has_content)) = list_stack.last_mut() { - *has_content = false; - if !text.is_empty() && !text.ends_with('\n') { - text.push('\n'); - } - for _ in 0..len - 1 { - text.push_str(" "); - } - if let Some(number) = list_number { - text.push_str(&format!("{}. ", number)); - *number += 1; - *has_content = false; - } else { - text.push_str("- "); - } - } - } - _ => {} - }, - Event::End(tag) => match tag { - TagEnd::CodeBlock => code_block = false, - TagEnd::Heading(_) => bold_depth -= 1, - TagEnd::Emphasis => italic_depth -= 1, - TagEnd::Strong => bold_depth -= 1, - TagEnd::Strikethrough => strikethrough_depth -= 1, - TagEnd::Link => link_url = None, - TagEnd::List(_) => drop(list_stack.pop()), - _ => {} - }, - Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()), - Event::Rule => { - new_paragraph(text, &mut list_stack); - text.push_str("────────\n"); - } - Event::HardBreak => text.push('\n'), - Event::SoftBreak => text.push('\n'), - _ => {} - } - } -} - -#[allow(clippy::too_many_arguments)] -fn process_text_segment( - segment: &str, - bold_depth: i32, - italic_depth: i32, - strikethrough_depth: i32, - link_url: Option, - text: &mut String, - highlights: &mut Vec<(Range, Highlight)>, - link_ranges: &mut Vec>, - link_urls: &mut Vec, -) { - // Build the style for this segment - let mut style = HighlightStyle::default(); - if bold_depth > 0 { - style.font_weight = Some(FontWeight::BOLD); - } - if italic_depth > 0 { - style.font_style = Some(FontStyle::Italic); - } - if strikethrough_depth > 0 { - style.strikethrough = Some(StrikethroughStyle { - thickness: 1.0.into(), - ..Default::default() - }); - } - - // Ranges always refer to the rendered text, including replaced mentions. - let segment_start = text.len(); - text.push_str(segment); - let text_end = text.len(); - - if let Some(link_url) = link_url { - // Handle as a markdown link - link_ranges.push(segment_start..text_end); - link_urls.push(link_url); - style.underline = Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }); - - // Add highlight for the entire linked segment - if style != HighlightStyle::default() { - highlights.push((segment_start..text_end, Highlight::Highlight(style))); - } - } else { - // Handle link detection within the segment - let mut finder = linkify::LinkFinder::new(); - finder.kinds(&[linkify::LinkKind::Url]); - let mut last_link_pos = 0; - - for link in finder - .links(segment) - .filter(|link| WEB_URL.is_match(link.as_str())) - { - let start = link.start(); - let end = link.end(); - - // Add non-link text before this link - if start > last_link_pos { - let non_link_start = segment_start + last_link_pos; - let non_link_end = segment_start + start; - - if style != HighlightStyle::default() { - highlights.push((non_link_start..non_link_end, Highlight::Highlight(style))); - } - } - - // Add the link - let range = (segment_start + start)..(segment_start + end); - link_ranges.push(range.clone()); - link_urls.push(link.as_str().to_string()); - - // Apply link styling (underline + existing style) - let mut link_style = style; - link_style.underline = Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }); - - highlights.push((range, Highlight::Highlight(link_style))); - - last_link_pos = end; - } - - // Add any remaining text after the last link - if last_link_pos < segment.len() { - let remaining_start = segment_start + last_link_pos; - let remaining_end = segment_start + segment.len(); - - if style != HighlightStyle::default() { - highlights.push((remaining_start..remaining_end, Highlight::Highlight(style))); - } - } - } -} - -fn new_paragraph(text: &mut String, list_stack: &mut [(Option, bool)]) { - let mut is_subsequent_paragraph_of_list = false; - - if let Some((_, has_content)) = list_stack.last_mut() { - if *has_content { - is_subsequent_paragraph_of_list = true; - } else { - *has_content = true; - return; - } - } - - if !text.is_empty() { - if !text.ends_with('\n') { - text.push('\n'); - } - text.push('\n'); - } - - for _ in 0..list_stack.len().saturating_sub(1) { - text.push_str(" "); - } - - if is_subsequent_paragraph_of_list { - text.push_str(" "); - } + RenderedText::new(content, &replacements, markdown) } diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs index 848a0623..859d0f0c 100644 --- a/crates/community_ui/src/lib.rs +++ b/crates/community_ui/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt; use anyhow::Result; @@ -19,6 +19,7 @@ use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::input::{InputEvent, Textarea, TextareaState}; +use ui::markdown::RenderedText; use ui::message::WelcomeMessage; use ui::notification::Notification; use ui::scroll::Scrollbar; @@ -98,6 +99,8 @@ pub struct CommunityPanel { channel: Option, /// The selected channel's timeline (oldest first) rows: Vec, + /// Rendered markdown content, keyed by message id and dropped when a row changes + rendered_texts_by_id: BTreeMap, /// Whether the store holds rows older than `rows` has_more: bool, /// A round or a page read is in flight @@ -163,34 +166,32 @@ impl CommunityPanel { }, )); - let panel = Self { + cx.defer_in(window, |this, window, cx| { + this.list_state.set_follow_mode(FollowMode::Tail); + this.list_state.set_scroll_handler(cx.listener( + |this, event: &ListScrollEvent, window, cx| { + if event.visible_range.start <= LOAD_OLDER_THRESHOLD { + this.load_older(window, cx); + } + }, + )); + this.load(window, cx); + }); + + Self { id, focus_handle: cx.focus_handle(), community: community.downgrade(), channel, rows: Vec::new(), + rendered_texts_by_id: BTreeMap::new(), has_more: false, loading: false, list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)), input, tasks: smallvec![], _subscriptions: subscriptions, - }; - - panel.list_state.set_follow_mode(FollowMode::Tail); - panel.list_state.set_scroll_handler(cx.listener( - |this, event: &ListScrollEvent, window, cx| { - if event.visible_range.start <= LOAD_OLDER_THRESHOLD { - this.load_older(window, cx); - } - }, - )); - - cx.defer_in(window, |this, window, cx| { - this.load(window, cx); - }); - - panel + } } /// The channel to show, following the community's selection. @@ -204,6 +205,7 @@ impl CommunityPanel { if channel != self.channel { self.channel = channel; self.rows.clear(); + self.rendered_texts_by_id.clear(); self.has_more = false; self.loading = false; self.list_state.reset(2); @@ -493,6 +495,7 @@ impl CommunityPanel { if !connected { self.rows = messages; + self.rendered_texts_by_id.clear(); self.has_more = has_more; self.list_state.reset(self.item_count()); cx.notify(); @@ -527,7 +530,13 @@ impl CommunityPanel { for message in messages { match shown.get(&message.id).copied() { - Some(ix) => self.rows[ix] = message, + Some(ix) => { + // Drop the cached render when the text changes, so edits re-parse. + if self.rows[ix].content != message.content { + self.rendered_texts_by_id.remove(&message.id); + } + self.rows[ix] = message; + } None => fresh.push(message), } } @@ -690,7 +699,7 @@ impl CommunityPanel { fn render_message( &mut self, ix: usize, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> AnyElement { if ix == 0 { @@ -708,7 +717,16 @@ impl CommunityPanel { let show_author = self.opens_run(ix - 2); - message::render(ix, message, show_author, cx) + let content = if message.deleted { + message::deleted(cx) + } else { + self.rendered_texts_by_id + .entry(message.id) + .or_insert_with(|| RenderedText::new(&message.content, &[], true)) + .element(ix.into(), window, cx) + }; + + message::render(ix, message, content, show_author, cx) } fn render_composer(&self, cx: &mut Context) -> impl IntoElement { diff --git a/crates/community_ui/src/message.rs b/crates/community_ui/src/message.rs index 20bfe8e3..cfeaf441 100644 --- a/crates/community_ui/src/message.rs +++ b/crates/community_ui/src/message.rs @@ -12,7 +12,13 @@ use ui::avatar::Avatar; use ui::h_flex; use ui::message::MessageRow; -pub(crate) fn render(ix: usize, message: &ChatMessage, show_author: bool, cx: &App) -> AnyElement { +pub(crate) fn render( + ix: usize, + message: &ChatMessage, + content: AnyElement, + show_author: bool, + cx: &App, +) -> AnyElement { let persons = PersonRegistry::global(cx); let author = persons.read(cx).get(&message.author, cx); let hide_avatar = AppSettings::get_hide_avatar(cx); @@ -30,23 +36,18 @@ pub(crate) fn render(ix: usize, message: &ChatMessage, show_author: bool, cx: &A .when(message.edited_at.is_some(), |this| { this.header_extra(div().child("(edited)")) }) - .child(content(message, cx)) + .child(content) .when(!message.reactions.is_empty(), |this| { this.child(reactions(message, cx)) }) .into_any_element() } -fn content(message: &ChatMessage, cx: &App) -> AnyElement { - if message.deleted { - return div() - .text_color(cx.theme().text_danger) - .child("Message deleted") - .into_any_element(); - } - +/// The placeholder shown in place of the body of a deleted message. +pub(crate) fn deleted(cx: &App) -> AnyElement { div() - .child(SharedString::from(&message.content)) + .text_color(cx.theme().text_danger) + .child("Message deleted") .into_any_element() } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 7ce646ef..9f23fefc 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -19,5 +19,9 @@ log.workspace = true uuid = "1.10" +linkify = "0.10.0" +pulldown-cmark = "0.13.1" +regex = "1" + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] smol.workspace = true diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 8815144d..d4ac3f1c 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dock; pub mod group_box; pub mod indicator; pub mod input; +pub mod markdown; pub mod menu; pub mod message; pub mod modal; diff --git a/crates/ui/src/markdown.rs b/crates/ui/src/markdown.rs new file mode 100644 index 00000000..9556aa6c --- /dev/null +++ b/crates/ui/src/markdown.rs @@ -0,0 +1,491 @@ +use std::ops::Range; +use std::sync::{Arc, LazyLock}; + +use gpui::{ + AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText, + IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window, +}; +use regex::Regex; +use theme::ActiveTheme; + +/// Matches `http://` and `https://` URLs. Only these are treated as clickable links. +static WEB_URL: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap()); + +/// A span of the source content, by byte range, that renders as `text` instead. +/// +/// Replacements are resolved before rendering and styled as an accent-colored +/// link. Mentions are one use: callers resolve the public key to a display name +/// and hand the renderer the span to substitute. They must be sorted by +/// `range.start` and must not overlap. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InlineReplacement { + pub range: Range, + pub text: SharedString, +} + +impl InlineReplacement { + pub fn new(range: Range, text: impl Into) -> Self { + Self { + range, + text: text.into(), + } + } +} + +#[allow(clippy::enum_variant_names)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Highlight { + Code, + InlineCode(bool), + Replacement, + Style(HighlightStyle), +} + +impl From for Highlight { + fn from(style: HighlightStyle) -> Self { + Self::Style(style) + } +} + +/// Message content flattened into a single styled string. +/// +/// Markdown structure (headings, lists, code blocks, emphasis, links) is turned +/// into plain text plus highlight ranges, ready to hand to an `InteractiveText`. +#[derive(Default)] +pub struct RenderedText { + pub text: SharedString, + pub highlights: Vec<(Range, Highlight)>, + pub link_ranges: Vec>, + pub link_urls: Arc<[String]>, +} + +impl RenderedText { + /// Parse `content`, optionally as markdown, replacing `replacements` inline. + pub fn new(content: &str, replacements: &[InlineReplacement], markdown: bool) -> Self { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut link_ranges = Vec::new(); + let mut link_urls = Vec::new(); + + render_text_mut( + content, + replacements, + &mut text, + &mut highlights, + &mut link_ranges, + &mut link_urls, + markdown, + ); + + // Trim trailing whitespace and adjust highlight and link ranges. + let trimmed_len = text.trim_end().len(); + + // Retain highlights and link ranges that are within the trimmed text. + if trimmed_len < text.len() { + highlights.retain_mut(|(range, _)| { + range.end = range.end.min(trimmed_len); + range.start < range.end + }); + + let mut ix = 0; + + while ix < link_ranges.len() { + let range = &mut link_ranges[ix]; + range.end = range.end.min(trimmed_len); + if range.start < range.end { + ix += 1; + } else { + link_ranges.remove(ix); + link_urls.remove(ix); + } + } + + text.truncate(trimmed_len); + } + + RenderedText { + text: SharedString::from(text), + link_urls: link_urls.into(), + link_ranges, + highlights, + } + } + + pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement { + let code_background = cx.theme().elevated_surface_background; + let color = cx.theme().text_accent; + let code_font = if cfg!(target_os = "macos") { + "Menlo" + } else if cfg!(target_os = "windows") { + "Consolas" + } else { + "monospace" + }; + + 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::Code => HighlightStyle { + background_color: Some(code_background), + ..Default::default() + }, + Highlight::InlineCode(link) => { + if *link { + HighlightStyle { + background_color: Some(code_background), + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + } + } else { + HighlightStyle { + background_color: Some(code_background), + ..Default::default() + } + } + } + Highlight::Replacement => HighlightStyle { + color: Some(color), + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + }, + Highlight::Style(highlight) => *highlight, + }, + ) + }), + ) + .with_font_family_overrides(self.highlights.iter().filter_map( + |(range, highlight)| match highlight { + Highlight::Code | Highlight::InlineCode(_) => { + Some((range.clone(), code_font.into())) + } + _ => None, + }, + )), + ) + .on_click(self.link_ranges.clone(), { + let link_urls = self.link_urls.clone(); + move |ix, _, cx| { + let url = &link_urls[ix]; + if WEB_URL.is_match(url) { + cx.open_url(url); + } + } + }) + .into_any_element() + } +} + +#[allow(clippy::too_many_arguments)] +fn render_text_mut( + block: &str, + replacements: &[InlineReplacement], + text: &mut String, + highlights: &mut Vec<(Range, Highlight)>, + link_ranges: &mut Vec>, + link_urls: &mut Vec, + markdown: bool, +) { + use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + + let mut bold_depth = 0; + let mut italic_depth = 0; + let mut strikethrough_depth = 0; + let mut link_url = None; + let mut list_stack = Vec::new(); + let mut code_block = false; + + // Only enable the extensions that make sense for chat messages. Notably this leaves + // out smart punctuation, tables, math and footnotes: they rewrite or swallow text. + let events: Box, Range)> + '_> = if markdown { + Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter()) + } else { + Box::new(std::iter::once((Event::Text(block.into()), 0..block.len()))) + }; + + for (event, source_range) in events { + let prev_len = text.len(); + + match event { + Event::Text(t) => { + if code_block { + text.push_str(t.as_ref()); + highlights.push((prev_len..text.len(), Highlight::Code)); + continue; + } + + let t_str = t.as_ref(); + let mut last_processed = 0; + + for replacement in replacements { + if replacement.range.start >= source_range.end { + break; + } + + if replacement.range.start < source_range.start + || replacement.range.end > source_range.end + { + continue; + } + + let Some(token) = block.get(replacement.range.clone()) else { + continue; + }; + + let Some(offset) = t_str[last_processed..].find(token) else { + continue; + }; + + let replacement_start_in_text = last_processed + offset; + let replacement_end_in_text = replacement_start_in_text + token.len(); + + // Add text before this replacement + if replacement_start_in_text > last_processed { + let before_replacement = &t_str[last_processed..replacement_start_in_text]; + process_text_segment( + before_replacement, + bold_depth, + italic_depth, + strikethrough_depth, + link_url.clone(), + text, + highlights, + link_ranges, + link_urls, + ); + } + + // Process the replacement + let replacement_start = text.len(); + text.push_str(&replacement.text); + let replacement_end = text.len(); + + highlights.push((replacement_start..replacement_end, Highlight::Replacement)); + + last_processed = replacement_end_in_text; + } + + // Add any remaining text after the last replacement + if last_processed < t_str.len() { + let remaining_text = &t_str[last_processed..]; + process_text_segment( + remaining_text, + bold_depth, + italic_depth, + strikethrough_depth, + link_url.clone(), + text, + highlights, + link_ranges, + link_urls, + ); + } + } + Event::Code(t) => { + text.push_str(t.as_ref()); + let is_link = link_url.is_some(); + + if let Some(link_url) = link_url.clone() { + link_ranges.push(prev_len..text.len()); + link_urls.push(link_url); + } + + highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link))) + } + Event::Start(tag) => match tag { + Tag::Paragraph => new_paragraph(text, &mut list_stack), + Tag::Heading { .. } => { + new_paragraph(text, &mut list_stack); + bold_depth += 1; + } + Tag::CodeBlock(_kind) => { + new_paragraph(text, &mut list_stack); + code_block = true; + } + Tag::Emphasis => italic_depth += 1, + Tag::Strong => bold_depth += 1, + Tag::Strikethrough => strikethrough_depth += 1, + Tag::Link { dest_url, .. } => { + link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string()); + } + Tag::List(number) => { + list_stack.push((number, false)); + } + Tag::Item => { + let len = list_stack.len(); + if let Some((list_number, has_content)) = list_stack.last_mut() { + *has_content = false; + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + for _ in 0..len - 1 { + text.push_str(" "); + } + if let Some(number) = list_number { + text.push_str(&format!("{}. ", number)); + *number += 1; + *has_content = false; + } else { + text.push_str("- "); + } + } + } + _ => {} + }, + Event::End(tag) => match tag { + TagEnd::CodeBlock => code_block = false, + TagEnd::Heading(_) => bold_depth -= 1, + TagEnd::Emphasis => italic_depth -= 1, + TagEnd::Strong => bold_depth -= 1, + TagEnd::Strikethrough => strikethrough_depth -= 1, + TagEnd::Link => link_url = None, + TagEnd::List(_) => drop(list_stack.pop()), + _ => {} + }, + Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()), + Event::Rule => { + new_paragraph(text, &mut list_stack); + text.push_str("────────\n"); + } + Event::HardBreak => text.push('\n'), + Event::SoftBreak => text.push('\n'), + _ => {} + } + } +} + +#[allow(clippy::too_many_arguments)] +fn process_text_segment( + segment: &str, + bold_depth: i32, + italic_depth: i32, + strikethrough_depth: i32, + link_url: Option, + text: &mut String, + highlights: &mut Vec<(Range, Highlight)>, + link_ranges: &mut Vec>, + link_urls: &mut Vec, +) { + // Build the style for this segment + let mut style = HighlightStyle::default(); + if bold_depth > 0 { + style.font_weight = Some(FontWeight::BOLD); + } + if italic_depth > 0 { + style.font_style = Some(FontStyle::Italic); + } + if strikethrough_depth > 0 { + style.strikethrough = Some(StrikethroughStyle { + thickness: 1.0.into(), + ..Default::default() + }); + } + + // Ranges always refer to the rendered text, including replaced spans. + let segment_start = text.len(); + text.push_str(segment); + let text_end = text.len(); + + if let Some(link_url) = link_url { + // Handle as a markdown link + link_ranges.push(segment_start..text_end); + link_urls.push(link_url); + style.underline = Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }); + + // Add highlight for the entire linked segment + if style != HighlightStyle::default() { + highlights.push((segment_start..text_end, Highlight::Style(style))); + } + } else { + // Handle link detection within the segment + let mut finder = linkify::LinkFinder::new(); + finder.kinds(&[linkify::LinkKind::Url]); + let mut last_link_pos = 0; + + for link in finder + .links(segment) + .filter(|link| WEB_URL.is_match(link.as_str())) + { + let start = link.start(); + let end = link.end(); + + // Add non-link text before this link + if start > last_link_pos { + let non_link_start = segment_start + last_link_pos; + let non_link_end = segment_start + start; + + if style != HighlightStyle::default() { + highlights.push((non_link_start..non_link_end, Highlight::Style(style))); + } + } + + // Add the link + let range = (segment_start + start)..(segment_start + end); + link_ranges.push(range.clone()); + link_urls.push(link.as_str().to_string()); + + // Apply link styling (underline + existing style) + let mut link_style = style; + link_style.underline = Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }); + + highlights.push((range, Highlight::Style(link_style))); + + last_link_pos = end; + } + + // Add any remaining text after the last link + if last_link_pos < segment.len() { + let remaining_start = segment_start + last_link_pos; + let remaining_end = segment_start + segment.len(); + + if style != HighlightStyle::default() { + highlights.push((remaining_start..remaining_end, Highlight::Style(style))); + } + } + } +} + +fn new_paragraph(text: &mut String, list_stack: &mut [(Option, bool)]) { + let mut is_subsequent_paragraph_of_list = false; + + if let Some((_, has_content)) = list_stack.last_mut() { + if *has_content { + is_subsequent_paragraph_of_list = true; + } else { + *has_content = true; + return; + } + } + + if !text.is_empty() { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push('\n'); + } + + for _ in 0..list_stack.len().saturating_sub(1) { + text.push_str(" "); + } + + if is_subsequent_paragraph_of_list { + text.push_str(" "); + } +}