diff --git a/Cargo.lock b/Cargo.lock index fe6f24bd..070d77cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2543,15 +2543,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -4147,15 +4138,6 @@ version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" -[[package]] -name = "linkify" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" -dependencies = [ - "memchr", -] - [[package]] name = "linktime-proc-macro" version = "0.2.3" @@ -5845,25 +5827,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags 2.13.2", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - [[package]] name = "pulp" version = "0.22.3" @@ -8017,19 +7980,13 @@ dependencies = [ name = "ui" version = "1.0.2" dependencies = [ - "anyhow", "common", "gpui-base", "gpui-pre", "instant", - "itertools 0.13.0", - "linkify", "log", - "pulldown-cmark", - "regex", "serde", "smallvec", - "smol", "theme", "uuid", ] diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 7c180368..1421c581 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1094,7 +1094,7 @@ impl ChatPanel { .rendered_texts_by_id .entry(message.id) .or_insert_with(|| { - text::rendered_text(&message.content, &message.mentions, &persons, true, cx) + text::rendered_text(&message.content, &message.mentions, &persons, cx) }) .element(ix.into(), window, cx); diff --git a/crates/chat_ui/src/text.rs b/crates/chat_ui/src/text.rs index d206482c..318115bd 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -4,14 +4,11 @@ use person::PersonRegistry; use ui::markdown::InlineReplacement; pub use ui::markdown::RenderedText; -/// Render message `content` to text, replacing mentions with their display names. -/// -/// When `markdown` is set the content is parsed as markdown. +/// Render message `content`, replacing mentions with their display names. pub fn rendered_text( content: &str, mentions: &[Mention], persons: &Entity, - markdown: bool, cx: &App, ) -> RenderedText { let replacements = mentions @@ -24,5 +21,5 @@ pub fn rendered_text( }) .collect::>(); - RenderedText::new(content, &replacements, markdown) + RenderedText::new(content, &replacements) } diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index ec88d341..36d0411c 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -838,9 +838,7 @@ impl GroupPanel { let content = self .rendered_texts_by_id .entry(message.id) - .or_insert_with(|| { - rendered_text(&message.content, &message.mentions, &persons, true, cx) - }) + .or_insert_with(|| rendered_text(&message.content, &message.mentions, &persons, cx)) .element(ix.into(), window, cx); let admin = nostr diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 9f23fefc..5e0c6c5f 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -13,15 +13,6 @@ gpui-base.workspace = true instant.workspace = true serde.workspace = true smallvec.workspace = true -anyhow.workspace = true -itertools.workspace = true 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/avatar.rs b/crates/ui/src/avatar.rs index 35910e15..f4bbceab 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -10,8 +10,7 @@ use crate::{Selectable, Sizable, Size, StyledExt}; /// Number of rows and columns in the generated pixel grid. const PIXEL_GRID: usize = 8; -/// Empty cells kept between the pattern and the avatar edge, so the art -/// gathers in the center instead of filling the whole avatar. +/// Empty cells kept between the pattern and the avatar edge. const MARGIN: usize = 1; /// Probability that a cell in the left half of the pattern area is filled. const FILL_PROBABILITY: f32 = 0.42; diff --git a/crates/ui/src/kbd.rs b/crates/ui/src/kbd.rs index 0b81f952..1acb8c02 100644 --- a/crates/ui/src/kbd.rs +++ b/crates/ui/src/kbd.rs @@ -66,8 +66,8 @@ impl Kbd { /// Return the Platform specific keybinding string by KeyStroke /// - /// macOS: https://support.apple.com/en-us/HT201236 - /// Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec + /// - macOS: https://support.apple.com/en-us/HT201236 + /// - Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec pub fn format(key: &Keystroke) -> String { #[cfg(target_os = "macos")] const DIVIDER: &str = ""; @@ -76,9 +76,6 @@ impl Kbd { let mut parts = vec![]; - // The key map order in macOS is: ⌃⌥⇧⌘ - // And in Windows is: Ctrl+Alt+Shift+Win - if key.modifiers.control { #[cfg(target_os = "macos")] parts.push("⌃"); @@ -113,6 +110,7 @@ impl Kbd { let mut keys = String::new(); let key_str = key.key.as_str(); + match key_str { #[cfg(target_os = "macos")] "ctrl" => keys.push('⌃'), diff --git a/crates/ui/src/markdown.rs b/crates/ui/src/markdown.rs index ef1dff1c..2d19b2af 100644 --- a/crates/ui/src/markdown.rs +++ b/crates/ui/src/markdown.rs @@ -1,22 +1,12 @@ use std::ops::Range; -use std::sync::{Arc, LazyLock}; use gpui::{ - AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText, - IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window, + AnyElement, App, ElementId, IntoElement, SharedString, StyleRefinement, Styled as _, 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()); +use gpui_base::{TextView, TextViewStyle}; +use theme::ActiveTheme as _; /// 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, @@ -32,460 +22,92 @@ impl InlineReplacement { } } -#[allow(clippy::enum_variant_names)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) 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`. +/// Message content prepared for rendering as a GPUI Kit text view. #[derive(Default)] pub struct RenderedText { - pub(crate) text: SharedString, - pub(crate) highlights: Vec<(Range, Highlight)>, - pub(crate) link_ranges: Vec>, - pub(crate) link_urls: Arc<[String]>, + text: SharedString, } 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(); + pub fn new(content: &str, replacements: &[InlineReplacement]) -> Self { + let mut text = String::with_capacity(content.len()); + let mut last = 0; - 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); - } + for replacement in replacements { + let range = &replacement.range; + if range.start < last + || range.end > content.len() + || range.start >= range.end + || !content.is_char_boundary(range.start) + || !content.is_char_boundary(range.end) + { + continue; } - text.truncate(trimmed_len); + text.push_str(&content[last..range.start]); + text.push_str(&format!( + "[{}](<{}>)", + escape_link_text(&replacement.text), + &content[range.start..range.end] + )); + last = range.end; } + text.push_str(&content[last..]); - RenderedText { + Self { 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" - }; + /// Render the message as a GPUI Kit text view. + pub fn element(&self, id: ElementId, _window: &Window, cx: &App) -> AnyElement { + let theme = cx.theme(); + let code_block = StyleRefinement::default().font_family(code_font_family()); - 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) { + TextView::markdown(id, self.text.clone()) + .style( + TextViewStyle::default() + .with_foreground(theme.text) + .with_muted_foreground(theme.text_muted) + .with_link(theme.text_accent) + .with_code_background(theme.elevated_surface_background) + .with_border(theme.border) + .with_code_block(code_block) + .with_dark(theme.is_dark()), + ) + .on_link_click(|url, _, _, cx| { + if is_web_url(url) { cx.open_url(url); } - } - }) - .into_any_element() + }) + .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}; +/// Escapes the characters that would terminate a markdown link label. +fn escape_link_text(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for character in text.chars() { + if matches!(character, '[' | ']' | '\\') { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} - 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; +/// Matches `http://` and `https://` URLs. Only these are treated as clickable links. +fn is_web_url(url: &str) -> bool { + let url = url.to_ascii_lowercase(); + url.starts_with("http://") || url.starts_with("https://") +} - // 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()) +fn code_font_family() -> &'static str { + if cfg!(target_os = "macos") { + "Menlo" + } else if cfg!(target_os = "windows") { + "Consolas" } 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(" "); + "monospace" } } diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 8de04264..596ed318 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -8,6 +8,7 @@ use gpui::{ ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle, Window, canvas, div, point, px, size, }; +use gpui_base::TextSelectionLayer; use theme::{ ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING, CLIENT_SIDE_DECORATION_SHADOW, @@ -382,6 +383,7 @@ impl Render for Root { .font_family(font_family) .bg(cx.theme().surface_background) .text_color(cx.theme().text) + .child(TextSelectionLayer) .child(self.view.clone()), ) }