diff --git a/Cargo.lock b/Cargo.lock index c8498b7d3a..0d6b3bde11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2718,6 +2718,15 @@ dependencies = [ "regex", ] +[[package]] +name = "example-markdown-editor" +version = "0.6.1" +dependencies = [ + "gpui-kit", + "gpui-pre-reqwest-client", + "regex", +] + [[package]] name = "example-stream-markdown" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 435bf8a92a..3a931ce295 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "examples/html", "examples/large-text", "examples/markdown", + "examples/markdown-editor", "examples/stream-markdown", "examples/window_title", "examples/dialog_overlay", diff --git a/crates/base/src/text/editor/input.rs b/crates/base/src/text/editor/input.rs new file mode 100644 index 0000000000..f0316db4d2 --- /dev/null +++ b/crates/base/src/text/editor/input.rs @@ -0,0 +1,201 @@ +use super::{MarkdownEditorState, model::Position}; +use gpui::*; +use std::ops::Range; + +// Native text APIs count UTF-16 units; the document counts UTF-8 bytes. +pub(super) fn byte_offset(text: &str, utf16: usize) -> usize { + let mut units = 0; + for (ix, ch) in text.char_indices() { + if units + ch.len_utf16() > utf16 { + return ix; + } + units += ch.len_utf16(); + } + text.len() +} + +pub(super) fn utf16_offset(text: &str, byte: usize) -> usize { + text[..byte.min(text.len())].encode_utf16().count() +} + +impl MarkdownEditorState { + pub(super) fn finish_composition(&mut self, cx: &mut Context) { + self.composition = None; + if let Some(before) = self.composition_before.take() { + if before.document.blocks != self.document.blocks { + self.record(before); + self.changed(cx); + } + } + } + + fn native_range(&self, range: Range) -> (Position, Position) { + let text = self.document.text(self.cursor.block); + ( + Position { + offset: byte_offset(&text, range.start), + ..self.cursor + }, + Position { + offset: byte_offset(&text, range.end), + ..self.cursor + }, + ) + } +} + +impl EntityInputHandler for MarkdownEditorState { + fn text_for_range( + &mut self, + range: Range, + adjusted: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + let text = self.document.text(self.cursor.block); + let (a, b) = self.native_range(range); + *adjusted = Some(utf16_offset(&text, a.offset)..utf16_offset(&text, b.offset)); + Some(text[a.offset..b.offset].to_string()) + } + + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option { + let text = self.document.text(self.cursor.block); + let (a, b) = if self.anchor.block == self.cursor.block { + self.document.ordered(self.anchor, self.cursor) + } else { + (self.cursor, self.cursor) + }; + Some(UTF16Selection { + range: utf16_offset(&text, a.offset)..utf16_offset(&text, b.offset), + reversed: self.cursor == a && a != b, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + let (a, b) = self.composition?; + let text = self.document.text(a.block); + Some(utf16_offset(&text, a.offset)..utf16_offset(&text, b.offset)) + } + + fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { + self.finish_composition(cx); + cx.notify(); + } + + fn replace_text_in_range( + &mut self, + range: Option>, + text: &str, + _: &mut Window, + cx: &mut Context, + ) { + if self.readonly { + return; + } + if let Some((a, b)) = range.map(|r| self.native_range(r)).or(self.composition) { + self.anchor = a; + self.cursor = b; + } + if self.composition_before.is_some() { + self.cursor = + self.document + .replace(self.anchor, self.cursor, text, self.stored.as_ref()); + self.anchor = self.cursor; + self.finish_composition(cx); + } else { + self.insert(text, cx); + } + } + + fn replace_and_mark_text_in_range( + &mut self, + range: Option>, + text: &str, + selected: Option>, + _: &mut Window, + cx: &mut Context, + ) { + if self.readonly { + return; + } + if self.composition_before.is_none() { + self.composition_before = Some(self.snapshot()); + } + let (a, b) = range + .map(|r| self.native_range(r)) + .or(self.composition) + .unwrap_or((self.anchor, self.cursor)); + let (start, _) = self.document.ordered(a, b); + let end = self.document.replace(a, b, text, self.stored.as_ref()); + self.composition = Some((start, end)); + self.cursor = Position { + offset: start.offset + + selected + .as_ref() + .map(|r| byte_offset(text, r.end)) + .unwrap_or(text.len()), + ..start + }; + self.anchor = Position { + offset: start.offset + + selected + .map(|r| byte_offset(text, r.start)) + .unwrap_or(text.len()), + ..start + }; + self.layouts.clear(); + self.pause_cursor(cx); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + range: Range, + _: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + let (a, b) = self.native_range(range); + let layout = self.layout_for(a)?; + let start = layout + .text + .position_for_index(a.offset - layout.source.start)?; + let end = layout + .text + .position_for_index( + b.offset + .saturating_sub(layout.source.start) + .min(layout.source.len()), + ) + .unwrap_or(start); + Some(Bounds::new( + start, + size( + if start.y == end.y { + (end.x - start.x).max(px(1.)) + } else { + px(1.) + }, + layout.text.line_height(), + ), + )) + } + + fn character_index_for_point( + &mut self, + point: Point, + _: &mut Window, + _: &mut Context, + ) -> Option { + let hit = self.hit(point)?; + if hit.block != self.cursor.block { + return None; + } + Some(utf16_offset(&self.document.text(hit.block), hit.offset)) + } +} diff --git a/crates/base/src/text/editor/mod.rs b/crates/base/src/text/editor/mod.rs new file mode 100644 index 0000000000..caeb7d7470 --- /dev/null +++ b/crates/base/src/text/editor/mod.rs @@ -0,0 +1,814 @@ +mod input; +mod model; +#[cfg(test)] +mod tests; + +use crate::text::{ + MarkdownExtensions, TextViewDefaults, TextViewStyle, + document::NodeRenderOptions, + inline::{Inline, InlineInteraction}, + node::{NodeContext, TextMark}, +}; +use crate::{input::blink_cursor::BlinkCursor, undo_history::UndoHistory}; +use gpui::*; +use model::{Document, Position}; +use std::{ + collections::{HashMap, HashSet}, + ops::Range, + sync::Arc, +}; + +actions!( + markdown_editor, + [ + Indent, + Outdent, + Backspace, + Delete, + Enter, + Left, + Right, + Up, + Down, + SelectLeft, + SelectRight, + SelectUp, + SelectDown, + Home, + End, + SelectAll, + Undo, + Redo, + Copy, + Cut, + Paste, + Bold, + Italic + ] +); + +// Register one shared action context for every document editor. +pub(super) fn init(cx: &mut App) { + let modifier = if cfg!(target_os = "macos") { + "cmd" + } else { + "ctrl" + }; + cx.bind_keys([ + KeyBinding::new("tab", Indent, Some("MarkdownEditor")), + KeyBinding::new("shift-tab", Outdent, Some("MarkdownEditor")), + KeyBinding::new("backspace", Backspace, Some("MarkdownEditor")), + KeyBinding::new("delete", Delete, Some("MarkdownEditor")), + KeyBinding::new("enter", Enter, Some("MarkdownEditor")), + KeyBinding::new("left", Left, Some("MarkdownEditor")), + KeyBinding::new("right", Right, Some("MarkdownEditor")), + KeyBinding::new("up", Up, Some("MarkdownEditor")), + KeyBinding::new("down", Down, Some("MarkdownEditor")), + KeyBinding::new("shift-left", SelectLeft, Some("MarkdownEditor")), + KeyBinding::new("shift-right", SelectRight, Some("MarkdownEditor")), + KeyBinding::new("shift-up", SelectUp, Some("MarkdownEditor")), + KeyBinding::new("shift-down", SelectDown, Some("MarkdownEditor")), + KeyBinding::new("home", Home, Some("MarkdownEditor")), + KeyBinding::new("end", End, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-a"), SelectAll, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-z"), Undo, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-shift-z"), Redo, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-y"), Redo, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-c"), Copy, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-x"), Cut, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-v"), Paste, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-b"), Bold, Some("MarkdownEditor")), + KeyBinding::new(&format!("{modifier}-i"), Italic, Some("MarkdownEditor")), + ]); +} + +// Notifications describe committed document changes. +#[derive(Clone, Debug)] +pub enum MarkdownEditorEvent { + Change, +} + +#[derive(Clone)] +struct Snapshot { + document: Document, + anchor: Position, + cursor: Position, +} + +#[derive(Clone)] +struct Edit { + before: Snapshot, + after: Snapshot, +} + +struct Layout { + source: Range, + text: TextLayout, + bounds: Bounds, +} + +// One entity owns all editing state across the document. +pub struct MarkdownEditorState { + document: Document, + anchor: Position, + cursor: Position, + focus: FocusHandle, + layouts: HashMap<(u64, usize), Layout>, + history: UndoHistory, + composition: Option<(Position, Position)>, + composition_before: Option, + stored: Option, + // Retain the initial selection while dragging after a multi-click. + dragging: Option<(Position, Position)>, + readonly: bool, + active: bool, + blink: Entity, + _subscriptions: Vec, + scroll: ScrollHandle, +} + +impl EventEmitter for MarkdownEditorState {} + +impl Focusable for MarkdownEditorState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl MarkdownEditorState { + // Construct a document editor from Markdown. + pub fn new(source: &str, cx: &mut Context) -> Result { + Self::new_with_extensions(source, MarkdownExtensions::default(), cx) + } + + // Use the same parser and renderer plugins as a Markdown reading view. + pub fn new_with_extensions( + source: &str, + extensions: MarkdownExtensions, + cx: &mut Context, + ) -> Result { + let document = Document::parse_with_extensions(source, extensions)?; + let cursor = document.first(); + let blink = cx.new(|_| BlinkCursor::new()); + let subscriptions = vec![cx.observe(&blink, |state: &mut Self, _, cx| { + if state.active { + cx.notify(); + } + })]; + Ok(Self { + document, + anchor: cursor, + cursor, + focus: cx.focus_handle(), + layouts: HashMap::new(), + history: UndoHistory::new().max_undos(200), + composition: None, + composition_before: None, + stored: None, + dragging: None, + readonly: false, + active: false, + blink, + _subscriptions: subscriptions, + scroll: ScrollHandle::new(), + }) + } + + // Export the structured document as Markdown. + pub fn source(&self) -> String { + self.document.source() + } + + // Replace the document without emitting a user edit event. + pub fn set_source(&mut self, source: &str, cx: &mut Context) -> Result<(), SharedString> { + let document = Document::parse_with_extensions( + source, + self.document.context.markdown_extensions.as_ref().clone(), + )?; + self.cursor = document.first(); + self.anchor = self.cursor; + self.document = document; + self.history.clear(); + self.layouts.clear(); + self.composition = None; + self.composition_before = None; + cx.notify(); + Ok(()) + } + + pub fn set_readonly(&mut self, readonly: bool, cx: &mut Context) { + if self.readonly == readonly { + return; + } + // Settle the current composition before disabling user edits. + self.finish_composition(cx); + self.readonly = readonly; + self.blink.update(cx, |blink, cx| { + if readonly { + blink.stop(cx); + } else { + blink.pause(cx); + } + }); + if readonly { + self.active = false; + } + cx.notify(); + } + pub fn is_readonly(&self) -> bool { + self.readonly + } + pub fn focus(&self, window: &mut Window, cx: &mut App) { + self.focus.focus(window, cx); + if !self.readonly { + self.pause_cursor(cx); + } + } + + fn pause_cursor(&self, cx: &mut App) { + if !self.readonly { + self.blink.update(cx, |blink, cx| blink.pause(cx)); + } + } + + fn snapshot(&self) -> Snapshot { + Snapshot { + document: self.document.clone(), + anchor: self.anchor, + cursor: self.cursor, + } + } + + fn restore(&mut self, snapshot: Snapshot) { + self.document = snapshot.document; + self.anchor = snapshot.anchor; + self.cursor = snapshot.cursor; + self.composition = None; + self.composition_before = None; + self.layouts.clear(); + self.stored = None; + } + + fn changed(&mut self, cx: &mut Context) { + self.layouts.clear(); + self.pause_cursor(cx); + cx.emit(MarkdownEditorEvent::Change); + cx.notify(); + } + + fn edit(&mut self, cx: &mut Context, operation: impl FnOnce(&mut Self)) { + if self.readonly { + return; + } + self.finish_composition(cx); + let before = self.snapshot(); + operation(self); + if before.document.blocks != self.document.blocks { + self.record(before); + self.changed(cx); + } else { + cx.notify(); + } + } + + fn place(&mut self, position: Position, extend: bool, cx: &mut Context) { + self.cursor = position; + if !extend { + self.anchor = position; + } + self.stored = None; + self.pause_cursor(cx); + cx.notify(); + } + + fn insert(&mut self, text: &str, cx: &mut Context) { + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + self.edit(cx, |this| { + let mut lines = text.split('\n'); + this.cursor = this.document.replace( + this.anchor, + this.cursor, + lines.next().unwrap_or_default(), + this.stored.as_ref(), + ); + for line in lines { + this.cursor = this.document.split(this.cursor); + this.cursor = + this.document + .replace(this.cursor, this.cursor, line, this.stored.as_ref()); + } + this.anchor = this.cursor; + // Markdown prefixes become structural formatting after a committed space. + if text == " " { + let prefix = this.document.text(this.cursor.block); + let task = match prefix.as_str() { + "-[] " | "-[ ] " | "- [] " | "- [ ] " => Some((false, false)), + "-[x] " | "-[X] " | "- [x] " | "- [X] " => Some((true, false)), + "[] " | "[ ] " => Some((false, true)), + "[x] " | "[X] " => Some((true, true)), + _ => None, + }; + if let Some((checked, require_list)) = task { + if this + .document + .make_task(this.cursor.block, checked, require_list) + { + let start = Position { + offset: 0, + ..this.cursor + }; + this.cursor = this.document.replace(start, this.cursor, "", None); + this.anchor = this.cursor; + } + return; + } + let heading = match prefix.as_str() { + "# " => Some(1), + "## " => Some(2), + "### " => Some(3), + _ => None, + }; + let list = match prefix.as_str() { + "- " | "* " => Some(false), + "1. " => Some(true), + _ => None, + }; + if heading.is_some() || list.is_some() { + let start = Position { + offset: 0, + ..this.cursor + }; + this.cursor = this.document.replace(start, this.cursor, "", None); + this.anchor = this.cursor; + if let Some(ordered) = list { + this.document.make_list(this.cursor.block, ordered); + } + if let Some(level) = heading { + this.document.heading(this.cursor.block, level); + } + } + } + }); + } + + fn delete(&mut self, forward: bool, cx: &mut Context) { + self.edit(cx, |this| { + if this.anchor == this.cursor { + if !forward + && this.cursor.offset == 0 + && this.document.text(this.cursor.block).is_empty() + && this.document.outdent_list(this.cursor.block) + { + return; + } + this.anchor = this.document.adjacent(this.cursor, forward); + } + this.cursor = this.document.replace(this.anchor, this.cursor, "", None); + this.anchor = this.cursor; + }); + } + + fn enter(&mut self, cx: &mut Context) { + self.edit(cx, |this| { + this.cursor = this.document.replace(this.anchor, this.cursor, "", None); + if this.document.text(this.cursor.block).is_empty() + && this.document.outdent_list(this.cursor.block) + { + this.anchor = this.cursor; + return; + } + this.cursor = this.document.split(this.cursor); + this.anchor = this.cursor; + }); + } + + pub fn indent_list(&mut self, cx: &mut Context) { + self.edit(cx, |this| { + this.document.indent_list(this.cursor.block); + }); + } + + pub fn outdent_list(&mut self, cx: &mut Context) { + self.edit(cx, |this| { + this.document.outdent_list(this.cursor.block); + }); + } + + pub fn toggle_bold(&mut self, cx: &mut Context) { + self.toggle_format(true, cx); + } + pub fn toggle_italic(&mut self, cx: &mut Context) { + self.toggle_format(false, cx); + } + fn toggle_format(&mut self, bold: bool, cx: &mut Context) { + if self.readonly { + return; + } + if self.anchor == self.cursor { + let mark = self + .stored + .get_or_insert_with(|| self.document.mark_at(self.cursor)); + if bold { + mark.bold = !mark.bold; + } else { + mark.italic = !mark.italic; + } + cx.notify(); + } else { + self.edit(cx, |this| { + this.document.format(this.anchor, this.cursor, bold) + }); + } + } + pub fn set_heading(&mut self, level: u8, cx: &mut Context) { + self.edit(cx, |this| this.document.heading(this.cursor.block, level)); + } + pub fn toggle_list(&mut self, cx: &mut Context) { + self.edit(cx, |this| this.document.list(this.cursor.block)); + } + pub fn move_block_up(&mut self, cx: &mut Context) { + self.edit(cx, |this| { + this.document.move_block(this.cursor.block, false) + }); + } + pub fn move_block_down(&mut self, cx: &mut Context) { + self.edit(cx, |this| this.document.move_block(this.cursor.block, true)); + } + fn record(&mut self, before: Snapshot) { + self.history.push(Edit { + before, + after: self.snapshot(), + }); + } + + pub fn undo(&mut self, cx: &mut Context) { + if self.readonly { + return; + } + self.finish_composition(cx); + if let Some(edits) = self.history.undo() { + for edit in edits { + self.restore(edit.before); + } + self.changed(cx); + } + } + + pub fn redo(&mut self, cx: &mut Context) { + if self.readonly { + return; + } + self.finish_composition(cx); + if let Some(edits) = self.history.redo() { + for edit in edits { + self.restore(edit.after); + } + self.changed(cx); + } + } + + fn copy(&self, cx: &mut Context) { + cx.write_to_clipboard(ClipboardItem::new_string( + self.document.selected(self.anchor, self.cursor), + )); + } + fn paste(&mut self, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + self.insert(&text, cx); + } + } + + fn horizontal(&mut self, forward: bool, extend: bool, cx: &mut Context) { + let (a, b) = self.document.ordered(self.anchor, self.cursor); + let next = if !extend && a != b { + if forward { b } else { a } + } else { + self.document.adjacent(self.cursor, forward) + }; + self.place(next, extend, cx); + } + + fn layout_for(&self, at: Position) -> Option<&Layout> { + self.layouts + .iter() + .filter(|((id, _), layout)| { + *id == at.block + && layout.source.start <= at.offset + && at.offset <= layout.source.end + }) + .max_by_key(|(_, layout)| layout.source.start) + .map(|(_, layout)| layout) + } + + fn hit(&self, point: Point) -> Option { + let ((id, _), layout) = self.layouts.iter().min_by(|(_, a), (_, b)| { + let distance = |r: &Bounds| { + let dy = (r.top() - point.y).max(point.y - r.bottom()).max(px(0.)); + let dx = (r.left() - point.x).max(point.x - r.right()).max(px(0.)); + (dy, dx) + }; + distance(&a.bounds) + .partial_cmp(&distance(&b.bounds)) + .unwrap() + })?; + let local = layout + .text + .index_for_position(point) + .unwrap_or_else(|ix| ix) + .min(layout.source.len()); + Some(Position { + block: *id, + offset: layout.source.start + local, + }) + } + + fn vertical(&mut self, down: bool, extend: bool, cx: &mut Context) { + if let Some(layout) = self.layout_for(self.cursor) { + if let Some(pos) = layout + .text + .position_for_index(self.cursor.offset - layout.source.start) + { + let point = point( + pos.x, + pos.y + layout.text.line_height() * if down { 1.5 } else { -0.5 }, + ); + if let Some(next) = self.hit(point) { + self.place(next, extend, cx); + } + } + } + } +} + +// The existing Inline element draws document selection and reports its layout. +pub(in crate::text) struct InlineEditing { + owner: WeakEntity, + id: u64, +} + +impl InlineInteraction for InlineEditing { + fn paint( + &self, + layout: &TextLayout, + bounds: Bounds, + source: Range, + window: &mut Window, + cx: &mut App, + ) { + let _ = self.owner.update(cx, |state, cx| { + let active = !state.readonly && state.focus.is_focused(window); + if active != state.active { + state.active = active; + state.blink.update(cx, |blink, cx| { + if active { + blink.pause(cx); + } else { + blink.stop(cx); + } + }); + } + let color = crate::Theme::global(cx).tokens.colors.selection; + if let Some(range) = state + .document + .selection_in(self.id, state.anchor, state.cursor) + { + let start = range.start.max(source.start); + let end = range.end.min(source.end); + if start < end { + Inline::paint_selection( + &((start - source.start)..(end - source.start)).into(), + layout, + &bounds, + window, + color, + ); + } + } + let at = state.cursor.offset; + let owns_caret = source.contains(&at) + || (at == source.end && at == state.document.text(self.id).len()); + if active + && state.cursor.block == self.id + && owns_caret + && state.blink.read(cx).visible() + { + if let Some(origin) = layout.position_for_index(at - source.start) { + window.paint_quad(fill( + Bounds::new(origin, size(px(1.), layout.line_height())), + crate::Theme::global(cx).tokens.colors.foreground, + )); + } + } + if let Some((start, end)) = state + .composition + .filter(|(start, _)| start.block == self.id) + { + let start = start.offset.max(source.start); + let end = end.offset.min(source.end); + if start < end { + if let (Some(a), Some(b)) = ( + layout.position_for_index(start - source.start), + layout.position_for_index(end - source.start), + ) { + window.paint_quad(fill( + Bounds::new( + point(a.x, a.y + layout.line_height() - px(1.)), + size((b.x - a.x).max(px(1.)), px(1.)), + ), + crate::Theme::global(cx).tokens.colors.foreground, + )); + } + } + } + state.layouts.insert( + (self.id, source.start), + Layout { + source, + text: layout.clone(), + bounds, + }, + ); + }); + } +} + +impl Render for MarkdownEditorState { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.layouts.clear(); + let owner = cx.entity().downgrade(); + let editable: HashSet<_> = self + .document + .paragraphs() + .iter() + .map(|p| p.editor_id()) + .collect(); + let defaults = TextViewDefaults::global(cx); + let node_context = NodeContext { + style: defaults + .style + .unwrap_or_else(|| TextViewStyle::from_theme(&crate::Theme::global(cx))) + .with_heading_base_font_size(window.rem_size()), + code_block_highlighter: defaults.code_block_highlighter, + markdown_extensions: self.document.context.markdown_extensions.clone(), + link_refs: self.document.context.link_refs.clone(), + paragraph_interaction: Some(Arc::new(move |p| { + if !editable.contains(&p.editor_id()) { + return None; + } + Some(Arc::new(InlineEditing { + owner: owner.clone(), + id: p.editor_id(), + }) as Arc) + })), + ..NodeContext::default() + }; + let blocks = self + .document + .blocks + .iter() + .enumerate() + .map(|(ix, node)| { + node.render_block( + NodeRenderOptions { + ix, + ..Default::default() + }, + &node_context, + window, + cx, + ) + }) + .collect::>(); + let input = cx.entity(); + div() + .id("markdown-editor") + .key_context("MarkdownEditor") + .track_focus(&self.focus) + .role(Role::MultilineTextInput) + .aria_label("Markdown editor") + .aria_value( + self.document + .selected(self.document.first(), self.document.last()), + ) + .size_full() + .min_h_0() + .relative() + .on_action(cx.listener(|this, _: &Indent, _, cx| this.indent_list(cx))) + .on_action(cx.listener(|this, _: &Outdent, _, cx| this.outdent_list(cx))) + .on_action(cx.listener(|this, _: &Backspace, _, cx| this.delete(false, cx))) + .on_action(cx.listener(|this, _: &Delete, _, cx| this.delete(true, cx))) + .on_action(cx.listener(|this, _: &Enter, _, cx| this.enter(cx))) + .on_action(cx.listener(|this, _: &Left, _, cx| this.horizontal(false, false, cx))) + .on_action(cx.listener(|this, _: &Right, _, cx| this.horizontal(true, false, cx))) + .on_action(cx.listener(|this, _: &SelectLeft, _, cx| this.horizontal(false, true, cx))) + .on_action(cx.listener(|this, _: &SelectRight, _, cx| this.horizontal(true, true, cx))) + .on_action(cx.listener(|this, _: &Up, _, cx| this.vertical(false, false, cx))) + .on_action(cx.listener(|this, _: &Down, _, cx| this.vertical(true, false, cx))) + .on_action(cx.listener(|this, _: &SelectUp, _, cx| this.vertical(false, true, cx))) + .on_action(cx.listener(|this, _: &SelectDown, _, cx| this.vertical(true, true, cx))) + .on_action(cx.listener(|this, _: &Home, _, cx| { + this.place( + Position { + offset: 0, + ..this.cursor + }, + false, + cx, + ) + })) + .on_action(cx.listener(|this, _: &End, _, cx| { + this.place( + Position { + offset: this.document.text(this.cursor.block).len(), + ..this.cursor + }, + false, + cx, + ) + })) + .on_action(cx.listener(|this, _: &SelectAll, _, cx| { + this.anchor = this.document.first(); + this.cursor = this.document.last(); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &Undo, _, cx| this.undo(cx))) + .on_action(cx.listener(|this, _: &Redo, _, cx| this.redo(cx))) + .on_action(cx.listener(|this, _: &Copy, _, cx| this.copy(cx))) + .on_action(cx.listener(|this, _: &Cut, _, cx| { + this.copy(cx); + this.insert("", cx); + })) + .on_action(cx.listener(|this, _: &Paste, _, cx| this.paste(cx))) + .on_action(cx.listener(|this, _: &Bold, _, cx| this.toggle_bold(cx))) + .on_action(cx.listener(|this, _: &Italic, _, cx| this.toggle_italic(cx))) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + crate::global_state::GlobalState::suppress_text_selection(cx); + this.finish_composition(cx); + this.focus.focus(window, cx); + if let Some(position) = this.hit(event.position) { + this.place(position, event.modifiers.shift, cx); + this.dragging = Some((this.anchor, this.anchor)); + if event.click_count == 2 { + let text = this.document.text(position.block); + if let Some(range) = + crate::text::selection::word_range_at(&text, position.offset) + { + this.anchor = Position { + offset: range.start, + ..position + }; + this.cursor = Position { + offset: range.end, + ..position + }; + this.dragging = Some((this.anchor, this.cursor)); + } + } + } + }), + ) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _, cx| { + if let Some((start, end)) = this.dragging + && event.pressed_button == Some(MouseButton::Left) + { + if let Some(position) = this.hit(event.position) { + if this.document.ordered(position, start).0 == position { + this.anchor = end; + this.place(position, true, cx); + } else if this.document.ordered(end, position).0 == end { + this.anchor = start; + this.place(position, true, cx); + } else { + this.anchor = start; + this.place(end, true, cx); + } + } + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, _| this.dragging = None), + ) + .child( + div() + .id("document-scroll") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll) + .children(blocks), + ) + .child( + canvas( + move |_, _, _| (), + move |bounds, _, window, cx| { + let focus = input.read(cx).focus.clone(); + window.handle_input(&focus, ElementInputHandler::new(bounds, input), cx); + }, + ) + .absolute() + .size_full(), + ) + } +} diff --git a/crates/base/src/text/editor/model.rs b/crates/base/src/text/editor/model.rs new file mode 100644 index 0000000000..8150854bcb --- /dev/null +++ b/crates/base/src/text/editor/model.rs @@ -0,0 +1,760 @@ +use crate::text::{ + MarkdownExtensions, + document::ParsedDocument, + format::markdown, + node::{BlockNode, InlineNode, NodeContext, Paragraph, TextMark}, +}; +use gpui::SharedString; +use std::{collections::HashSet, ops::Range, sync::Arc}; +use unicode_segmentation::UnicodeSegmentation; + +// Positions use stable paragraph identities and UTF-8 boundaries. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] +pub(super) struct Position { + pub block: u64, + pub offset: usize, +} + +#[derive(Clone, Default)] +pub(super) struct Document { + pub blocks: Vec, + pub context: NodeContext, +} + +// Paragraph render state is retained across edits and history snapshots. +// Its allocation identifies a paragraph only within this in-memory document. +impl Paragraph { + pub(super) fn editor_id(&self) -> u64 { + Arc::as_ptr(&self.state) as usize as u64 + } +} + +fn children(node: &BlockNode) -> Option<&Vec> { + match node { + BlockNode::Root { children, .. } + | BlockNode::List { children, .. } + | BlockNode::ListItem { children, .. } + | BlockNode::Blockquote { children, .. } => Some(children), + _ => None, + } +} + +fn children_mut(node: &mut BlockNode) -> Option<&mut Vec> { + match node { + BlockNode::Root { children, .. } + | BlockNode::List { children, .. } + | BlockNode::ListItem { children, .. } + | BlockNode::Blockquote { children, .. } => Some(children), + _ => None, + } +} + +fn paragraph(node: &BlockNode) -> Option<&Paragraph> { + match node { + BlockNode::Paragraph(p) | BlockNode::Heading { children: p, .. } + if p.children + .iter() + .all(|n| n.image.is_none() && n.custom.is_none()) => + { + Some(p) + } + _ => None, + } +} + +fn paragraph_mut(node: &mut BlockNode) -> Option<&mut Paragraph> { + match node { + BlockNode::Paragraph(p) | BlockNode::Heading { children: p, .. } + if p.children + .iter() + .all(|n| n.image.is_none() && n.custom.is_none()) => + { + Some(p) + } + _ => None, + } +} + +fn visit<'a>(nodes: &'a [BlockNode], result: &mut Vec<&'a Paragraph>) { + for node in nodes { + if let Some(p) = paragraph(node) { + result.push(p); + } + if let Some(nodes) = children(node) { + visit(nodes, result); + } + } +} + +fn find_mut(nodes: &mut [BlockNode], id: u64) -> Option<&mut Paragraph> { + for node in nodes { + if paragraph(node).is_some_and(|p| p.editor_id() == id) { + return paragraph_mut(node); + } + if let Some(nodes) = children_mut(node) { + if let Some(p) = find_mut(nodes, id) { + return Some(p); + } + } + } + None +} + +// Slice content and format ranges without retaining mutable render caches. +fn slice(p: &Paragraph, range: Range) -> Vec { + let mut result = Vec::new(); + let mut offset = 0; + for node in &p.children { + let start = range.start.saturating_sub(offset).min(node.text.len()); + let end = range.end.saturating_sub(offset).min(node.text.len()); + if start < end { + let marks = node + .marks + .iter() + .filter_map(|(r, mark)| { + let a = r.start.max(start); + let b = r.end.min(end); + (a < b).then(|| ((a - start)..(b - start), mark.clone())) + }) + .collect(); + result.push(InlineNode::new(node.text[start..end].to_string()).marks(marks)); + } + offset += node.text.len(); + } + result +} + +impl Document { + #[cfg(test)] + pub fn parse(source: &str) -> Result { + Self::parse_with_extensions(source, MarkdownExtensions::default()) + } + + pub fn parse_with_extensions( + source: &str, + extensions: MarkdownExtensions, + ) -> Result { + let mut context = NodeContext { + markdown_extensions: Arc::new(extensions), + ..Default::default() + }; + let parsed = markdown::parse(source, &mut context)?; + let mut doc = Self { + blocks: parsed.blocks.as_ref().clone(), + context, + }; + fn fill_empty_items(nodes: &mut [BlockNode]) { + for node in nodes { + if let BlockNode::ListItem { children, .. } = node { + if children.is_empty() { + children.push(BlockNode::Paragraph(Paragraph::default())); + } + } + if let Some(nested) = children_mut(node) { + fill_empty_items(nested); + } + } + } + fill_empty_items(&mut doc.blocks); + doc.ensure_paragraph(); + Ok(doc) + } + + pub fn source(&self) -> String { + ParsedDocument { + source: "".into(), + blocks: Arc::new(self.blocks.clone()), + } + .to_markdown() + } + + pub fn paragraphs(&self) -> Vec<&Paragraph> { + let mut result = Vec::new(); + visit(&self.blocks, &mut result); + result + } + + pub fn text(&self, id: u64) -> String { + self.paragraphs() + .iter() + .find(|p| p.editor_id() == id) + .map(|p| p.text()) + .unwrap_or_default() + } + + pub fn first(&self) -> Position { + Position { + block: self.paragraphs()[0].editor_id(), + offset: 0, + } + } + + pub fn last(&self) -> Position { + let p = self.paragraphs().last().copied().unwrap(); + Position { + block: p.editor_id(), + offset: p.text_len(), + } + } + + pub fn ordered(&self, a: Position, b: Position) -> (Position, Position) { + let ids: Vec<_> = self.paragraphs().iter().map(|p| p.editor_id()).collect(); + let key = |p: Position| { + ( + ids.iter().position(|id| *id == p.block).unwrap_or(0), + p.offset, + ) + }; + if key(a) <= key(b) { (a, b) } else { (b, a) } + } + + pub fn selected(&self, a: Position, b: Position) -> String { + let (a, b) = self.ordered(a, b); + self.paragraphs() + .into_iter() + .skip_while(|p| p.editor_id() != a.block) + .scan(false, |done, p| { + if *done { + return None; + } + *done = p.editor_id() == b.block; + let text = p.text(); + let start = if p.editor_id() == a.block { + a.offset + } else { + 0 + }; + let end = if p.editor_id() == b.block { + b.offset + } else { + text.len() + }; + Some(text[start..end].to_string()) + }) + .collect::>() + .join("\n") + } + + pub fn selection_in(&self, id: u64, a: Position, b: Position) -> Option> { + let (a, b) = self.ordered(a, b); + let ps = self.paragraphs(); + let ix = |id| ps.iter().position(|p| p.editor_id() == id); + let current = ix(id)?; + if current < ix(a.block)? || current > ix(b.block)? { + return None; + } + Some( + (if id == a.block { a.offset } else { 0 })..(if id == b.block { + b.offset + } else { + ps[current].text_len() + }), + ) + } + + fn new_paragraph(&mut self, nodes: Vec) -> Paragraph { + let mut p = Paragraph::default(); + p.children = nodes; + p + } + + fn ensure_paragraph(&mut self) { + if self.paragraphs().is_empty() { + let p = self.new_paragraph(vec![]); + self.blocks.push(BlockNode::Paragraph(p)); + } + } + + // Typing inherits formatting unless a toolbar command overrides it. + pub fn mark_at(&self, at: Position) -> TextMark { + let mut mark = TextMark::default(); + if at.offset > 0 { + if let Some(p) = self + .paragraphs() + .into_iter() + .find(|p| p.editor_id() == at.block) + { + for node in slice(p, previous_boundary(&p.text(), at.offset)..at.offset) { + for (_, inherited) in node.marks { + mark.merge(inherited); + } + } + } + } + mark + } + + pub fn replace( + &mut self, + a: Position, + b: Position, + text: &str, + stored: Option<&TextMark>, + ) -> Position { + let (a, b) = self.ordered(a, b); + let ps = self.paragraphs(); + let first = ps.iter().find(|p| p.editor_id() == a.block).unwrap(); + let last = ps.iter().find(|p| p.editor_id() == b.block).unwrap(); + let mut nodes = slice(first, 0..a.offset); + let mark = stored.cloned().unwrap_or_else(|| self.mark_at(a)); + if !text.is_empty() { + nodes.push(InlineNode::new(text.to_string()).marks(vec![(0..text.len(), mark)])); + } + nodes.extend(slice(last, b.offset..last.text_len())); + let remove: HashSet<_> = ps + .iter() + .skip_while(|p| p.editor_id() != a.block) + .skip(1) + .take_while(|p| p.editor_id() != b.block) + .map(|p| p.editor_id()) + .chain((a.block != b.block).then_some(b.block)) + .collect(); + find_mut(&mut self.blocks, a.block).unwrap().children = nodes; + fn prune(nodes: &mut Vec, remove: &HashSet) { + nodes.retain_mut(|node| { + if paragraph(node).is_some_and(|p| remove.contains(&p.editor_id())) { + return false; + } + if let Some(nodes) = children_mut(node) { + prune(nodes, remove); + return !nodes.is_empty(); + } + true + }); + } + if a.block != b.block { + prune(&mut self.blocks, &remove); + } + Position { + block: a.block, + offset: a.offset + text.len(), + } + } + + pub fn split(&mut self, at: Position) -> Position { + let p = self + .paragraphs() + .into_iter() + .find(|p| p.editor_id() == at.block) + .unwrap(); + let tail = slice(p, at.offset..p.text_len()); + let head = slice(p, 0..at.offset); + let new_p = self.new_paragraph(tail); + let result = Position { + block: new_p.editor_id(), + offset: 0, + }; + find_mut(&mut self.blocks, at.block).unwrap().children = head; + fn insert(nodes: &mut Vec, id: u64, new_p: &Paragraph) -> bool { + for ix in 0..nodes.len() { + if paragraph(&nodes[ix]).is_some_and(|p| p.editor_id() == id) { + nodes.insert(ix + 1, BlockNode::Paragraph(new_p.clone())); + return true; + } + if let BlockNode::List { + children: items, .. + } = &mut nodes[ix] + { + for item_ix in 0..items.len() { + if let BlockNode::ListItem { + children, checked, .. + } = &items[item_ix] + { + if children + .first() + .and_then(paragraph) + .is_some_and(|p| p.editor_id() == id) + { + let mut tail = vec![BlockNode::Paragraph(new_p.clone())]; + let checked = checked.map(|_| false); + tail.extend(children[1..].iter().cloned()); + children_mut(&mut items[item_ix]).unwrap().truncate(1); + items.insert( + item_ix + 1, + BlockNode::ListItem { + children: tail, + spread: false, + checked, + span: None, + }, + ); + return true; + } + } + } + } + if let Some(children) = children_mut(&mut nodes[ix]) { + if insert(children, id, new_p) { + return true; + } + } + } + false + } + insert(&mut self.blocks, at.block, &new_p); + result + } + + pub fn adjacent(&self, at: Position, forward: bool) -> Position { + let ps = self.paragraphs(); + let ix = ps.iter().position(|p| p.editor_id() == at.block).unwrap(); + let text = ps[ix].text(); + if forward { + if at.offset < text.len() { + Position { + offset: next_boundary(&text, at.offset), + ..at + } + } else if ix + 1 < ps.len() { + Position { + block: ps[ix + 1].editor_id(), + offset: 0, + } + } else { + at + } + } else if at.offset > 0 { + Position { + offset: previous_boundary(&text, at.offset), + ..at + } + } else if ix > 0 { + Position { + block: ps[ix - 1].editor_id(), + offset: ps[ix - 1].text_len(), + } + } else { + at + } + } + + pub fn format(&mut self, a: Position, b: Position, bold: bool) { + let ranges: Vec<_> = self + .paragraphs() + .iter() + .filter_map(|p| { + self.selection_in(p.editor_id(), a, b) + .filter(|r| !r.is_empty()) + .map(|r| (p.editor_id(), r)) + }) + .collect(); + let enabled = !ranges.iter().all(|(id, r)| { + let p = self + .paragraphs() + .into_iter() + .find(|p| p.editor_id() == *id) + .unwrap(); + slice(p, r.clone()).iter().all(|n| { + n.marks.iter().any(|(r, m)| { + r.start == 0 && r.end == n.text.len() && if bold { m.bold } else { m.italic } + }) + }) + }); + for (id, r) in ranges { + let p = find_mut(&mut self.blocks, id).unwrap(); + let mut nodes = slice(p, 0..r.start); + for mut node in slice(p, r.clone()) { + for (_, mark) in &mut node.marks { + if bold { + mark.bold = false; + } else { + mark.italic = false; + } + } + let mut mark = TextMark::default(); + if bold { + mark.bold = enabled; + } else { + mark.italic = enabled; + } + node.marks.push((0..node.text.len(), mark)); + nodes.push(node); + } + nodes.extend(slice(p, r.end..p.text_len())); + p.children = nodes; + } + } + + pub fn heading(&mut self, id: u64, level: u8) { + fn convert(nodes: &mut [BlockNode], id: u64, level: u8) { + for node in nodes { + if let Some(p) = paragraph(node).filter(|p| p.editor_id() == id) { + let p = p.clone(); + *node = if level == 0 { + BlockNode::Paragraph(p) + } else { + BlockNode::Heading { + level, + children: p, + span: None, + } + }; + return; + } + if let Some(nodes) = children_mut(node) { + convert(nodes, id, level); + } + } + } + convert(&mut self.blocks, id, level.min(6)); + } + + pub fn list(&mut self, id: u64) { + if self.unlist(id) { + return; + } + self.make_list(id, false); + } + + pub fn make_list(&mut self, id: u64, ordered: bool) { + if let Some(ix) = self + .blocks + .iter() + .position(|n| paragraph(n).is_some_and(|p| p.editor_id() == id)) + { + let node = self.blocks.remove(ix); + self.blocks.insert( + ix, + BlockNode::List { + children: vec![BlockNode::ListItem { + children: vec![node], + spread: false, + checked: None, + span: None, + }], + ordered, + span: None, + }, + ); + } + } + + // Reuse the list item's existing task state and Markdown serialization. + pub fn make_task(&mut self, id: u64, checked: bool, require_list: bool) -> bool { + if self.list_item_path(id).is_none() { + if require_list { + return false; + } + self.make_list(id, false); + } + let Some((path, ix)) = self.list_item_path(id) else { + return false; + }; + let BlockNode::List { + children: items, .. + } = node_at_mut(&mut self.blocks, &path) + else { + return false; + }; + let BlockNode::ListItem { checked: task, .. } = &mut items[ix] else { + return false; + }; + *task = Some(checked); + true + } + + pub fn unlist(&mut self, id: u64) -> bool { + fn unwrap(nodes: &mut Vec, id: u64) -> bool { + for ix in 0..nodes.len() { + if let BlockNode::List { + children: items, + ordered, + .. + } = &nodes[ix] + { + if let Some(item_ix) = items.iter().position(|item| { + children(item).is_some_and(|nodes| { + nodes + .first() + .and_then(paragraph) + .is_some_and(|p| p.editor_id() == id) + }) + }) { + let mut replacement = Vec::new(); + if item_ix > 0 { + replacement.push(BlockNode::List { + children: items[..item_ix].to_vec(), + ordered: *ordered, + span: None, + }); + } + replacement.extend(children(&items[item_ix]).unwrap().clone()); + if item_ix + 1 < items.len() { + replacement.push(BlockNode::List { + children: items[item_ix + 1..].to_vec(), + ordered: *ordered, + span: None, + }); + } + nodes.splice(ix..ix + 1, replacement); + return true; + } + } + if let Some(children) = children_mut(&mut nodes[ix]) { + if unwrap(children, id) { + return true; + } + } + } + false + } + unwrap(&mut self.blocks, id) + } + + // Locate the list and item by structure, keeping paragraph identities unchanged. + fn list_item_path(&self, id: u64) -> Option<(Vec, usize)> { + fn find( + nodes: &[BlockNode], + id: u64, + path: &mut Vec, + ) -> Option<(Vec, usize)> { + for (ix, node) in nodes.iter().enumerate() { + path.push(ix); + if let BlockNode::List { + children: items, .. + } = node + { + if let Some(item) = items.iter().position(|item| { + children(item) + .and_then(|nodes| nodes.first()) + .and_then(paragraph) + .is_some_and(|p| p.editor_id() == id) + }) { + return Some((path.clone(), item)); + } + } + if let Some(nested) = children(node) { + if let Some(found) = find(nested, id, path) { + return Some(found); + } + } + path.pop(); + } + None + } + find(&self.blocks, id, &mut Vec::new()) + } + + pub fn indent_list(&mut self, id: u64) -> bool { + let Some((path, ix)) = self.list_item_path(id) else { + return false; + }; + if ix == 0 { + return false; + } + let BlockNode::List { + children: items, + ordered, + .. + } = node_at_mut(&mut self.blocks, &path) + else { + return false; + }; + let ordered = *ordered; + let item = items.remove(ix); + let nested = children_mut(&mut items[ix - 1]).unwrap(); + if let Some(BlockNode::List { + children: siblings, + ordered: sibling_ordered, + .. + }) = nested.last_mut() + { + if *sibling_ordered == ordered { + siblings.push(item); + return true; + } + } + nested.push(BlockNode::List { + children: vec![item], + ordered, + span: None, + }); + true + } + + pub fn outdent_list(&mut self, id: u64) -> bool { + let Some((path, ix)) = self.list_item_path(id) else { + return false; + }; + // A nested list is a child of an item in an outer list. + if path.len() < 3 + || !matches!( + node_at_mut(&mut self.blocks, &path[..path.len() - 2]), + BlockNode::List { .. } + ) + { + return self.unlist(id); + } + let (mut item, trailing, ordered, empty) = { + let BlockNode::List { + children: items, + ordered, + .. + } = node_at_mut(&mut self.blocks, &path) + else { + return false; + }; + let trailing = items.split_off(ix + 1); + let item = items.remove(ix); + (item, trailing, *ordered, items.is_empty()) + }; + if !trailing.is_empty() { + children_mut(&mut item).unwrap().push(BlockNode::List { + children: trailing, + ordered, + span: None, + }); + } + if empty { + let parent = node_at_mut(&mut self.blocks, &path[..path.len() - 1]); + children_mut(parent).unwrap().remove(*path.last().unwrap()); + } + let parent_ix = path[path.len() - 2]; + let BlockNode::List { + children: items, .. + } = node_at_mut(&mut self.blocks, &path[..path.len() - 2]) + else { + unreachable!() + }; + items.insert(parent_ix + 1, item); + true + } + + pub fn move_block(&mut self, id: u64, down: bool) { + let Some(ix) = self.blocks.iter().position(|node| { + let mut ps = Vec::new(); + visit(std::slice::from_ref(node), &mut ps); + ps.iter().any(|p| p.editor_id() == id) + }) else { + return; + }; + let other = if down { ix + 1 } else { ix.saturating_sub(1) }; + if other < self.blocks.len() { + self.blocks.swap(ix, other); + } + } +} + +fn node_at_mut<'a>(nodes: &'a mut [BlockNode], path: &[usize]) -> &'a mut BlockNode { + let node = &mut nodes[path[0]]; + if path.len() == 1 { + node + } else { + node_at_mut(children_mut(node).unwrap(), &path[1..]) + } +} + +pub(super) fn previous_boundary(text: &str, offset: usize) -> usize { + text.grapheme_indices(true) + .map(|(ix, _)| ix) + .take_while(|ix| *ix < offset) + .last() + .unwrap_or(0) +} + +pub(super) fn next_boundary(text: &str, offset: usize) -> usize { + text.grapheme_indices(true) + .map(|(ix, _)| ix) + .find(|ix| *ix > offset) + .unwrap_or(text.len()) +} diff --git a/crates/base/src/text/editor/tests.rs b/crates/base/src/text/editor/tests.rs new file mode 100644 index 0000000000..47954b8fd1 --- /dev/null +++ b/crates/base/src/text/editor/tests.rs @@ -0,0 +1,603 @@ +use super::{ + input::{byte_offset, utf16_offset}, + model::{Document, Position}, +}; + +#[test] +fn edit_ranges_keep_marks_and_unicode_boundaries() { + let mut doc = Document::parse("**你好** world").unwrap(); + let at = Position { + offset: "你好".len(), + ..doc.first() + }; + let cursor = doc.replace(at, at, "🙂", None); + assert_eq!(doc.text(at.block), "你好🙂 world"); + assert_eq!(doc.adjacent(cursor, false), at); + assert!(doc.source().contains("**")); + assert_eq!(byte_offset("你🙂a", 3), 7); + assert_eq!(utf16_offset("你🙂a", 7), 3); +} + +#[test] +fn cross_block_replace_and_split_keep_the_first_identity() { + let mut doc = Document::parse("# One\n\nTwo\n\nThree").unwrap(); + let first = doc.first(); + let last = Position { + offset: 2, + ..doc.last() + }; + let cursor = doc.replace(Position { offset: 1, ..first }, last, "!", None); + assert_eq!(doc.text(first.block), "O!ree"); + assert_eq!(doc.paragraphs().len(), 1); + let next = doc.split(cursor); + assert_ne!(next.block, first.block); + assert_eq!(doc.text(next.block), "ree"); + assert!(doc.source().starts_with("# O!")); +} + +#[test] +fn list_enter_creates_a_sibling_item() { + let mut doc = Document::parse("- One\n- Two").unwrap(); + doc.split(Position { + offset: 1, + ..doc.first() + }); + let source = doc.source(); + assert!(source.contains("- O")); + assert!(source.contains("- ne")); + assert!(source.contains("- Two")); +} + +#[test] +fn unsupported_blocks_survive_text_edits() { + let mut doc = Document::parse("Hello\n\n```rust\nlet x = 1;\n```\n\n![alt](https://example.com/a.png)\n\n| A | B |\n| - | - |\n| 1 | 2 |").unwrap(); + doc.replace(doc.first(), doc.first(), "X", None); + let source = doc.source(); + assert!(source.contains("let x = 1;")); + assert!(source.contains("https://example.com/a.png")); + assert!(source.contains("| A | B |")); +} + +#[test] +fn snapshots_do_not_share_mutable_document_content() { + let mut doc = Document::parse("**before**").unwrap(); + let before = doc.clone(); + doc.replace(doc.first(), doc.last(), "after", None); + assert_eq!(before.text(before.first().block), "before"); + assert_eq!(doc.text(doc.first().block), "after"); +} + +#[test] +fn toggling_format_off_preserves_text_and_other_marks() { + let mut doc = Document::parse("**hello** *world*").unwrap(); + doc.format( + doc.first(), + Position { + offset: 5, + ..doc.first() + }, + true, + ); + assert!(!doc.source().contains("**hello**")); + assert!(doc.source().contains("*world*")); + assert_eq!(doc.text(doc.first().block), "hello world"); +} + +#[gpui::test] +fn ime_commit_is_one_undo_and_native_offsets_are_utf16(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::{EntityInputHandler, VisualTestContext}; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| MarkdownEditorState::new("a🙂b", cx).unwrap()); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| { + state.replace_and_mark_text_in_range(Some(1..3), "你", Some(1..1), window, cx); + assert_eq!(state.document.text(state.cursor.block), "a你b"); + state.replace_and_mark_text_in_range(None, "你好", Some(2..2), window, cx); + state.replace_text_in_range(None, "你好", window, cx); + assert_eq!(state.document.text(state.cursor.block), "a你好b"); + assert!(state.history.can_undo()); + state.undo(cx); + assert_eq!(state.document.text(state.cursor.block), "a🙂b"); + state.redo(cx); + assert_eq!(state.document.text(state.cursor.block), "a你好b"); + }) + }); +} + +#[gpui::test] +fn readonly_rejects_typing_formatting_and_history(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::{EntityInputHandler, VisualTestContext}; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| MarkdownEditorState::new("hello", cx).unwrap()); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| { + let source = state.source(); + state.set_readonly(true, cx); + state.replace_text_in_range(None, "changed", window, cx); + state.replace_and_mark_text_in_range(None, "你", None, window, cx); + state.set_heading(2, cx); + state.undo(cx); + assert_eq!(state.source(), source); + }) + }); +} + +#[gpui::test] +fn keyboard_edits_the_rendered_document(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::VisualTestContext; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| MarkdownEditorState::new("hello", cx).unwrap()); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| state.focus(window, cx)); + window.draw(cx).clear(cx); + }); + cx.simulate_keystrokes("end enter w o r l d"); + cx.read(|cx| { + let state = editor.read(cx); + assert_eq!(state.document.paragraphs().len(), 2); + assert_eq!(state.document.text(state.cursor.block), "world"); + }); +} + +#[gpui::test] +fn collapsed_format_can_disable_inherited_bold(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::AppContext; + cx.update(crate::init); + let editor = cx.new(|cx| MarkdownEditorState::new("**hello**", cx).unwrap()); + editor.update(cx, |state, cx| { + state.place(state.document.last(), false, cx); + state.toggle_bold(cx); + state.insert(" plain", cx); + assert!(!state.document.mark_at(state.cursor).bold); + assert!(state.source().contains("**hello**")); + state.toggle_bold(cx); + state.insert(" bold", cx); + assert!(state.document.mark_at(state.cursor).bold); + }); +} + +#[gpui::test] +fn readonly_round_trip_preserves_selection_and_history(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::VisualTestContext; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| MarkdownEditorState::new("hello", cx).unwrap()); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| { + state.focus(window, cx); + state.place(state.document.last(), false, cx); + state.insert("!", cx); + state.anchor = state.document.first(); + state.set_readonly(true, cx); + }); + window.draw(cx).clear(cx); + }); + cx.simulate_keystrokes("backspace enter x"); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| { + assert_eq!(state.document.text(state.cursor.block), "hello!"); + assert_eq!( + state.document.selected(state.anchor, state.cursor), + "hello!" + ); + assert!(!state.active); + state.copy(cx); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("hello!".to_string()) + ); + state.set_readonly(false, cx); + state.focus(window, cx); + }); + }); + cx.simulate_keystrokes("x"); + editor.update(cx, |state, cx| { + assert_eq!(state.document.text(state.cursor.block), "x"); + state.undo(cx); + assert_eq!(state.document.text(state.cursor.block), "hello!"); + state.undo(cx); + assert_eq!(state.document.text(state.cursor.block), "hello"); + }); +} + +#[gpui::test] +fn readonly_transition_settles_composition_once(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::{EntityInputHandler, VisualTestContext}; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| MarkdownEditorState::new("", cx).unwrap()); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| { + state.replace_and_mark_text_in_range(None, "你好", None, window, cx); + state.set_readonly(true, cx); + state.set_readonly(true, cx); + state.replace_text_in_range(None, "late commit", window, cx); + assert!(state.composition.is_none()); + assert!(state.history.can_undo()); + assert_eq!(state.document.text(state.cursor.block), "你好"); + state.set_readonly(false, cx); + state.undo(cx); + assert_eq!(state.document.text(state.cursor.block), ""); + state.redo(cx); + assert_eq!(state.document.text(state.cursor.block), "你好"); + }); + }); +} + +#[gpui::test] +fn inline_code_fragments_map_back_to_document_positions(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::{EntityInputHandler, VisualTestContext, point, px}; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| { + MarkdownEditorState::new("# before `代码` [after](https://example.com)", cx).unwrap() + }); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| state.focus(window, cx)); + window.draw(cx).clear(cx); + editor.update(cx, |state, cx| { + let id = state.document.first().block; + let mut fragments = state + .layouts + .iter() + .filter(|((block, _), _)| *block == id) + .map(|(_, layout)| layout) + .collect::>(); + fragments.sort_by_key(|layout| layout.source.start); + assert!( + fragments.len() >= 3, + "inline code must use shared fragment layout" + ); + assert_eq!(fragments.first().unwrap().source.start, 0); + assert_eq!( + fragments.last().unwrap().source.end, + state.document.text(id).len() + ); + for pair in fragments.windows(2) { + assert_eq!(pair[0].source.end, pair[1].source.start); + } + let target = Position { + block: id, + offset: "before 代".len(), + }; + let layout = state.layout_for(target).unwrap(); + let origin = layout + .text + .position_for_index(target.offset - layout.source.start) + .unwrap(); + let hit = state + .hit(point( + origin.x + px(0.1), + origin.y + layout.text.line_height() / 2., + )) + .unwrap(); + assert_eq!(hit, target); + state.place(hit, false, cx); + let bounds = state + .bounds_for_range(8..8, gpui::Bounds::default(), window, cx) + .unwrap(); + assert!((bounds.left() - origin.x).abs() < px(1.)); + state.insert("新", cx); + assert_eq!(state.document.text(id), "before 代新码 after"); + assert!(state.source().contains("https://example.com")); + state.undo(cx); + assert_eq!(state.document.first().block, id); + assert_eq!(state.document.text(id), "before 代码 after"); + }); + }); +} + +#[gpui::test] +fn shared_history_discards_redo_after_a_new_edit(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::AppContext; + cx.update(crate::init); + let editor = cx.new(|cx| MarkdownEditorState::new("a", cx).unwrap()); + editor.update(cx, |state, cx| { + state.place(state.document.last(), false, cx); + state.enter(cx); + let second = state.cursor.block; + state.insert("b", cx); + state.undo(cx); + state.undo(cx); + assert_eq!(state.document.paragraphs().len(), 1); + state.redo(cx); + assert_eq!(state.cursor.block, second); + state.insert("c", cx); + assert!(!state.history.can_redo()); + assert_eq!(state.document.text(second), "c"); + }); +} + +#[gpui::test] +fn table_cells_do_not_register_editable_positions(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::VisualTestContext; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| { + MarkdownEditorState::new("text\n\n| A | B |\n| - | - |\n| 1 | 2 |", cx).unwrap() + }); + VisualTestContext::update(cx, |window, cx| { + window.draw(cx).clear(cx); + editor.read_with(cx, |state, _| { + let text_id = state.document.first().block; + assert!(!state.layouts.is_empty()); + assert!(state.layouts.keys().all(|(id, _)| *id == text_id)); + }); + }); +} + +#[test] +fn list_indent_outdent_preserves_order_and_markdown_nesting() { + for source in ["- one\n- two\n- three", "1. one\n2. two\n3. three"] { + let mut doc = Document::parse(source).unwrap(); + let ids = doc + .paragraphs() + .iter() + .map(|p| p.editor_id()) + .collect::>(); + assert!(!doc.indent_list(ids[0])); + assert!(doc.indent_list(ids[1])); + assert!(doc.indent_list(ids[2])); + let nested = doc.source(); + assert!(nested.contains("\n - two") || nested.contains("\n 1. two")); + let parsed = Document::parse(&nested).unwrap(); + assert_eq!(parsed.source(), nested); + assert!(doc.outdent_list(ids[1])); + assert_eq!( + doc.paragraphs() + .iter() + .map(|p| p.editor_id()) + .collect::>(), + ids + ); + // Following nested siblings become children of the promoted item. + assert!(doc.outdent_list(ids[2])); + assert_eq!(doc.source(), Document::parse(source).unwrap().source()); + } +} + +#[gpui::test] +fn list_keyboard_shortcuts_and_ordered_continuation(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::VisualTestContext; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| MarkdownEditorState::new("", cx).unwrap()); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| state.focus(window, cx)); + window.draw(cx).clear(cx); + }); + cx.simulate_keystrokes("1 . space a enter b tab"); + editor.update(cx, |state, _| { + assert!(state.source().contains("1. a\n 1. b")) + }); + cx.simulate_keystrokes("shift-tab"); + editor.update(cx, |state, cx| { + assert!(state.source().contains("1. a\n2. b")); + state.undo(cx); + assert!(state.source().contains("1. a\n 1. b")); + state.redo(cx); + assert!(state.source().contains("1. a\n2. b")); + state.set_readonly(true, cx); + }); + cx.simulate_keystrokes("tab shift-tab"); + editor.update(cx, |state, _| { + assert!(state.source().contains("1. a\n2. b")) + }); +} + +#[gpui::test] +fn empty_list_backspace_keeps_the_current_paragraph(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::AppContext; + cx.update(crate::init); + for source in ["previous\n\n- ", "previous\n\n1. "] { + let editor = cx.new(|cx| MarkdownEditorState::new(source, cx).unwrap()); + editor.update(cx, |state, cx| { + state.place(state.document.last(), false, cx); + let cursor = state.cursor; + let count = state.document.paragraphs().len(); + let before = state.source(); + state.delete(false, cx); + assert_eq!(state.cursor, cursor); + assert_eq!(state.document.paragraphs().len(), count); + assert_eq!(state.document.text(cursor.block), ""); + assert_ne!(state.source(), before); + state.undo(cx); + assert_eq!(state.source(), before); + }); + } +} + +#[test] +fn enter_on_a_parent_list_item_keeps_its_children() { + let mut doc = Document::parse("- parent\n - child\n- sibling").unwrap(); + let at = Position { + offset: 3, + ..doc.first() + }; + doc.split(at); + let source = doc.source(); + assert!(source.contains("- par\n- ent\n - child\n- sibling")); + assert_eq!(Document::parse(&source).unwrap().source(), source); +} + +#[gpui::test] +fn empty_nested_list_backspace_outdents_without_joining(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::AppContext; + cx.update(crate::init); + let editor = cx.new(|cx| MarkdownEditorState::new("- parent", cx).unwrap()); + editor.update(cx, |state, cx| { + state.place(state.document.last(), false, cx); + state.enter(cx); + state.indent_list(cx); + let cursor = state.cursor; + let nested = state.source(); + state.delete(false, cx); + assert_eq!(state.cursor, cursor); + assert_eq!(state.document.paragraphs().len(), 2); + assert_eq!(state.document.text(cursor.block), ""); + assert_ne!(state.source(), nested); + state.delete(false, cx); + assert_eq!(state.cursor, cursor); + assert_eq!(state.document.paragraphs().len(), 2); + assert!(!state.document.outdent_list(cursor.block)); + state.undo(cx); + state.undo(cx); + assert_eq!(state.source(), nested); + }); +} + +#[gpui::test] +fn task_prefixes_export_standard_markdown_and_continue_unchecked(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::AppContext; + cx.update(crate::init); + for (prefix, checked) in [ + ("-[]", false), + ("-[ ]", false), + ("-[x]", true), + ("-[X]", true), + ("- [ ]", false), + ("- [x]", true), + ] { + let editor = cx.new(|cx| MarkdownEditorState::new("", cx).unwrap()); + editor.update(cx, |state, cx| { + // Commit each character separately, including the list's first space. + for ch in prefix.chars() { + state.insert(&ch.to_string(), cx); + } + state.insert(" ", cx); + let id = state.cursor.block; + assert_eq!(state.document.text(id), ""); + state.insert("任务", cx); + let expected = if checked { + "- [x] 任务" + } else { + "- [ ] 任务" + }; + assert_eq!(state.source().trim(), expected); + assert_eq!( + Document::parse(&state.source()).unwrap().source(), + state.source() + ); + state.enter(cx); + state.insert("下一项", cx); + assert_eq!(state.source().trim(), format!("{expected}\n- [ ] 下一项")); + state.undo(cx); + state.undo(cx); + assert_eq!(state.source().trim(), expected); + }); + } +} + +#[gpui::test] +fn task_conversion_is_undoable_and_plain_brackets_stay_text(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::AppContext; + cx.update(crate::init); + let editor = cx.new(|cx| MarkdownEditorState::new("", cx).unwrap()); + editor.update(cx, |state, cx| { + state.insert("-[x]", cx); + state.insert(" ", cx); + assert_eq!(state.source().trim(), "- [x]"); + state.undo(cx); + assert_eq!(state.document.text(state.cursor.block), "-[x]"); + state.redo(cx); + assert_eq!(state.source().trim(), "- [x]"); + state.delete(false, cx); + assert_eq!(state.source().trim(), ""); + state.insert("[x]", cx); + state.insert(" ", cx); + assert_eq!(state.document.text(state.cursor.block), "[x] "); + }); +} + +#[gpui::test] +fn task_prefix_keyboard_input(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::VisualTestContext; + cx.update(crate::init); + let (editor, cx) = cx.add_window_view(|_, cx| MarkdownEditorState::new("", cx).unwrap()); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| state.focus(window, cx)); + window.draw(cx).clear(cx); + }); + cx.simulate_keystrokes("- [ x ] space a enter b"); + editor.update(cx, |state, _| { + assert_eq!(state.source().trim(), "- [x] a\n- [ ] b") + }); +} + +#[gpui::test] +fn double_click_selects_words_in_editable_and_readonly_markdown(cx: &mut gpui::TestAppContext) { + use super::MarkdownEditorState; + use gpui::{Modifiers, MouseButton, MouseDownEvent, VisualTestContext, point, px}; + cx.update(crate::init); + for readonly in [false, true] { + for (source, offset, expected) in [ + ("before **some**thing after", 9, "something"), + ("before `hello` after", 9, "hello"), + ("中文", 3, "文"), + ] { + let (editor, cx) = + cx.add_window_view(|_, cx| MarkdownEditorState::new(source, cx).unwrap()); + VisualTestContext::update(cx, |window, cx| { + editor.update(cx, |state, cx| state.set_readonly(readonly, cx)); + window.draw(cx).clear(cx); + }); + let position = editor.read_with(cx, |state, _| { + let at = Position { + offset, + ..state.document.first() + }; + let layout = state.layout_for(at).unwrap(); + let origin = layout + .text + .position_for_index(offset - layout.source.start) + .unwrap(); + point( + origin.x + px(0.1), + origin.y + layout.text.line_height() / 2., + ) + }); + cx.simulate_event(MouseDownEvent { + position, + modifiers: Modifiers::default(), + button: MouseButton::Left, + click_count: 2, + first_mouse: false, + }); + editor.read_with(cx, |state, _| { + assert_eq!( + state.document.selected(state.anchor, state.cursor), + expected + ) + }); + cx.simulate_mouse_move(position, MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); + editor.update(cx, |state, cx| { + assert_eq!( + state.document.selected(state.anchor, state.cursor), + expected + ); + state.copy(cx); + assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap(), expected); + if !readonly { + state.insert("new", cx); + assert!(!state.document.text(state.cursor.block).contains(expected)); + state.undo(cx); + assert_eq!( + state.document.selected(state.anchor, state.cursor), + expected + ); + } + }); + } + } +} diff --git a/crates/base/src/text/inline.rs b/crates/base/src/text/inline.rs index a59bb278cd..93ff586406 100644 --- a/crates/base/src/text/inline.rs +++ b/crates/base/src/text/inline.rs @@ -157,6 +157,18 @@ pub(super) fn text_size_ranges( ranges } +// Consumers can own text interaction while sharing the existing layout. +pub(super) trait InlineInteraction: Send + Sync { + fn paint( + &self, + layout: &TextLayout, + bounds: Bounds, + source: Range, + window: &mut Window, + cx: &mut App, + ); +} + /// A inline element used to render a inline text and support selectable. /// /// All text in TextView (including the CodeBlock) used this for text rendering. @@ -172,6 +184,8 @@ pub(super) struct Inline { selection_bounds: Option>, selection_source: Option<(Arc>, Range)>, link_click_handler: Option>, + interaction: Option>, + source_range: Range, state: Arc>, } @@ -216,10 +230,22 @@ impl Inline { selection_bounds: None, selection_source: None, link_click_handler, + interaction: None, + source_range: 0..0, state, } } + pub(super) fn interaction( + mut self, + interaction: Option>, + source: Range, + ) -> Self { + self.interaction = interaction; + self.source_range = source; + self + } + /// Use the resolved style captured by a deferred parent layout. pub(super) fn text_style(mut self, text_style: TextStyle) -> Self { self.text_style = Some(text_style); @@ -463,7 +489,7 @@ impl Inline { } /// Paint the selection background. - fn paint_selection( + pub(super) fn paint_selection( selection: &Selection, text_layout: &TextLayout, bounds: &Bounds, @@ -570,7 +596,12 @@ impl Element for Inline { .unwrap_or_else(|| window.text_style()); let runs = text_runs(self.text.len(), &text_style, &self.highlights); - self.styled_text = StyledText::new(self.text.clone()).with_runs(runs); + // Keep an empty editable paragraph measurable for its caret. + self.styled_text = if self.interaction.is_some() && self.text.is_empty() { + StyledText::new("\u{200b}").with_runs(vec![text_style.to_run("\u{200b}".len())]) + } else { + StyledText::new(self.text.clone()).with_runs(runs) + }; let (layout_id, _) = self.styled_text .request_layout(global_element_id, inspector_id, window, cx); @@ -626,6 +657,13 @@ impl Element for Inline { let current_view = window.current_view(); let hitbox = prepaint; let text_layout = self.styled_text.layout().clone(); + if let Some(interaction) = &self.interaction { + interaction.paint(&text_layout, bounds, self.source_range.clone(), window, cx); + self.styled_text + .paint(global_id, None, bounds, &mut (), &mut (), window, cx); + window.set_cursor_style(CursorStyle::IBeam, hitbox); + return; + } self.styled_text .paint(global_id, None, bounds, &mut (), &mut (), window, cx); diff --git a/crates/base/src/text/inline_flow.rs b/crates/base/src/text/inline_flow.rs index a7f559bbde..5320b03263 100644 --- a/crates/base/src/text/inline_flow.rs +++ b/crates/base/src/text/inline_flow.rs @@ -16,7 +16,9 @@ use gpui::{ use crate::text::text_view::{LinkClickHandlerFn, handle_link_click}; use super::{ - inline::{Inline, InlineHighlight, InlineState, text_runs, text_size_ranges}, + inline::{ + Inline, InlineHighlight, InlineInteraction, InlineState, text_runs, text_size_ranges, + }, inline_object::{InlineObject, MeasuredInlineObject}, node::LinkMark, utils::image_source, @@ -28,6 +30,7 @@ pub(super) const INLINE_CODE_PADDING: f32 = 2.; pub(super) struct InlineFlow { id: ElementId, items: Vec, + interaction: Option>, link_click_handler: Option>, } @@ -153,10 +156,16 @@ impl InlineFlow { Self { id: id.into(), items, + interaction: None, link_click_handler, } } + pub(super) fn interaction(mut self, interaction: Option>) -> Self { + self.interaction = interaction; + self + } + fn image_element( ix: usize, url: &SharedUri, @@ -443,7 +452,8 @@ impl Element for InlineFlow { highlights, self.link_click_handler.clone(), ) - .selection_source(source_state.clone(), source_range) + .selection_source(source_state.clone(), source_range.clone()) + .interaction(self.interaction.clone(), source_range) .text_style(text_style.clone()) .selection_bounds(Bounds::new( point(bounds.left(), bounds.top() + selection_bounds.top()), diff --git a/crates/base/src/text/mod.rs b/crates/base/src/text/mod.rs index be52543538..51336e35c5 100644 --- a/crates/base/src/text/mod.rs +++ b/crates/base/src/text/mod.rs @@ -1,4 +1,5 @@ mod document; +pub mod editor; mod format; mod inline; mod inline_element; @@ -25,6 +26,7 @@ pub use text_view::*; pub(crate) fn init(cx: &mut App) { state::init(cx); + editor::init(cx); } /// Create a new markdown text view with code location as id. diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index 66a489be71..0b3582d712 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -21,7 +21,8 @@ use crate::{ MarkdownNode, TableActionsFn, document::NodeRenderOptions, inline::{ - Inline, InlineHighlight, InlineState, combine_highlights, text_runs, text_size_ranges, + Inline, InlineHighlight, InlineInteraction, InlineState, combine_highlights, text_runs, + text_size_ranges, }, inline_flow::{InlineFlow, InlineFlowItem, slice_ranges}, text_view::handle_link_click, @@ -1477,6 +1478,8 @@ impl CodeBlock { /// A context for rendering nodes, contains link references. #[derive(Default, Clone)] pub(crate) struct NodeContext { + // Optional interaction over the shared paragraph layout. + pub(super) paragraph_interaction: Option>, /// The byte offset of the node in the original markdown text. /// Used for incremental updates. pub(crate) offset: usize, @@ -1495,6 +1498,9 @@ impl NodeContext { } } +pub(super) type ParagraphInteraction = + dyn Fn(&Paragraph) -> Option> + Send + Sync; + impl PartialEq for NodeContext { fn eq(&self, other: &Self) -> bool { self.link_refs == other.link_refs && self.style == other.style @@ -1569,6 +1575,10 @@ impl Paragraph { } fn render(&self, node_cx: &NodeContext, _window: &mut Window, cx: &mut App) -> AnyElement { + let interaction = node_cx + .paragraph_interaction + .as_ref() + .and_then(|interaction| interaction(self)); let span = self.span; let children = &self.children; @@ -1578,6 +1588,7 @@ impl Paragraph { self.inline_flow_items(node_cx, cx), node_cx.link_click_handler.clone(), ) + .interaction(interaction) .into_any_element(); } @@ -1684,7 +1695,7 @@ impl Paragraph { } // Add the last text node - if text.len() > 0 { + if text.len() > 0 || interaction.is_some() { if let Ok(mut state) = self.state.lock() { state.set_text(text.into()); } @@ -1696,6 +1707,7 @@ impl Paragraph { highlights, node_cx.link_click_handler.clone(), ) + .interaction(interaction, 0..self.text_len()) .into_any_element(), ); } @@ -2048,7 +2060,14 @@ impl BlockNode { } else { "- ".to_string() }; - format!("{}{}", prefix, child.to_markdown()) + let body = child.to_markdown(); + let indent = " ".repeat(prefix.len()); + let mut lines = body.split('\n'); + let first = format!("{}{}", prefix, lines.next().unwrap_or_default()); + std::iter::once(first) + .chain(lines.map(|line| format!("{indent}{line}"))) + .collect::>() + .join("\n") }) .collect::>() .join("\n"), diff --git a/crates/base/src/text/text_view.rs b/crates/base/src/text/text_view.rs index 45f7baf9b1..d590f8dfbc 100644 --- a/crates/base/src/text/text_view.rs +++ b/crates/base/src/text/text_view.rs @@ -26,8 +26,8 @@ pub(crate) type CodeBlockHighlighterFn = /// presentation or syntax-highlighting overrides. #[derive(Clone, Default)] pub struct TextViewDefaults { - style: Option, - code_block_highlighter: Option>, + pub(super) style: Option, + pub(super) code_block_highlighter: Option>, } impl Global for TextViewDefaults {} diff --git a/crates/component/src/text/editor.rs b/crates/component/src/text/editor.rs new file mode 100644 index 0000000000..f067c1621a --- /dev/null +++ b/crates/component/src/text/editor.rs @@ -0,0 +1,37 @@ +use crate::{ActiveTheme, StyledExt}; +use gpui::*; +pub use gpui_base::text::editor::{MarkdownEditorEvent, MarkdownEditorState}; + +// The styled editing surface retains its state in the owning application. +#[derive(IntoElement)] +pub struct MarkdownEditor { + state: Entity, + style: StyleRefinement, +} + +impl MarkdownEditor { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + } + } +} + +impl Styled for MarkdownEditor { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for MarkdownEditor { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .size_full() + .min_h_0() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .refine_style(&self.style) + .child(self.state) + } +} diff --git a/crates/component/src/text/mod.rs b/crates/component/src/text/mod.rs index 7401a16857..abefbcd6f2 100644 --- a/crates/component/src/text/mod.rs +++ b/crates/component/src/text/mod.rs @@ -1,6 +1,8 @@ //! Compatibility facade for rich text now owned by `gpui-base`. mod compat; +mod editor; +pub use editor::{MarkdownEditor, MarkdownEditorEvent, MarkdownEditorState}; mod frontmatter; mod style; diff --git a/examples/fixtures/markdown_plugins.rs b/examples/fixtures/markdown_plugins.rs new file mode 100644 index 0000000000..2cf410fcc6 --- /dev/null +++ b/examples/fixtures/markdown_plugins.rs @@ -0,0 +1,1003 @@ +#[derive(Clone)] +struct TickerNode { + symbol: String, +} + +#[derive(Clone)] +struct UserCardNode { + id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct MathNode { + source: String, + inline: bool, +} + +#[derive(Clone, Copy)] +struct TickerQuote { + name: &'static str, + price: f64, + change: f64, +} + +#[derive(Clone)] +struct TickerPlugin { + apple_quote: TickerQuote, + tesla_quote: TickerQuote, +} + +impl TickerPlugin { + fn new(apple_quote: TickerQuote, tesla_quote: TickerQuote) -> Self { + Self { + apple_quote, + tesla_quote, + } + } + + fn quote(&self, symbol: &str) -> TickerQuote { + match symbol { + "AAPL.US" => self.apple_quote, + "TSLA.US" => self.tesla_quote, + _ => TickerQuote { + name: "Unknown", + price: 0.0, + change: 0.0, + }, + } + } +} + +#[derive(Clone)] +struct UserCardPlugin; + +#[derive(Clone)] +struct MathPlugin; + +#[derive(Clone)] +struct RenderedMathImage { + image: Arc, + width: f32, + height: f32, + baseline: f32, +} + +impl MathPlugin { + fn new() -> Self { + Self + } +} + +impl UserCardPlugin { + fn new() -> Self { + Self + } +} + +fn mdx_attr(attrs: &[markdown_ast::AttributeContent], name: &str) -> Option { + attrs.iter().find_map(|attr| match attr { + markdown_ast::AttributeContent::Property(prop) if prop.name == name => { + match prop.value.as_ref() { + Some(markdown_ast::AttributeValue::Literal(value)) => Some(value.clone()), + _ => None, + } + } + _ => None, + }) +} + +fn html_tag_name(value: &str) -> Option<&str> { + value + .trim() + .strip_prefix('<')? + .split([' ', '/', '>']) + .next() +} + +fn html_attr(value: &str, name: &str) -> Option { + let pattern = format!("{name}=\""); + let start = value.find(&pattern)? + pattern.len(); + let end = value[start..].find('"')?; + Some(value[start..start + end].to_string()) +} + +fn ticker_symbol(value: &str) -> Option<&str> { + let symbol = value.strip_prefix('$')?; + if symbol.is_empty() + || !symbol.contains('.') + || !symbol + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '.') + { + return None; + } + Some(symbol) +} + +fn math_markdown(source: &str, inline: bool) -> String { + if inline { + format!("${source}$") + } else { + format!("$$\n{source}\n$$") + } +} + +fn math_node(source: String, inline: bool, markdown: impl Into) -> MarkdownNode { + MarkdownNode::new( + if inline { "inline-math" } else { "math" }, + MathNode { + source: source.clone(), + inline, + }, + ) + .text(prettify_math_source(&source)) + .accessibility_label(format!("Formula: {}", prettify_math_source(&source))) + .markdown(markdown.into()) +} + +fn block_math_source(source: &str) -> Option<&str> { + let source = source.trim(); + let body = source.strip_prefix("$$")?.strip_suffix("$$")?.trim(); + (!body.is_empty()).then_some(body) +} + +impl MarkdownPlugin for MathPlugin { + fn is_block(&self) -> bool { + true + } + + fn name(&self) -> &str { + "math" + } + + fn parse( + &self, + node: &markdown_ast::Node, + cx: &MarkdownParseContext<'_>, + ) -> Option { + if let markdown_ast::Node::Math(math) = node { + return Some(math_node( + math.value.clone(), + false, + cx.node_source(node) + .map(str::to_string) + .unwrap_or_else(|| math_markdown(&math.value, false)), + )); + } + + let markdown_ast::Node::Paragraph(_) = node else { + return None; + }; + let source = cx.node_source(node)?; + + if let Some(math) = block_math_source(source) { + return Some(math_node(math.to_string(), false, source)); + } + + None + } + + fn render(&self, node: &MarkdownNode, window: &mut Window, cx: &mut App) -> impl IntoElement { + let math = node.data::().expect("math markdown node data"); + let font_size = f32::from(window.text_style().font_size.to_pixels(window.rem_size())); + + div() + .w_full() + .flex() + .justify_center() + .py_1() + .child(render_math_formula(&math.source, false, font_size, cx)) + } +} + +/// Prepared resources belong to this example, not a process-global inline cache. +#[derive(Default)] +struct InlineMathCache { + document: String, + images: HashMap>, +} + +impl InlineMathCache { + fn set_document(&mut self, source: &str) { + if self.document != source { + self.document = source.to_string(); + // Keep prepared and pending resources for formulas still in the document. + self.images + .retain(|key, _| source.contains(key.split('\0').next().unwrap_or_default())); + } + } +} + +#[derive(Clone)] +struct InlineMathPlugin { + cache: Arc>, + invalidate: Arc, +} + +impl InlineMathPlugin { + fn new(invalidate: impl Fn(&mut App) + Send + Sync + 'static) -> Self { + Self { + cache: Arc::default(), + invalidate: Arc::new(invalidate), + } + } + + fn set_document(&self, source: &str) { + self.cache.lock().unwrap().set_document(source); + } +} + +fn parse_inline_math(node: &markdown_ast::Node, source: Option<&str>) -> Option { + let markdown_ast::Node::InlineMath(math) = node else { + return None; + }; + Some(math_node( + math.value.clone(), + true, + source + .map(str::to_string) + .unwrap_or_else(|| math_markdown(&math.value, true)), + )) +} + +impl MarkdownPlugin for InlineMathPlugin { + fn name(&self) -> &str { + "inline-math" + } + + fn parse( + &self, + node: &markdown_ast::Node, + context: &MarkdownParseContext<'_>, + ) -> Option { + parse_inline_math(node, context.node_source(node)) + } + + fn render_inline( + &self, + node: &MarkdownNode, + context: &InlineRenderContext, + _window: &mut Window, + cx: &mut App, + ) -> Option { + let source = node.data::()?.source.clone(); + let font_size = f32::from(context.font_size()); + let foreground = context.text_style().color; + let background = cx.theme().background; + let key = format!("{source}\0{font_size:?}\0{foreground:?}\0{background:?}"); + let mut cache = self.cache.lock().unwrap(); + if let Some(image) = cache.images.get(&key) { + return image.as_ref().map(|image| { + let fallback_source = source.clone(); + let fallback = + move || render_math_text(&fallback_source, true, font_size, foreground); + InlineElement::new( + img(image.image.clone()) + .object_fit(ObjectFit::Contain) + .w(px(image.width)) + .h(px(image.height)) + .with_loading(fallback.clone()) + .with_fallback(fallback), + ) + .with_baseline(px(image.baseline)) + }); + } + // None represents pending or unavailable; both use TextView's atomic text fallback. + cache.images.insert(key.clone(), None); + drop(cache); + let cache = Arc::downgrade(&self.cache); + let invalidate = self.invalidate.clone(); + // The callback only reads prepared images and enqueues at most one request per + // exact source/font/theme key. Deferring is necessary to capture the real + // font size (including headings), without running MathJax during layout. + // The pending entry above deduplicates repeated measurement callbacks. + cx.defer(move |cx| { + if cache + .upgrade() + .is_none_or(|cache| !cache.lock().unwrap().images.contains_key(&key)) + { + return; + } + let task = cx.background_executor().spawn(async move { + render_math_image(&source, true, font_size, foreground, background) + }); + cx.spawn(async move |cx| { + let image = task.await; + let Some(cache) = cache.upgrade() else { return }; + let mut cache = cache.lock().unwrap(); + if !cache.images.contains_key(&key) { + return; + } + cache.images.insert(key, image); + drop(cache); + cx.update(|cx| invalidate(cx)); + }) + .detach(); + }); + None + } +} + +fn render_math_formula(source: &str, inline: bool, font_size: f32, cx: &mut App) -> AnyElement { + if let Some(image) = render_math_image( + source, + inline, + font_size, + cx.theme().foreground, + cx.theme().background, + ) { + img(image.image) + .object_fit(ObjectFit::Contain) + .flex_shrink_0() + .w(px(image.width)) + .h(px(image.height)) + .into_any_element() + } else { + render_math_text(source, inline, font_size, cx.theme().foreground) + } +} + +impl MarkdownPlugin for TickerPlugin { + fn is_block(&self) -> bool { + true + } + + fn name(&self) -> &str { + "ticker" + } + + fn parse( + &self, + node: &markdown_ast::Node, + cx: &MarkdownParseContext<'_>, + ) -> Option { + let markdown_ast::Node::Paragraph(paragraph) = node else { + return None; + }; + let [markdown_ast::Node::Text(text)] = paragraph.children.as_slice() else { + return None; + }; + let symbol = ticker_symbol(&text.value)?; + Some( + MarkdownNode::new( + "ticker", + TickerNode { + symbol: symbol.to_string(), + }, + ) + .text(format!("${symbol}")) + .markdown(cx.node_source(node).unwrap_or(text.value.as_str())), + ) + } + + fn render(&self, node: &MarkdownNode, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let ticker = node + .data::() + .expect("ticker markdown node data"); + let symbol = ticker.symbol.as_str(); + let quote = self.quote(symbol); + let up = quote.change >= 0.0; + let trend = if up { cx.theme().green } else { cx.theme().red }; + + v_flex() + .w(px(240.)) + .gap_1p5() + .px_3() + .py_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .child( + h_flex() + .items_center() + .justify_between() + .child( + v_flex() + .gap_1() + .child( + div() + .text_sm() + .line_height(relative(1.)) + .font_weight(FontWeight::SEMIBOLD) + .child(format!("${symbol}")), + ) + .child( + div() + .text_xs() + .line_height(relative(1.)) + .text_color(cx.theme().muted_foreground) + .child(quote.name), + ), + ) + .child( + h_flex() + .items_center() + .gap_0p5() + .px_1() + .py_0p5() + .rounded(cx.theme().radius) + .bg(trend.opacity(0.12)) + .text_xs() + .line_height(relative(1.)) + .text_color(trend) + .child( + Icon::new(if up { + IconName::ArrowUp + } else { + IconName::ArrowDown + }) + .xsmall(), + ) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .child(format!("{:+.1}%", quote.change)), + ), + ), + ) + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_lg() + .line_height(relative(1.)) + .font_weight(FontWeight::SEMIBOLD) + .child(format!("{:.2}", quote.price)), + ) + .child( + div() + .text_xs() + .line_height(relative(1.)) + .text_color(cx.theme().muted_foreground) + .child("Last"), + ), + ) + } +} + +impl MarkdownPlugin for UserCardPlugin { + fn is_block(&self) -> bool { + true + } + + fn name(&self) -> &str { + "user-card" + } + + fn parse( + &self, + node: &markdown_ast::Node, + cx: &MarkdownParseContext<'_>, + ) -> Option { + match node { + markdown_ast::Node::MdxJsxFlowElement(element) + if element.name.as_deref() == Some("UserCard") => + { + let id = mdx_attr(&element.attributes, "id")?; + Some( + MarkdownNode::new("user-card", UserCardNode { id: id.clone() }) + .text(id) + .markdown(cx.node_source(node).unwrap_or_default()), + ) + } + markdown_ast::Node::Html(raw) if html_tag_name(&raw.value) == Some("UserCard") => { + let id = html_attr(&raw.value, "id")?; + Some( + MarkdownNode::new("user-card", UserCardNode { id: id.clone() }) + .text(id) + .markdown(cx.node_source(node).unwrap_or(raw.value.as_str())), + ) + } + _ => None, + } + } + + fn render(&self, node: &MarkdownNode, window: &mut Window, cx: &mut App) -> impl IntoElement { + let user = node + .data::() + .expect("user-card markdown node data"); + let id = user.id.as_str(); + let (name, avatar) = match id { + "huacnlee" => ( + "Jason Lee", + "https://avatars.githubusercontent.com/u/5518?v=4", + ), + "madcodelife" => ( + "Floyd Wang", + "https://avatars.githubusercontent.com/u/28998859?v=4", + ), + _ => ("Unknown", ""), + }; + + let following = window.use_keyed_state( + SharedString::from(format!("user-card-follow-{id}")), + cx, + |_, _| false, + ); + let is_following = *following.read(cx); + + h_flex() + .w(px(300.)) + .items_center() + .gap_3() + .px_3() + .py_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .child( + Avatar::new() + .name(name) + .with_size(px(24.)) + .when(!avatar.is_empty(), |this| this.src(avatar)), + ) + .child( + div() + .flex_1() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(name), + ) + .child( + Button::new(SharedString::from(format!("follow-{id}"))) + .outline() + .small() + .label(if is_following { "Following" } else { "Follow" }) + .on_click(move |_, _, cx| { + following.update(cx, |v, cx| { + *v = !*v; + cx.notify(); + }); + }), + ) + } +} + +const MATHJAX_NODE_SCRIPT: &str = r#" +const path = require("path"); +const root = process.env.GPUI_MATHJAX_ROOT; +const source = process.env.GPUI_MATH_SOURCE || ""; +const display = process.env.GPUI_MATH_DISPLAY === "1"; +const req = (file) => require(path.join(root, file)); + +const {mathjax} = req("js/mathjax.js"); +const {TeX} = req("js/input/tex.js"); +const {SVG} = req("js/output/svg.js"); +const {liteAdaptor} = req("js/adaptors/liteAdaptor.js"); +const {RegisterHTMLHandler} = req("js/handlers/html.js"); + +const adaptor = liteAdaptor(); +RegisterHTMLHandler(adaptor); + +const tex = new TeX({packages: ["base", "ams"]}); +const svg = new SVG({fontCache: "none"}); +const html = mathjax.document("", {InputJax: tex, OutputJax: svg}); +const node = html.convert(source, {display}); +const outer = adaptor.outerHTML(node); +const match = outer.match(//); + +if (!match) { + process.exit(2); +} + +process.stdout.write(match[0]); +"#; + +fn render_math_image( + source: &str, + inline: bool, + font_size: f32, + foreground: Hsla, + background: Hsla, +) -> Option { + static CACHE: OnceLock>>> = OnceLock::new(); + + let (foreground_fill, foreground_opacity) = svg_color(foreground); + let (background_fill, background_opacity) = svg_color(background); + let cache_key = format!( + "{inline}\0{font_size:.2}\0{foreground_fill}\0{foreground_opacity:.3}\0{background_fill}\0{background_opacity:.3}\0{source}" + ); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if !inline + && let Ok(cache) = cache.lock() + && let Some(image) = cache.get(&cache_key) + { + return image.clone(); + } + + let image = render_math_svg(source, inline, font_size, foreground, background).map(|svg| { + let width = svg_attr(&svg, "width") + .and_then(|width| width.parse().ok()) + .unwrap_or(1.0); + let height = svg_attr(&svg, "height") + .and_then(|height| height.parse().ok()) + .unwrap_or(1.0); + + RenderedMathImage { + width, + height, + baseline: svg_baseline(&svg, height).unwrap_or(height), + image: Arc::new(Image::from_bytes(ImageFormat::Svg, svg.into_bytes())), + } + }); + + if !inline && let Ok(mut cache) = cache.lock() { + cache.insert(cache_key, image.clone()); + } + + image +} + +fn render_math_svg( + source: &str, + inline: bool, + font_size: f32, + foreground: Hsla, + background: Hsla, +) -> Option { + let root = mathjax_root()?; + let output = Command::new("node") + .arg("-e") + .arg(MATHJAX_NODE_SCRIPT) + .env("GPUI_MATHJAX_ROOT", root) + .env("GPUI_MATH_SOURCE", source) + .env("GPUI_MATH_DISPLAY", if inline { "0" } else { "1" }) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let mut svg = String::from_utf8(output.stdout).ok()?; + let width = svg_dimension(&svg, "width")?; + let height = svg_dimension(&svg, "height")?; + let font_size = if inline { + font_size.max(10.0) + } else { + (font_size * 1.18).max(12.0) + }; + let ex = font_size * 0.5; + let width = (width * ex).ceil().max(1.0); + let height = (height * ex).ceil().max(1.0); + let (foreground_fill, foreground_opacity) = svg_color(foreground); + let (background_fill, background_opacity) = svg_color(background); + + svg = replace_svg_attr(&svg, "width", &format!("{width:.1}")); + svg = replace_svg_attr(&svg, "height", &format!("{height:.1}")); + svg = remove_svg_attr(&svg, "style"); + svg = rewrite_rects_as_paths(&svg); + svg = svg.replace("currentColor", &foreground_fill); + if !inline { + svg = inject_svg_background(&svg, &background_fill, background_opacity); + } + if foreground_opacity < 0.999 { + svg = svg.replacen( + " AnyElement { + let font_size = if inline { + font_size.max(10.0) + } else { + (font_size * 1.18).max(12.0) + }; + + div() + .flex_none() + .line_height(relative(if inline { 1.0 } else { 1.2 })) + .text_size(px(font_size)) + .text_color(color) + .italic() + .child(prettify_math_source(source)) + .into_any_element() +} + +fn mathjax_root() -> Option { + if let Some(root) = std::env::var_os("GPUI_MATHJAX_ROOT") { + let root = PathBuf::from(root); + return root.join("js/mathjax.js").is_file().then_some(root); + } + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + [ + manifest_dir.join("../../docs/node_modules/mathjax-full"), + PathBuf::from("docs/node_modules/mathjax-full"), + ] + .into_iter() + .find(|path| path.join("js/mathjax.js").is_file()) +} + +fn svg_dimension(svg: &str, name: &str) -> Option { + svg_attr(svg, name)?.strip_suffix("ex")?.parse().ok() +} + +fn svg_attr<'a>(svg: &'a str, name: &str) -> Option<&'a str> { + let pattern = format!(r#"{name}=""#); + let start = svg.find(&pattern)? + pattern.len(); + let end = svg[start..].find('"')?; + Some(&svg[start..start + end]) +} + +fn inject_svg_background(svg: &str, fill: &str, opacity: f32) -> String { + let Some(open_end) = svg.find('>') else { + return svg.to_string(); + }; + let opacity_attr = if opacity < 0.999 { + format!(r#" opacity="{opacity:.3}""#) + } else { + String::new() + }; + let background = if let Some((x, y, width, height)) = svg_view_box(svg) { + format!( + r#""# + ) + } else { + format!( + r#""# + ) + }; + + let mut out = String::with_capacity(svg.len() + background.len()); + out.push_str(&svg[..open_end + 1]); + out.push_str(&background); + out.push_str(&svg[open_end + 1..]); + out +} + +// MathJax places the alphabetic baseline at y=0 in its SVG viewBox. +fn svg_baseline(svg: &str, pixel_height: f32) -> Option { + let (_, y, _, height) = svg_view_box(svg)?; + if !y.is_finite() || !height.is_finite() || height <= 0.0 { + return None; + } + Some((-y / height * pixel_height).clamp(0.0, pixel_height)) +} + +fn svg_view_box(svg: &str) -> Option<(f32, f32, f32, f32)> { + let values = svg_attr(svg, "viewBox")? + .split(|ch: char| ch == ',' || ch.is_ascii_whitespace()) + .filter(|part| !part.is_empty()) + .map(str::parse::) + .collect::, _>>() + .ok()?; + let [x, y, width, height] = values.as_slice() else { + return None; + }; + Some((*x, *y, *width, *height)) +} + +fn replace_svg_attr(svg: &str, name: &str, value: &str) -> String { + let pattern = format!(r#"{name}=""#); + let Some(start) = svg.find(&pattern).map(|start| start + pattern.len()) else { + return svg.to_string(); + }; + let Some(end) = svg[start..].find('"') else { + return svg.to_string(); + }; + + let mut out = String::with_capacity(svg.len() + value.len()); + out.push_str(&svg[..start]); + out.push_str(value); + out.push_str(&svg[start + end..]); + out +} + +fn remove_svg_attr(svg: &str, name: &str) -> String { + let pattern = format!(r#" {name}=""#); + let Some(start) = svg.find(&pattern) else { + return svg.to_string(); + }; + let Some(end) = svg[start + pattern.len()..].find('"') else { + return svg.to_string(); + }; + + let mut out = String::with_capacity(svg.len()); + out.push_str(&svg[..start]); + out.push_str(&svg[start + pattern.len() + end + 1..]); + out +} + +fn rewrite_rects_as_paths(svg: &str) -> String { + static RECT_RE: OnceLock = OnceLock::new(); + let rect_re = + RECT_RE.get_or_init(|| Regex::new(r#"]*)>(?:)?"#).expect("rect regex")); + + rect_re + .replace_all(svg, |captures: &Captures<'_>| { + let attrs = captures.get(1).map(|m| m.as_str()).unwrap_or_default(); + rect_path(attrs).unwrap_or_else(|| captures[0].to_string()) + }) + .into_owned() +} + +fn rect_path(attrs: &str) -> Option { + let width = svg_attr(attrs, "width")?.parse::().ok()?; + let height = svg_attr(attrs, "height")?.parse::().ok()?; + let x = svg_attr(attrs, "x") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0); + let y = svg_attr(attrs, "y") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0); + let right = x + width; + let bottom = y + height; + + Some(format!( + r#""# + )) +} + +fn prettify_math_source(source: &str) -> String { + let mut out = source.split_whitespace().collect::>().join(" "); + let replacements = [ + (r"\alpha", "\u{03b1}"), + (r"\beta", "\u{03b2}"), + (r"\gamma", "\u{03b3}"), + (r"\delta", "\u{03b4}"), + (r"\pi", "\u{03c0}"), + (r"\sum", "\u{2211}"), + (r"\sqrt", "\u{221a}"), + (r"\times", "\u{00d7}"), + (r"\cdot", "\u{22c5}"), + (r"\leq", "\u{2264}"), + (r"\geq", "\u{2265}"), + (r"\neq", "\u{2260}"), + (r"\infty", "\u{221e}"), + (r"\left", ""), + (r"\right", ""), + ]; + + for (from, to) in replacements { + out = out.replace(from, to); + } + + compact_math_scripts(&out) +} + +fn compact_math_scripts(source: &str) -> String { + let chars = source.chars().collect::>(); + let mut out = String::new(); + let mut ix = 0; + + while ix < chars.len() { + match chars[ix] { + '^' | '_' => { + let superscript = chars[ix] == '^'; + let (script, next_ix) = take_script(&chars, ix + 1); + if script.is_empty() { + out.push(chars[ix]); + ix += 1; + continue; + } + + for ch in script.chars() { + out.push(script_char(ch, superscript)); + } + ix = next_ix; + } + ch => { + out.push(ch); + ix += 1; + } + } + } + + out +} + +fn take_script(chars: &[char], start_ix: usize) -> (String, usize) { + let Some(first) = chars.get(start_ix).copied() else { + return (String::new(), start_ix); + }; + + if first != '{' { + return (first.to_string(), start_ix + 1); + } + + let mut depth = 1; + let mut ix = start_ix + 1; + let mut script = String::new(); + while let Some(ch) = chars.get(ix).copied() { + match ch { + '{' => { + depth += 1; + script.push(ch); + } + '}' => { + depth -= 1; + if depth == 0 { + return (script, ix + 1); + } + script.push(ch); + } + _ => script.push(ch), + } + ix += 1; + } + + (script, ix) +} + +fn script_char(ch: char, superscript: bool) -> char { + if superscript { + match ch { + '0' => '\u{2070}', + '1' => '\u{00b9}', + '2' => '\u{00b2}', + '3' => '\u{00b3}', + '4' => '\u{2074}', + '5' => '\u{2075}', + '6' => '\u{2076}', + '7' => '\u{2077}', + '8' => '\u{2078}', + '9' => '\u{2079}', + '+' => '\u{207a}', + '-' => '\u{207b}', + '=' => '\u{207c}', + '(' => '\u{207d}', + ')' => '\u{207e}', + 'i' => '\u{2071}', + 'n' => '\u{207f}', + _ => ch, + } + } else { + match ch { + '0' => '\u{2080}', + '1' => '\u{2081}', + '2' => '\u{2082}', + '3' => '\u{2083}', + '4' => '\u{2084}', + '5' => '\u{2085}', + '6' => '\u{2086}', + '7' => '\u{2087}', + '8' => '\u{2088}', + '9' => '\u{2089}', + '+' => '\u{208a}', + '-' => '\u{208b}', + '=' => '\u{208c}', + '(' => '\u{208d}', + ')' => '\u{208e}', + 'a' => '\u{2090}', + 'e' => '\u{2091}', + 'h' => '\u{2095}', + 'i' => '\u{1d62}', + 'j' => '\u{2c7c}', + 'k' => '\u{2096}', + 'l' => '\u{2097}', + 'm' => '\u{2098}', + 'n' => '\u{2099}', + 'o' => '\u{2092}', + 'p' => '\u{209a}', + 'r' => '\u{1d63}', + 's' => '\u{209b}', + 't' => '\u{209c}', + 'u' => '\u{1d64}', + 'v' => '\u{1d65}', + 'x' => '\u{2093}', + _ => ch, + } + } +} + +fn svg_color(color: Hsla) -> (String, f32) { + let rgba: Rgba = color.into(); + let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8; + ( + format!( + "#{:02x}{:02x}{:02x}", + channel(rgba.r), + channel(rgba.g), + channel(rgba.b) + ), + rgba.a.clamp(0.0, 1.0), + ) +} diff --git a/examples/markdown-editor/Cargo.toml b/examples/markdown-editor/Cargo.toml new file mode 100644 index 0000000000..4b1fc6677e --- /dev/null +++ b/examples/markdown-editor/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "example-markdown-editor" +version = "0.6.1" +edition.workspace = true +publish = false + +[dependencies] +gpui-kit.workspace = true +regex = "1" +reqwest_client.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +gpui-kit = { workspace = true, features = ["test-support"] } diff --git a/examples/markdown-editor/src/main.rs b/examples/markdown-editor/src/main.rs new file mode 100644 index 0000000000..f61375867e --- /dev/null +++ b/examples/markdown-editor/src/main.rs @@ -0,0 +1,303 @@ +use gpui_kit::component::{ + ActiveTheme, Disableable, Icon, IconName, Root, Sizable, + avatar::Avatar, + button::Button, + h_flex, + text::{ + InlineElement, InlineRenderContext, MarkdownEditor, MarkdownEditorEvent, + MarkdownEditorState, MarkdownExtensions, MarkdownNode, MarkdownParseContext, + MarkdownPlugin, TextView, markdown_ast, + }, + v_flex, +}; +use gpui_kit::{prelude::FluentBuilder, *}; + +use regex::{Captures, Regex}; +use std::{ + collections::HashMap, + path::PathBuf, + process::Command, + sync::{Arc, Mutex, OnceLock}, +}; +#[path = "../../markdown/src/mention.rs"] +mod mention; +include!("../../fixtures/markdown_plugins.rs"); + +const SAMPLE: &str = include_str!("../../fixtures/test.md"); + +struct Example { + editor: Entity, + source: String, + show_source: bool, + inline_math: InlineMathPlugin, + _subscription: Subscription, +} + +impl Example { + fn new(cx: &mut Context) -> Self { + let mut math = None; + let editor = cx.new(|cx| { + let owner = cx.entity().downgrade(); + let inline_math = InlineMathPlugin::new(move |cx| { + let _ = owner.update(cx, |_, cx| cx.notify()); + }); + let extensions = MarkdownExtensions::default() + .plugin(inline_math.clone()) + .plugin(mention::MentionPlugin) + .plugin(TickerPlugin::new( + TickerQuote { + name: "Apple Inc.", + price: 300.21, + change: 5.2, + }, + TickerQuote { + name: "Tesla, Inc.", + price: 412.05, + change: -2.13, + }, + )) + .plugin(UserCardPlugin::new()) + .plugin(MathPlugin::new()); + math = Some(inline_math); + MarkdownEditorState::new_with_extensions(SAMPLE, extensions, cx) + .expect("valid Markdown") + }); + let subscription = cx.subscribe(&editor, |this, editor, _: &MarkdownEditorEvent, cx| { + this.source = editor.read(cx).source(); + cx.notify(); + }); + Self { + editor, + source: SAMPLE.into(), + show_source: false, + inline_math: math.unwrap(), + _subscription: subscription, + } + } +} + +impl Render for Example { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let readonly = self.editor.read(cx).is_readonly(); + self.inline_math.set_document(&self.source); + div() + .flex() + .flex_col() + .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child( + div() + .flex() + .items_center() + .gap_2() + .p_3() + .border_b_1() + .border_color(cx.theme().border) + .child( + Button::new("paragraph") + .disabled(readonly) + .label("正文") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.editor.update(cx, |state, cx| { + state.set_heading(0, cx); + state.focus(window, cx); + }) + })), + ) + .child( + Button::new("heading") + .disabled(readonly) + .label("标题") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.editor.update(cx, |state, cx| { + state.set_heading(2, cx); + state.focus(window, cx); + }) + })), + ) + .child( + Button::new("bold") + .disabled(readonly) + .label("粗体") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.editor.update(cx, |state, cx| { + state.toggle_bold(cx); + state.focus(window, cx); + }) + })), + ) + .child( + Button::new("italic") + .disabled(readonly) + .label("斜体") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.editor.update(cx, |state, cx| { + state.toggle_italic(cx); + state.focus(window, cx); + }) + })), + ) + .child( + Button::new("list") + .disabled(readonly) + .label("列表") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.editor.update(cx, |state, cx| { + state.toggle_list(cx); + state.focus(window, cx); + }) + })), + ) + .child( + Button::new("undo") + .disabled(readonly) + .label("撤销") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.editor.update(cx, |state, cx| { + state.undo(cx); + state.focus(window, cx); + }) + })), + ) + .child( + Button::new("redo") + .disabled(readonly) + .label("重做") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.editor.update(cx, |state, cx| { + state.redo(cx); + state.focus(window, cx); + }) + })), + ) + .child( + Button::new("mode") + .label(if readonly { + "切换为编辑" + } else { + "切换为只读" + }) + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.editor.update(cx, |state, cx| { + state.set_readonly(!state.is_readonly(), cx); + state.focus(window, cx); + }); + cx.notify(); + })), + ) + .child( + Button::new("source") + .label("Markdown") + .small() + .on_click(cx.listener(|this, _, _, cx| { + this.show_source = !this.show_source; + cx.notify(); + })), + ), + ) + .child( + div() + .flex() + .items_stretch() + .flex_1() + .min_h_0() + .child( + div() + .flex_1() + .min_w_0() + .p_6() + .child(MarkdownEditor::new(&self.editor)), + ) + .when(self.show_source, |this| { + this.child( + div() + .w_96() + .border_l_1() + .border_color(cx.theme().border) + .p_4() + .child( + TextView::markdown( + "source", + format!("````markdown\n{}\n````", self.source), + ) + .scrollable(true), + ), + ) + }), + ) + } +} + +fn main() { + gpui_kit::application() + .with_assets(gpui_kit::assets::Assets) + .run(|cx| { + gpui_kit::init(cx); + // Load remote images with the same client as the original demo. + let http_client = + reqwest_client::ReqwestClient::user_agent("gpui-component/markdown-editor") + .unwrap(); + cx.set_http_client(Arc::new(http_client)); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::centered(size(px(1100.), px(760.)), cx)), + ..Default::default() + }, + |window, cx| { + window.set_window_title("Markdown Editor"); + let view = cx.new(Example::new); + cx.new(|cx| Root::new(view, window, cx)) + }, + ) + .expect("open Markdown editor"); + cx.activate(true); + }); +} + +#[cfg(test)] +mod tests { + use super::Example; + use gpui_kit as gpui; + + #[gpui::test] + fn shared_markdown_fixture_renders_with_original_plugins(cx: &mut gpui::TestAppContext) { + use gpui::VisualTestContext; + cx.update(gpui_kit::init); + let (view, cx) = cx.add_window_view(|_, cx| Example::new(cx)); + VisualTestContext::update(cx, |window, cx| { + window.draw(cx).clear(cx); + let editor = view.read(cx).editor.clone(); + let before = editor.read(cx).source(); + for expected in [ + "Hello", + "AAPL.US", + "TSLA.US", + "UserCard", + "mention:huacnlee", + "e^{i", + "```rust", + "Numbered item", + ] { + assert!( + before.contains(expected), + "fixture content missing: {expected}" + ); + } + editor.update(cx, |state, cx| state.set_readonly(true, cx)); + window.draw(cx).clear(cx); + assert_eq!(editor.read(cx).source(), before); + editor.update(cx, |state, cx| state.set_readonly(false, cx)); + window.draw(cx).clear(cx); + assert_eq!(editor.read(cx).source(), before); + }); + } +} diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index 573dfbde4f..81b6f3b4b8 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -45,1010 +45,7 @@ const MARKERS: &[(&str, &str)] = &[ ("NOTE", "type"), ]; -#[derive(Clone)] -struct TickerNode { - symbol: String, -} - -#[derive(Clone)] -struct UserCardNode { - id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct MathNode { - source: String, - inline: bool, -} - -#[derive(Clone, Copy)] -struct TickerQuote { - name: &'static str, - price: f64, - change: f64, -} - -#[derive(Clone)] -struct TickerPlugin { - apple_quote: TickerQuote, - tesla_quote: TickerQuote, -} - -impl TickerPlugin { - fn new(apple_quote: TickerQuote, tesla_quote: TickerQuote) -> Self { - Self { - apple_quote, - tesla_quote, - } - } - - fn quote(&self, symbol: &str) -> TickerQuote { - match symbol { - "AAPL.US" => self.apple_quote, - "TSLA.US" => self.tesla_quote, - _ => TickerQuote { - name: "Unknown", - price: 0.0, - change: 0.0, - }, - } - } -} - -#[derive(Clone)] -struct UserCardPlugin; - -#[derive(Clone)] -struct MathPlugin; - -#[derive(Clone)] -struct RenderedMathImage { - image: Arc, - width: f32, - height: f32, - baseline: f32, -} - -impl MathPlugin { - fn new() -> Self { - Self - } -} - -impl UserCardPlugin { - fn new() -> Self { - Self - } -} - -fn mdx_attr(attrs: &[markdown_ast::AttributeContent], name: &str) -> Option { - attrs.iter().find_map(|attr| match attr { - markdown_ast::AttributeContent::Property(prop) if prop.name == name => { - match prop.value.as_ref() { - Some(markdown_ast::AttributeValue::Literal(value)) => Some(value.clone()), - _ => None, - } - } - _ => None, - }) -} - -fn html_tag_name(value: &str) -> Option<&str> { - value - .trim() - .strip_prefix('<')? - .split([' ', '/', '>']) - .next() -} - -fn html_attr(value: &str, name: &str) -> Option { - let pattern = format!("{name}=\""); - let start = value.find(&pattern)? + pattern.len(); - let end = value[start..].find('"')?; - Some(value[start..start + end].to_string()) -} - -fn ticker_symbol(value: &str) -> Option<&str> { - let symbol = value.strip_prefix('$')?; - if symbol.is_empty() - || !symbol.contains('.') - || !symbol - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '.') - { - return None; - } - Some(symbol) -} - -fn math_markdown(source: &str, inline: bool) -> String { - if inline { - format!("${source}$") - } else { - format!("$$\n{source}\n$$") - } -} - -fn math_node(source: String, inline: bool, markdown: impl Into) -> MarkdownNode { - MarkdownNode::new( - if inline { "inline-math" } else { "math" }, - MathNode { - source: source.clone(), - inline, - }, - ) - .text(prettify_math_source(&source)) - .accessibility_label(format!("Formula: {}", prettify_math_source(&source))) - .markdown(markdown.into()) -} - -fn block_math_source(source: &str) -> Option<&str> { - let source = source.trim(); - let body = source.strip_prefix("$$")?.strip_suffix("$$")?.trim(); - (!body.is_empty()).then_some(body) -} - -impl MarkdownPlugin for MathPlugin { - fn is_block(&self) -> bool { - true - } - - fn name(&self) -> &str { - "math" - } - - fn parse( - &self, - node: &markdown_ast::Node, - cx: &MarkdownParseContext<'_>, - ) -> Option { - if let markdown_ast::Node::Math(math) = node { - return Some(math_node( - math.value.clone(), - false, - cx.node_source(node) - .map(str::to_string) - .unwrap_or_else(|| math_markdown(&math.value, false)), - )); - } - - let markdown_ast::Node::Paragraph(_) = node else { - return None; - }; - let source = cx.node_source(node)?; - - if let Some(math) = block_math_source(source) { - return Some(math_node(math.to_string(), false, source)); - } - - None - } - - fn render(&self, node: &MarkdownNode, window: &mut Window, cx: &mut App) -> impl IntoElement { - let math = node.data::().expect("math markdown node data"); - let font_size = f32::from(window.text_style().font_size.to_pixels(window.rem_size())); - - div() - .w_full() - .flex() - .justify_center() - .py_1() - .child(render_math_formula(&math.source, false, font_size, cx)) - } -} - -/// Prepared resources belong to this example, not a process-global inline cache. -#[derive(Default)] -struct InlineMathCache { - document: String, - images: HashMap>, -} - -impl InlineMathCache { - fn set_document(&mut self, source: &str) { - if self.document != source { - self.document = source.to_string(); - // Keep prepared and pending resources for formulas still in the document. - self.images - .retain(|key, _| source.contains(key.split('\0').next().unwrap_or_default())); - } - } -} - -#[derive(Clone)] -struct InlineMathPlugin { - cache: Arc>, - view: WeakEntity, -} - -impl InlineMathPlugin { - fn new(view: &Entity) -> Self { - Self { - cache: Arc::default(), - view: view.downgrade(), - } - } - - fn set_document(&self, source: &str) { - self.cache.lock().unwrap().set_document(source); - } -} - -fn parse_inline_math(node: &markdown_ast::Node, source: Option<&str>) -> Option { - let markdown_ast::Node::InlineMath(math) = node else { - return None; - }; - Some(math_node( - math.value.clone(), - true, - source - .map(str::to_string) - .unwrap_or_else(|| math_markdown(&math.value, true)), - )) -} - -impl MarkdownPlugin for InlineMathPlugin { - fn name(&self) -> &str { - "inline-math" - } - - fn parse( - &self, - node: &markdown_ast::Node, - context: &MarkdownParseContext<'_>, - ) -> Option { - parse_inline_math(node, context.node_source(node)) - } - - fn render_inline( - &self, - node: &MarkdownNode, - context: &InlineRenderContext, - _window: &mut Window, - cx: &mut App, - ) -> Option { - let source = node.data::()?.source.clone(); - let font_size = f32::from(context.font_size()); - let foreground = context.text_style().color; - let background = cx.theme().background; - let key = format!("{source}\0{font_size:?}\0{foreground:?}\0{background:?}"); - let mut cache = self.cache.lock().unwrap(); - if let Some(image) = cache.images.get(&key) { - return image.as_ref().map(|image| { - let fallback_source = source.clone(); - let fallback = - move || render_math_text(&fallback_source, true, font_size, foreground); - InlineElement::new( - img(image.image.clone()) - .object_fit(ObjectFit::Contain) - .w(px(image.width)) - .h(px(image.height)) - .with_loading(fallback.clone()) - .with_fallback(fallback), - ) - .with_baseline(px(image.baseline)) - }); - } - // None represents pending or unavailable; both use TextView's atomic text fallback. - cache.images.insert(key.clone(), None); - drop(cache); - let cache = Arc::downgrade(&self.cache); - let view = self.view.clone(); - // The callback only reads prepared images and enqueues at most one request per - // exact source/font/theme key. Deferring is necessary to capture the real - // font size (including headings), without running MathJax during layout. - // The pending entry above deduplicates repeated measurement callbacks. - cx.defer(move |cx| { - if view.upgrade().is_none() - || cache - .upgrade() - .is_none_or(|cache| !cache.lock().unwrap().images.contains_key(&key)) - { - return; - } - let task = cx.background_executor().spawn(async move { - render_math_image(&source, true, font_size, foreground, background) - }); - cx.spawn(async move |cx| { - let image = task.await; - let Some(cache) = cache.upgrade() else { return }; - let mut cache = cache.lock().unwrap(); - if !cache.images.contains_key(&key) { - return; - } - cache.images.insert(key, image); - drop(cache); - let _ = view.update(cx, |view, cx| view.invalidate_inline_layout(cx)); - }) - .detach(); - }); - None - } -} - -fn render_math_formula(source: &str, inline: bool, font_size: f32, cx: &mut App) -> AnyElement { - if let Some(image) = render_math_image( - source, - inline, - font_size, - cx.theme().foreground, - cx.theme().background, - ) { - img(image.image) - .object_fit(ObjectFit::Contain) - .flex_shrink_0() - .w(px(image.width)) - .h(px(image.height)) - .into_any_element() - } else { - render_math_text(source, inline, font_size, cx.theme().foreground) - } -} - -impl MarkdownPlugin for TickerPlugin { - fn is_block(&self) -> bool { - true - } - - fn name(&self) -> &str { - "ticker" - } - - fn parse( - &self, - node: &markdown_ast::Node, - cx: &MarkdownParseContext<'_>, - ) -> Option { - let markdown_ast::Node::Paragraph(paragraph) = node else { - return None; - }; - let [markdown_ast::Node::Text(text)] = paragraph.children.as_slice() else { - return None; - }; - let symbol = ticker_symbol(&text.value)?; - Some( - MarkdownNode::new( - "ticker", - TickerNode { - symbol: symbol.to_string(), - }, - ) - .text(format!("${symbol}")) - .markdown(cx.node_source(node).unwrap_or(text.value.as_str())), - ) - } - - fn render(&self, node: &MarkdownNode, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let ticker = node - .data::() - .expect("ticker markdown node data"); - let symbol = ticker.symbol.as_str(); - let quote = self.quote(symbol); - let up = quote.change >= 0.0; - let trend = if up { cx.theme().green } else { cx.theme().red }; - - v_flex() - .w(px(240.)) - .gap_1p5() - .px_3() - .py_2() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .child( - h_flex() - .items_center() - .justify_between() - .child( - v_flex() - .gap_1() - .child( - div() - .text_sm() - .line_height(relative(1.)) - .font_weight(FontWeight::SEMIBOLD) - .child(format!("${symbol}")), - ) - .child( - div() - .text_xs() - .line_height(relative(1.)) - .text_color(cx.theme().muted_foreground) - .child(quote.name), - ), - ) - .child( - h_flex() - .items_center() - .gap_0p5() - .px_1() - .py_0p5() - .rounded(cx.theme().radius) - .bg(trend.opacity(0.12)) - .text_xs() - .line_height(relative(1.)) - .text_color(trend) - .child( - Icon::new(if up { - IconName::ArrowUp - } else { - IconName::ArrowDown - }) - .xsmall(), - ) - .child( - div() - .font_weight(FontWeight::MEDIUM) - .child(format!("{:+.1}%", quote.change)), - ), - ), - ) - .child( - h_flex() - .items_center() - .justify_between() - .child( - div() - .text_lg() - .line_height(relative(1.)) - .font_weight(FontWeight::SEMIBOLD) - .child(format!("{:.2}", quote.price)), - ) - .child( - div() - .text_xs() - .line_height(relative(1.)) - .text_color(cx.theme().muted_foreground) - .child("Last"), - ), - ) - } -} - -impl MarkdownPlugin for UserCardPlugin { - fn is_block(&self) -> bool { - true - } - - fn name(&self) -> &str { - "user-card" - } - - fn parse( - &self, - node: &markdown_ast::Node, - cx: &MarkdownParseContext<'_>, - ) -> Option { - match node { - markdown_ast::Node::MdxJsxFlowElement(element) - if element.name.as_deref() == Some("UserCard") => - { - let id = mdx_attr(&element.attributes, "id")?; - Some( - MarkdownNode::new("user-card", UserCardNode { id: id.clone() }) - .text(id) - .markdown(cx.node_source(node).unwrap_or_default()), - ) - } - markdown_ast::Node::Html(raw) if html_tag_name(&raw.value) == Some("UserCard") => { - let id = html_attr(&raw.value, "id")?; - Some( - MarkdownNode::new("user-card", UserCardNode { id: id.clone() }) - .text(id) - .markdown(cx.node_source(node).unwrap_or(raw.value.as_str())), - ) - } - _ => None, - } - } - - fn render(&self, node: &MarkdownNode, window: &mut Window, cx: &mut App) -> impl IntoElement { - let user = node - .data::() - .expect("user-card markdown node data"); - let id = user.id.as_str(); - let (name, avatar) = match id { - "huacnlee" => ( - "Jason Lee", - "https://avatars.githubusercontent.com/u/5518?v=4", - ), - "madcodelife" => ( - "Floyd Wang", - "https://avatars.githubusercontent.com/u/28998859?v=4", - ), - _ => ("Unknown", ""), - }; - - let following = window.use_keyed_state( - SharedString::from(format!("user-card-follow-{id}")), - cx, - |_, _| false, - ); - let is_following = *following.read(cx); - - h_flex() - .w(px(300.)) - .items_center() - .gap_3() - .px_3() - .py_2() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .child( - Avatar::new() - .name(name) - .with_size(px(24.)) - .when(!avatar.is_empty(), |this| this.src(avatar)), - ) - .child( - div() - .flex_1() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .child(name), - ) - .child( - Button::new(SharedString::from(format!("follow-{id}"))) - .outline() - .small() - .label(if is_following { "Following" } else { "Follow" }) - .on_click(move |_, _, cx| { - following.update(cx, |v, cx| { - *v = !*v; - cx.notify(); - }); - }), - ) - } -} - -const MATHJAX_NODE_SCRIPT: &str = r#" -const path = require("path"); -const root = process.env.GPUI_MATHJAX_ROOT; -const source = process.env.GPUI_MATH_SOURCE || ""; -const display = process.env.GPUI_MATH_DISPLAY === "1"; -const req = (file) => require(path.join(root, file)); - -const {mathjax} = req("js/mathjax.js"); -const {TeX} = req("js/input/tex.js"); -const {SVG} = req("js/output/svg.js"); -const {liteAdaptor} = req("js/adaptors/liteAdaptor.js"); -const {RegisterHTMLHandler} = req("js/handlers/html.js"); - -const adaptor = liteAdaptor(); -RegisterHTMLHandler(adaptor); - -const tex = new TeX({packages: ["base", "ams"]}); -const svg = new SVG({fontCache: "none"}); -const html = mathjax.document("", {InputJax: tex, OutputJax: svg}); -const node = html.convert(source, {display}); -const outer = adaptor.outerHTML(node); -const match = outer.match(//); - -if (!match) { - process.exit(2); -} - -process.stdout.write(match[0]); -"#; - -fn render_math_image( - source: &str, - inline: bool, - font_size: f32, - foreground: Hsla, - background: Hsla, -) -> Option { - static CACHE: OnceLock>>> = OnceLock::new(); - - let (foreground_fill, foreground_opacity) = svg_color(foreground); - let (background_fill, background_opacity) = svg_color(background); - let cache_key = format!( - "{inline}\0{font_size:.2}\0{foreground_fill}\0{foreground_opacity:.3}\0{background_fill}\0{background_opacity:.3}\0{source}" - ); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if !inline - && let Ok(cache) = cache.lock() - && let Some(image) = cache.get(&cache_key) - { - return image.clone(); - } - - let image = render_math_svg(source, inline, font_size, foreground, background).map(|svg| { - let width = svg_attr(&svg, "width") - .and_then(|width| width.parse().ok()) - .unwrap_or(1.0); - let height = svg_attr(&svg, "height") - .and_then(|height| height.parse().ok()) - .unwrap_or(1.0); - - RenderedMathImage { - width, - height, - baseline: svg_baseline(&svg, height).unwrap_or(height), - image: Arc::new(Image::from_bytes(ImageFormat::Svg, svg.into_bytes())), - } - }); - - if !inline && let Ok(mut cache) = cache.lock() { - cache.insert(cache_key, image.clone()); - } - - image -} - -fn render_math_svg( - source: &str, - inline: bool, - font_size: f32, - foreground: Hsla, - background: Hsla, -) -> Option { - let root = mathjax_root()?; - let output = Command::new("node") - .arg("-e") - .arg(MATHJAX_NODE_SCRIPT) - .env("GPUI_MATHJAX_ROOT", root) - .env("GPUI_MATH_SOURCE", source) - .env("GPUI_MATH_DISPLAY", if inline { "0" } else { "1" }) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let mut svg = String::from_utf8(output.stdout).ok()?; - let width = svg_dimension(&svg, "width")?; - let height = svg_dimension(&svg, "height")?; - let font_size = if inline { - font_size.max(10.0) - } else { - (font_size * 1.18).max(12.0) - }; - let ex = font_size * 0.5; - let width = (width * ex).ceil().max(1.0); - let height = (height * ex).ceil().max(1.0); - let (foreground_fill, foreground_opacity) = svg_color(foreground); - let (background_fill, background_opacity) = svg_color(background); - - svg = replace_svg_attr(&svg, "width", &format!("{width:.1}")); - svg = replace_svg_attr(&svg, "height", &format!("{height:.1}")); - svg = remove_svg_attr(&svg, "style"); - svg = rewrite_rects_as_paths(&svg); - svg = svg.replace("currentColor", &foreground_fill); - if !inline { - svg = inject_svg_background(&svg, &background_fill, background_opacity); - } - if foreground_opacity < 0.999 { - svg = svg.replacen( - " AnyElement { - let font_size = if inline { - font_size.max(10.0) - } else { - (font_size * 1.18).max(12.0) - }; - - div() - .flex_none() - .line_height(relative(if inline { 1.0 } else { 1.2 })) - .text_size(px(font_size)) - .text_color(color) - .italic() - .child(prettify_math_source(source)) - .into_any_element() -} - -fn mathjax_root() -> Option { - if let Some(root) = std::env::var_os("GPUI_MATHJAX_ROOT") { - let root = PathBuf::from(root); - return root.join("js/mathjax.js").is_file().then_some(root); - } - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - [ - manifest_dir.join("../../docs/node_modules/mathjax-full"), - PathBuf::from("docs/node_modules/mathjax-full"), - ] - .into_iter() - .find(|path| path.join("js/mathjax.js").is_file()) -} - -fn svg_dimension(svg: &str, name: &str) -> Option { - svg_attr(svg, name)?.strip_suffix("ex")?.parse().ok() -} - -fn svg_attr<'a>(svg: &'a str, name: &str) -> Option<&'a str> { - let pattern = format!(r#"{name}=""#); - let start = svg.find(&pattern)? + pattern.len(); - let end = svg[start..].find('"')?; - Some(&svg[start..start + end]) -} - -fn inject_svg_background(svg: &str, fill: &str, opacity: f32) -> String { - let Some(open_end) = svg.find('>') else { - return svg.to_string(); - }; - let opacity_attr = if opacity < 0.999 { - format!(r#" opacity="{opacity:.3}""#) - } else { - String::new() - }; - let background = if let Some((x, y, width, height)) = svg_view_box(svg) { - format!( - r#""# - ) - } else { - format!( - r#""# - ) - }; - - let mut out = String::with_capacity(svg.len() + background.len()); - out.push_str(&svg[..open_end + 1]); - out.push_str(&background); - out.push_str(&svg[open_end + 1..]); - out -} - -// MathJax places the alphabetic baseline at y=0 in its SVG viewBox. -fn svg_baseline(svg: &str, pixel_height: f32) -> Option { - let (_, y, _, height) = svg_view_box(svg)?; - if !y.is_finite() || !height.is_finite() || height <= 0.0 { - return None; - } - Some((-y / height * pixel_height).clamp(0.0, pixel_height)) -} - -fn svg_view_box(svg: &str) -> Option<(f32, f32, f32, f32)> { - let values = svg_attr(svg, "viewBox")? - .split(|ch: char| ch == ',' || ch.is_ascii_whitespace()) - .filter(|part| !part.is_empty()) - .map(str::parse::) - .collect::, _>>() - .ok()?; - let [x, y, width, height] = values.as_slice() else { - return None; - }; - Some((*x, *y, *width, *height)) -} - -fn replace_svg_attr(svg: &str, name: &str, value: &str) -> String { - let pattern = format!(r#"{name}=""#); - let Some(start) = svg.find(&pattern).map(|start| start + pattern.len()) else { - return svg.to_string(); - }; - let Some(end) = svg[start..].find('"') else { - return svg.to_string(); - }; - - let mut out = String::with_capacity(svg.len() + value.len()); - out.push_str(&svg[..start]); - out.push_str(value); - out.push_str(&svg[start + end..]); - out -} - -fn remove_svg_attr(svg: &str, name: &str) -> String { - let pattern = format!(r#" {name}=""#); - let Some(start) = svg.find(&pattern) else { - return svg.to_string(); - }; - let Some(end) = svg[start + pattern.len()..].find('"') else { - return svg.to_string(); - }; - - let mut out = String::with_capacity(svg.len()); - out.push_str(&svg[..start]); - out.push_str(&svg[start + pattern.len() + end + 1..]); - out -} - -fn rewrite_rects_as_paths(svg: &str) -> String { - static RECT_RE: OnceLock = OnceLock::new(); - let rect_re = - RECT_RE.get_or_init(|| Regex::new(r#"]*)>(?:)?"#).expect("rect regex")); - - rect_re - .replace_all(svg, |captures: &Captures<'_>| { - let attrs = captures.get(1).map(|m| m.as_str()).unwrap_or_default(); - rect_path(attrs).unwrap_or_else(|| captures[0].to_string()) - }) - .into_owned() -} - -fn rect_path(attrs: &str) -> Option { - let width = svg_attr(attrs, "width")?.parse::().ok()?; - let height = svg_attr(attrs, "height")?.parse::().ok()?; - let x = svg_attr(attrs, "x") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0.0); - let y = svg_attr(attrs, "y") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0.0); - let right = x + width; - let bottom = y + height; - - Some(format!( - r#""# - )) -} - -fn prettify_math_source(source: &str) -> String { - let mut out = source.split_whitespace().collect::>().join(" "); - let replacements = [ - (r"\alpha", "\u{03b1}"), - (r"\beta", "\u{03b2}"), - (r"\gamma", "\u{03b3}"), - (r"\delta", "\u{03b4}"), - (r"\pi", "\u{03c0}"), - (r"\sum", "\u{2211}"), - (r"\sqrt", "\u{221a}"), - (r"\times", "\u{00d7}"), - (r"\cdot", "\u{22c5}"), - (r"\leq", "\u{2264}"), - (r"\geq", "\u{2265}"), - (r"\neq", "\u{2260}"), - (r"\infty", "\u{221e}"), - (r"\left", ""), - (r"\right", ""), - ]; - - for (from, to) in replacements { - out = out.replace(from, to); - } - - compact_math_scripts(&out) -} - -fn compact_math_scripts(source: &str) -> String { - let chars = source.chars().collect::>(); - let mut out = String::new(); - let mut ix = 0; - - while ix < chars.len() { - match chars[ix] { - '^' | '_' => { - let superscript = chars[ix] == '^'; - let (script, next_ix) = take_script(&chars, ix + 1); - if script.is_empty() { - out.push(chars[ix]); - ix += 1; - continue; - } - - for ch in script.chars() { - out.push(script_char(ch, superscript)); - } - ix = next_ix; - } - ch => { - out.push(ch); - ix += 1; - } - } - } - - out -} - -fn take_script(chars: &[char], start_ix: usize) -> (String, usize) { - let Some(first) = chars.get(start_ix).copied() else { - return (String::new(), start_ix); - }; - - if first != '{' { - return (first.to_string(), start_ix + 1); - } - - let mut depth = 1; - let mut ix = start_ix + 1; - let mut script = String::new(); - while let Some(ch) = chars.get(ix).copied() { - match ch { - '{' => { - depth += 1; - script.push(ch); - } - '}' => { - depth -= 1; - if depth == 0 { - return (script, ix + 1); - } - script.push(ch); - } - _ => script.push(ch), - } - ix += 1; - } - - (script, ix) -} - -fn script_char(ch: char, superscript: bool) -> char { - if superscript { - match ch { - '0' => '\u{2070}', - '1' => '\u{00b9}', - '2' => '\u{00b2}', - '3' => '\u{00b3}', - '4' => '\u{2074}', - '5' => '\u{2075}', - '6' => '\u{2076}', - '7' => '\u{2077}', - '8' => '\u{2078}', - '9' => '\u{2079}', - '+' => '\u{207a}', - '-' => '\u{207b}', - '=' => '\u{207c}', - '(' => '\u{207d}', - ')' => '\u{207e}', - 'i' => '\u{2071}', - 'n' => '\u{207f}', - _ => ch, - } - } else { - match ch { - '0' => '\u{2080}', - '1' => '\u{2081}', - '2' => '\u{2082}', - '3' => '\u{2083}', - '4' => '\u{2084}', - '5' => '\u{2085}', - '6' => '\u{2086}', - '7' => '\u{2087}', - '8' => '\u{2088}', - '9' => '\u{2089}', - '+' => '\u{208a}', - '-' => '\u{208b}', - '=' => '\u{208c}', - '(' => '\u{208d}', - ')' => '\u{208e}', - 'a' => '\u{2090}', - 'e' => '\u{2091}', - 'h' => '\u{2095}', - 'i' => '\u{1d62}', - 'j' => '\u{2c7c}', - 'k' => '\u{2096}', - 'l' => '\u{2097}', - 'm' => '\u{2098}', - 'n' => '\u{2099}', - 'o' => '\u{2092}', - 'p' => '\u{209a}', - 'r' => '\u{1d63}', - 's' => '\u{209b}', - 't' => '\u{209c}', - 'u' => '\u{1d64}', - 'v' => '\u{1d65}', - 'x' => '\u{2093}', - _ => ch, - } - } -} - -fn svg_color(color: Hsla) -> (String, f32) { - let rgba: Rgba = color.into(); - let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8; - ( - format!( - "#{:02x}{:02x}{:02x}", - channel(rgba.r), - channel(rgba.g), - channel(rgba.b) - ), - rgba.a.clamp(0.0, 1.0), - ) -} +include!("../../fixtures/markdown_plugins.rs"); /// Serialize a table to CSV: `,` separated, quoting only cells that contain /// `"`, `,` or a newline, with `"` doubled inside quotes. @@ -1220,7 +217,10 @@ impl Example { vec![cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| cx.notify())]; let text_view = cx.new(|cx| TextViewState::markdown(EXAMPLE, cx)); - let inline_math = InlineMathPlugin::new(&text_view); + let weak_view = text_view.downgrade(); + let inline_math = InlineMathPlugin::new(move |cx| { + let _ = weak_view.update(cx, |view, cx| view.invalidate_inline_layout(cx)); + }); Self { text_view, inline_math,