From 5df1481297758f4a695906f94feacd2603bba439 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 9 May 2024 11:03:33 +0200 Subject: [PATCH] Introduce a new `markdown` crate (#11556) This pull request introduces a new `markdown` crate which is capable of parsing and rendering a Markdown source. One of the key additions is that it enables text selection within a `Markdown` view. Eventually, this will replace `RichText` but for now the goal is to use it in the assistant revamped assistant in the spirit of making progress. image Note that this pull request doesn't yet use the new markdown renderer in `assistant2`. This is because we need to modify the assistant before slotting in the new renderer and I wanted to merge this independently of those changes. Release Notes: - N/A --------- Co-authored-by: Nathan Sobo Co-authored-by: Conrad Co-authored-by: Alp Co-authored-by: Zachiah Sawyer --- Cargo.lock | 21 + Cargo.toml | 2 + .../src/notifications/collab_notification.rs | 2 +- .../src/components/extension_card.rs | 2 +- crates/gpui/src/element.rs | 2 +- crates/gpui/src/elements/anchored.rs | 2 +- crates/gpui/src/elements/div.rs | 4 +- crates/gpui/src/elements/text.rs | 187 ++-- crates/gpui/src/text_system/line_layout.rs | 70 +- crates/markdown/Cargo.toml | 40 + crates/markdown/examples/markdown.rs | 181 ++++ crates/markdown/src/markdown.rs | 902 ++++++++++++++++++ crates/markdown/src/parser.rs | 274 ++++++ crates/story/src/story.rs | 4 +- .../ui/src/components/button/button_like.rs | 2 +- crates/ui/src/components/label/label_like.rs | 2 +- crates/ui/src/components/list/list.rs | 2 +- crates/ui/src/components/list/list_item.rs | 2 +- crates/ui/src/components/modal.rs | 6 +- crates/ui/src/components/popover.rs | 2 +- crates/ui/src/components/tab.rs | 2 +- crates/ui/src/components/tab_bar.rs | 2 +- .../ui/src/components/title_bar/title_bar.rs | 2 +- crates/workspace/src/pane_group.rs | 2 +- 24 files changed, 1629 insertions(+), 88 deletions(-) create mode 100644 crates/markdown/Cargo.toml create mode 100644 crates/markdown/examples/markdown.rs create mode 100644 crates/markdown/src/markdown.rs create mode 100644 crates/markdown/src/parser.rs diff --git a/Cargo.lock b/Cargo.lock index e25fca7aaf..bd0ae52784 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5974,6 +5974,27 @@ dependencies = [ "libc", ] +[[package]] +name = "markdown" +version = "0.1.0" +dependencies = [ + "anyhow", + "assets", + "env_logger", + "futures 0.3.28", + "gpui", + "language", + "languages", + "linkify", + "log", + "node_runtime", + "pulldown-cmark", + "settings", + "theme", + "ui", + "util", +] + [[package]] name = "markdown_preview" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a4134c6771..6768e1132b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ members = [ "crates/live_kit_client", "crates/live_kit_server", "crates/lsp", + "crates/markdown", "crates/markdown_preview", "crates/media", "crates/menu", @@ -192,6 +193,7 @@ languages = { path = "crates/languages" } live_kit_client = { path = "crates/live_kit_client" } live_kit_server = { path = "crates/live_kit_server" } lsp = { path = "crates/lsp" } +markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } media = { path = "crates/media" } menu = { path = "crates/menu" } diff --git a/crates/collab_ui/src/notifications/collab_notification.rs b/crates/collab_ui/src/notifications/collab_notification.rs index 8efe2c5bb5..14dae9cd2c 100644 --- a/crates/collab_ui/src/notifications/collab_notification.rs +++ b/crates/collab_ui/src/notifications/collab_notification.rs @@ -26,7 +26,7 @@ impl CollabNotification { } impl ParentElement for CollabNotification { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/extensions_ui/src/components/extension_card.rs b/crates/extensions_ui/src/components/extension_card.rs index d421fd9b9d..2dc472f801 100644 --- a/crates/extensions_ui/src/components/extension_card.rs +++ b/crates/extensions_ui/src/components/extension_card.rs @@ -23,7 +23,7 @@ impl ExtensionCard { } impl ParentElement for ExtensionCard { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index 427e6d61ef..e573b11d6e 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -140,7 +140,7 @@ pub trait RenderOnce: 'static { /// can accept any number of any kind of child elements pub trait ParentElement { /// Extend this element's children with the given child elements. - fn extend(&mut self, elements: impl Iterator); + fn extend(&mut self, elements: impl IntoIterator); /// Add a single child element to this element. fn child(mut self, child: impl IntoElement) -> Self diff --git a/crates/gpui/src/elements/anchored.rs b/crates/gpui/src/elements/anchored.rs index 27b86849f4..f161521f57 100644 --- a/crates/gpui/src/elements/anchored.rs +++ b/crates/gpui/src/elements/anchored.rs @@ -63,7 +63,7 @@ impl Anchored { } impl ParentElement for Anchored { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index e77d42bafe..41ad33e588 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2337,7 +2337,7 @@ impl ParentElement for Focusable where E: ParentElement, { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.element.extend(elements) } } @@ -2430,7 +2430,7 @@ impl ParentElement for Stateful where E: ParentElement, { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.element.extend(elements) } } diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 0e795712b4..2adb2dbf54 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -17,7 +17,7 @@ use std::{ use util::ResultExt; impl Element for &'static str { - type RequestLayoutState = TextState; + type RequestLayoutState = TextLayout; type PrepaintState = (); fn id(&self) -> Option { @@ -29,7 +29,7 @@ impl Element for &'static str { _id: Option<&GlobalElementId>, cx: &mut WindowContext, ) -> (LayoutId, Self::RequestLayoutState) { - let mut state = TextState::default(); + let mut state = TextLayout::default(); let layout_id = state.layout(SharedString::from(*self), None, cx); (layout_id, state) } @@ -37,21 +37,22 @@ impl Element for &'static str { fn prepaint( &mut self, _id: Option<&GlobalElementId>, - _bounds: Bounds, - _text_state: &mut Self::RequestLayoutState, + bounds: Bounds, + text_layout: &mut Self::RequestLayoutState, _cx: &mut WindowContext, ) { + text_layout.prepaint(bounds, self) } fn paint( &mut self, _id: Option<&GlobalElementId>, - bounds: Bounds, - text_state: &mut TextState, + _bounds: Bounds, + text_layout: &mut TextLayout, _: &mut (), cx: &mut WindowContext, ) { - text_state.paint(bounds, self, cx) + text_layout.paint(self, cx) } } @@ -72,7 +73,7 @@ impl IntoElement for String { } impl Element for SharedString { - type RequestLayoutState = TextState; + type RequestLayoutState = TextLayout; type PrepaintState = (); fn id(&self) -> Option { @@ -86,7 +87,7 @@ impl Element for SharedString { cx: &mut WindowContext, ) -> (LayoutId, Self::RequestLayoutState) { - let mut state = TextState::default(); + let mut state = TextLayout::default(); let layout_id = state.layout(self.clone(), None, cx); (layout_id, state) } @@ -94,22 +95,22 @@ impl Element for SharedString { fn prepaint( &mut self, _id: Option<&GlobalElementId>, - _bounds: Bounds, - _text_state: &mut Self::RequestLayoutState, + bounds: Bounds, + text_layout: &mut Self::RequestLayoutState, _cx: &mut WindowContext, ) { + text_layout.prepaint(bounds, self.as_ref()) } fn paint( &mut self, _id: Option<&GlobalElementId>, - bounds: Bounds, - text_state: &mut Self::RequestLayoutState, + _bounds: Bounds, + text_layout: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, cx: &mut WindowContext, ) { - let text_str: &str = self.as_ref(); - text_state.paint(bounds, text_str, cx) + text_layout.paint(self.as_ref(), cx) } } @@ -129,6 +130,7 @@ impl IntoElement for SharedString { pub struct StyledText { text: SharedString, runs: Option>, + layout: TextLayout, } impl StyledText { @@ -137,9 +139,15 @@ impl StyledText { StyledText { text: text.into(), runs: None, + layout: TextLayout::default(), } } + /// todo!() + pub fn layout(&self) -> &TextLayout { + &self.layout + } + /// Set the styling attributes for the given text, as well as /// as any ranges of text that have had their style customized. pub fn with_highlights( @@ -167,10 +175,16 @@ impl StyledText { self.runs = Some(runs); self } + + /// Set the text runs for this piece of text. + pub fn with_runs(mut self, runs: Vec) -> Self { + self.runs = Some(runs); + self + } } impl Element for StyledText { - type RequestLayoutState = TextState; + type RequestLayoutState = (); type PrepaintState = (); fn id(&self) -> Option { @@ -184,29 +198,29 @@ impl Element for StyledText { cx: &mut WindowContext, ) -> (LayoutId, Self::RequestLayoutState) { - let mut state = TextState::default(); - let layout_id = state.layout(self.text.clone(), self.runs.take(), cx); - (layout_id, state) + let layout_id = self.layout.layout(self.text.clone(), self.runs.take(), cx); + (layout_id, ()) } fn prepaint( &mut self, _id: Option<&GlobalElementId>, - _bounds: Bounds, - _state: &mut Self::RequestLayoutState, + bounds: Bounds, + _: &mut Self::RequestLayoutState, _cx: &mut WindowContext, ) { + self.layout.prepaint(bounds, &self.text) } fn paint( &mut self, _id: Option<&GlobalElementId>, - bounds: Bounds, - text_state: &mut Self::RequestLayoutState, + _bounds: Bounds, + _: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, cx: &mut WindowContext, ) { - text_state.paint(bounds, &self.text, cx) + self.layout.paint(&self.text, cx) } } @@ -218,19 +232,20 @@ impl IntoElement for StyledText { } } -#[doc(hidden)] +/// todo!() #[derive(Default, Clone)] -pub struct TextState(Arc>>); +pub struct TextLayout(Arc>>); -struct TextStateInner { +struct TextLayoutInner { lines: SmallVec<[WrappedLine; 1]>, line_height: Pixels, wrap_width: Option, size: Option>, + bounds: Option>, } -impl TextState { - fn lock(&self) -> MutexGuard> { +impl TextLayout { + fn lock(&self) -> MutexGuard> { self.0.lock() } @@ -265,11 +280,11 @@ impl TextState { None }; - if let Some(text_state) = element_state.0.lock().as_ref() { - if text_state.size.is_some() - && (wrap_width.is_none() || wrap_width == text_state.wrap_width) + if let Some(text_layout) = element_state.0.lock().as_ref() { + if text_layout.size.is_some() + && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) { - return text_state.size.unwrap(); + return text_layout.size.unwrap(); } } @@ -283,11 +298,12 @@ impl TextState { ) .log_err() else { - element_state.lock().replace(TextStateInner { + element_state.lock().replace(TextLayoutInner { lines: Default::default(), line_height, wrap_width, size: Some(Size::default()), + bounds: None, }); return Size::default(); }; @@ -299,11 +315,12 @@ impl TextState { size.width = size.width.max(line_size.width).ceil(); } - element_state.lock().replace(TextStateInner { + element_state.lock().replace(TextLayoutInner { lines, line_height, wrap_width, size: Some(size), + bounds: None, }); size @@ -313,12 +330,25 @@ impl TextState { layout_id } - fn paint(&mut self, bounds: Bounds, text: &str, cx: &mut WindowContext) { + fn prepaint(&mut self, bounds: Bounds, text: &str) { + let mut element_state = self.lock(); + let element_state = element_state + .as_mut() + .ok_or_else(|| anyhow!("measurement has not been performed on {}", text)) + .unwrap(); + element_state.bounds = Some(bounds); + } + + fn paint(&mut self, text: &str, cx: &mut WindowContext) { let element_state = self.lock(); let element_state = element_state .as_ref() .ok_or_else(|| anyhow!("measurement has not been performed on {}", text)) .unwrap(); + let bounds = element_state + .bounds + .ok_or_else(|| anyhow!("prepaint has not been performed on {:?}", text)) + .unwrap(); let line_height = element_state.line_height; let mut line_origin = bounds.origin; @@ -328,15 +358,19 @@ impl TextState { } } - fn index_for_position(&self, bounds: Bounds, position: Point) -> Option { - if !bounds.contains(&position) { - return None; - } - + /// todo!() + pub fn index_for_position(&self, mut position: Point) -> Result { let element_state = self.lock(); let element_state = element_state .as_ref() .expect("measurement has not been performed"); + let bounds = element_state + .bounds + .expect("prepaint has not been performed"); + + if position.y < bounds.top() { + return Err(0); + } let line_height = element_state.line_height; let mut line_origin = bounds.origin; @@ -348,14 +382,56 @@ impl TextState { line_start_ix += line.len() + 1; } else { let position_within_line = position - line_origin; - let index_within_line = - line.index_for_position(position_within_line, line_height)?; - return Some(line_start_ix + index_within_line); + match line.index_for_position(position_within_line, line_height) { + Ok(index_within_line) => return Ok(line_start_ix + index_within_line), + Err(index_within_line) => return Err(line_start_ix + index_within_line), + } + } + } + + Err(line_start_ix.saturating_sub(1)) + } + + /// todo!() + pub fn position_for_index(&self, index: usize) -> Option> { + let element_state = self.lock(); + let element_state = element_state + .as_ref() + .expect("measurement has not been performed"); + let bounds = element_state + .bounds + .expect("prepaint has not been performed"); + let line_height = element_state.line_height; + + let mut line_origin = bounds.origin; + let mut line_start_ix = 0; + + for line in &element_state.lines { + let line_end_ix = line_start_ix + line.len(); + if index < line_start_ix { + break; + } else if index > line_end_ix { + line_origin.y += line.size(line_height).height; + line_start_ix = line_end_ix + 1; + continue; + } else { + let ix_within_line = index - line_start_ix; + return Some(line_origin + line.position_for_index(ix_within_line, line_height)?); } } None } + + /// todo!() + pub fn bounds(&self) -> Bounds { + self.0.lock().as_ref().unwrap().bounds.unwrap() + } + + /// todo!() + pub fn line_height(&self) -> Pixels { + self.0.lock().as_ref().unwrap().line_height + } } /// A text element that can be interacted with. @@ -436,7 +512,7 @@ impl InteractiveText { } impl Element for InteractiveText { - type RequestLayoutState = TextState; + type RequestLayoutState = (); type PrepaintState = Hitbox; fn id(&self) -> Option { @@ -484,17 +560,18 @@ impl Element for InteractiveText { &mut self, global_id: Option<&GlobalElementId>, bounds: Bounds, - text_state: &mut Self::RequestLayoutState, + _: &mut Self::RequestLayoutState, hitbox: &mut Hitbox, cx: &mut WindowContext, ) { + let text_layout = self.text.layout().clone(); cx.with_element_state::( global_id.unwrap(), |interactive_state, cx| { let mut interactive_state = interactive_state.unwrap_or_default(); if let Some(click_listener) = self.click_listener.take() { let mouse_position = cx.mouse_position(); - if let Some(ix) = text_state.index_for_position(bounds, mouse_position) { + if let Some(ix) = text_layout.index_for_position(mouse_position).ok() { if self .clickable_ranges .iter() @@ -504,7 +581,7 @@ impl Element for InteractiveText { } } - let text_state = text_state.clone(); + let text_layout = text_layout.clone(); let mouse_down = interactive_state.mouse_down_index.clone(); if let Some(mouse_down_index) = mouse_down.get() { let hitbox = hitbox.clone(); @@ -512,7 +589,7 @@ impl Element for InteractiveText { cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| { if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) { if let Some(mouse_up_index) = - text_state.index_for_position(bounds, event.position) + text_layout.index_for_position(event.position).ok() { click_listener( &clickable_ranges, @@ -533,7 +610,7 @@ impl Element for InteractiveText { cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) { if let Some(mouse_down_index) = - text_state.index_for_position(bounds, event.position) + text_layout.index_for_position(event.position).ok() { mouse_down.set(Some(mouse_down_index)); cx.refresh(); @@ -546,12 +623,12 @@ impl Element for InteractiveText { cx.on_mouse_event({ let mut hover_listener = self.hover_listener.take(); let hitbox = hitbox.clone(); - let text_state = text_state.clone(); + let text_layout = text_layout.clone(); let hovered_index = interactive_state.hovered_index.clone(); move |event: &MouseMoveEvent, phase, cx| { if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) { let current = hovered_index.get(); - let updated = text_state.index_for_position(bounds, event.position); + let updated = text_layout.index_for_position(event.position).ok(); if current != updated { hovered_index.set(updated); if let Some(hover_listener) = hover_listener.as_ref() { @@ -567,10 +644,10 @@ impl Element for InteractiveText { let hitbox = hitbox.clone(); let active_tooltip = interactive_state.active_tooltip.clone(); let pending_mouse_down = interactive_state.mouse_down_index.clone(); - let text_state = text_state.clone(); + let text_layout = text_layout.clone(); cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { - let position = text_state.index_for_position(bounds, event.position); + let position = text_layout.index_for_position(event.position).ok(); let is_hovered = position.is_some() && hitbox.is_hovered(cx) && pending_mouse_down.get().is_none(); @@ -621,7 +698,7 @@ impl Element for InteractiveText { }); } - self.text.paint(None, bounds, text_state, &mut (), cx); + self.text.paint(None, bounds, &mut (), &mut (), cx); ((), interactive_state) }, diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 067dbca17d..aa1b96b1cc 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -1,4 +1,4 @@ -use crate::{px, FontId, GlyphId, Pixels, PlatformTextSystem, Point, Size}; +use crate::{point, px, FontId, GlyphId, Pixels, PlatformTextSystem, Point, Size}; use collections::FxHashMap; use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard}; use smallvec::SmallVec; @@ -254,39 +254,83 @@ impl WrappedLineLayout { /// The index corresponding to a given position in this layout for the given line height. pub fn index_for_position( &self, - position: Point, + mut position: Point, line_height: Pixels, - ) -> Option { + ) -> Result { let wrapped_line_ix = (position.y / line_height) as usize; - let wrapped_line_start_x = if wrapped_line_ix > 0 { + let wrapped_line_start_index; + let wrapped_line_start_x; + if wrapped_line_ix > 0 { let Some(line_start_boundary) = self.wrap_boundaries.get(wrapped_line_ix - 1) else { - return None; + return Err(0); }; let run = &self.unwrapped_layout.runs[line_start_boundary.run_ix]; - run.glyphs[line_start_boundary.glyph_ix].position.x + let glyph = &run.glyphs[line_start_boundary.glyph_ix]; + wrapped_line_start_index = glyph.index; + wrapped_line_start_x = glyph.position.x; } else { - Pixels::ZERO + wrapped_line_start_index = 0; + wrapped_line_start_x = Pixels::ZERO; }; - let wrapped_line_end_x = if wrapped_line_ix < self.wrap_boundaries.len() { + let wrapped_line_end_index; + let wrapped_line_end_x; + if wrapped_line_ix < self.wrap_boundaries.len() { let next_wrap_boundary_ix = wrapped_line_ix; let next_wrap_boundary = self.wrap_boundaries[next_wrap_boundary_ix]; let run = &self.unwrapped_layout.runs[next_wrap_boundary.run_ix]; - run.glyphs[next_wrap_boundary.glyph_ix].position.x + let glyph = &run.glyphs[next_wrap_boundary.glyph_ix]; + wrapped_line_end_index = glyph.index; + wrapped_line_end_x = glyph.position.x; } else { - self.unwrapped_layout.width + wrapped_line_end_index = self.unwrapped_layout.len; + wrapped_line_end_x = self.unwrapped_layout.width; }; let mut position_in_unwrapped_line = position; position_in_unwrapped_line.x += wrapped_line_start_x; - if position_in_unwrapped_line.x > wrapped_line_end_x { - None + if position_in_unwrapped_line.x < wrapped_line_start_x { + Err(wrapped_line_start_index) + } else if position_in_unwrapped_line.x >= wrapped_line_end_x { + Err(wrapped_line_end_index) } else { - self.unwrapped_layout + Ok(self + .unwrapped_layout .index_for_x(position_in_unwrapped_line.x) + .unwrap()) } } + + /// todo!() + pub fn position_for_index(&self, index: usize, line_height: Pixels) -> Option> { + let mut line_start_ix = 0; + let mut line_end_indices = self + .wrap_boundaries + .iter() + .map(|wrap_boundary| { + let run = &self.unwrapped_layout.runs[wrap_boundary.run_ix]; + let glyph = &run.glyphs[wrap_boundary.glyph_ix]; + glyph.index + }) + .chain([self.len()]) + .enumerate(); + for (ix, line_end_ix) in line_end_indices { + let line_y = ix as f32 * line_height; + if index < line_start_ix { + break; + } else if index > line_end_ix { + line_start_ix = line_end_ix; + continue; + } else { + let line_start_x = self.unwrapped_layout.x_for_index(line_start_ix); + let x = self.unwrapped_layout.x_for_index(index) - line_start_x; + return Some(point(x, line_y)); + } + } + + None + } } pub(crate) struct LineLayoutCache { diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml new file mode 100644 index 0000000000..00fbb0a50a --- /dev/null +++ b/crates/markdown/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "markdown" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/markdown.rs" +doctest = false + +[features] +test-support = [ + "gpui/test-support", + "util/test-support" +] + +[dependencies] +anyhow.workspace = true +futures.workspace = true +gpui.workspace = true +language.workspace = true +linkify.workspace = true +log.workspace = true +pulldown-cmark.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true + +[dev-dependencies] +assets.workspace = true +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } +languages.workspace = true +node_runtime.workspace = true +settings = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } diff --git a/crates/markdown/examples/markdown.rs b/crates/markdown/examples/markdown.rs new file mode 100644 index 0000000000..576637953e --- /dev/null +++ b/crates/markdown/examples/markdown.rs @@ -0,0 +1,181 @@ +use assets::Assets; +use gpui::{prelude::*, App, Task, View, WindowOptions}; +use language::{language_settings::AllLanguageSettings, LanguageRegistry}; +use markdown::{Markdown, MarkdownStyle}; +use node_runtime::FakeNodeRuntime; +use settings::SettingsStore; +use std::sync::Arc; +use theme::LoadThemes; +use ui::prelude::*; +use ui::{div, WindowContext}; + +const MARKDOWN_EXAMPLE: &'static str = r#" +# Markdown Example Document + +## Headings +Headings are created by adding one or more `#` symbols before your heading text. The number of `#` you use will determine the size of the heading. + +## Emphasis +Emphasis can be added with italics or bold. *This text will be italic*. _This will also be italic_ + +## Lists + +### Unordered Lists +Unordered lists use asterisks `*`, plus `+`, or minus `-` as list markers. + +* Item 1 +* Item 2 + * Item 2a + * Item 2b + +### Ordered Lists +Ordered lists use numbers followed by a period. + +1. Item 1 +2. Item 2 +3. Item 3 + 1. Item 3a + 2. Item 3b + +## Links +Links are created using the format [http://zed.dev](https://zed.dev). + +They can also be detected automatically, for example https://zed.dev/blog. + +## Images +Images are like links, but with an exclamation mark `!` in front. + +```todo! +![This is an image](/images/logo.png) +``` + +## Code +Inline `code` can be wrapped with backticks `` ` ``. + +```markdown +Inline `code` has `back-ticks around` it. +``` + +Code blocks can be created by indenting lines by four spaces or with triple backticks ```. + +```javascript +function test() { + console.log("notice the blank line before this function?"); +} +``` + +## Blockquotes +Blockquotes are created with `>`. + +> This is a blockquote. + +## Horizontal Rules +Horizontal rules are created using three or more asterisks `***`, dashes `---`, or underscores `___`. + +## Line breaks +This is a +\ +line break! + +--- + +Remember, markdown processors may have slight differences and extensions, so always refer to the specific documentation or guides relevant to your platform or editor for the best practices and additional features. +"#; + +pub fn main() { + env_logger::init(); + App::new().with_assets(Assets).run(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + language::init(cx); + SettingsStore::update(cx, |store, cx| { + store.update_user_settings::(cx, |_| {}); + }); + + let node_runtime = FakeNodeRuntime::new(); + let language_registry = Arc::new(LanguageRegistry::new( + Task::ready(()), + cx.background_executor().clone(), + )); + languages::init(language_registry.clone(), node_runtime, cx); + theme::init(LoadThemes::JustBase, cx); + Assets.load_fonts(cx).unwrap(); + + cx.activate(true); + cx.open_window(WindowOptions::default(), |cx| { + cx.new_view(|cx| { + MarkdownExample::new( + MARKDOWN_EXAMPLE.to_string(), + MarkdownStyle { + code_block: gpui::TextStyleRefinement { + font_family: Some("Zed Mono".into()), + color: Some(cx.theme().colors().editor_foreground), + background_color: Some(cx.theme().colors().editor_background), + ..Default::default() + }, + inline_code: gpui::TextStyleRefinement { + font_family: Some("Zed Mono".into()), + // @nate: Could we add inline-code specific styles to the theme? + color: Some(cx.theme().colors().editor_foreground), + background_color: Some(cx.theme().colors().editor_background), + ..Default::default() + }, + rule_color: Color::Muted.color(cx), + block_quote_border_color: Color::Muted.color(cx), + block_quote: gpui::TextStyleRefinement { + color: Some(Color::Muted.color(cx)), + ..Default::default() + }, + link: gpui::TextStyleRefinement { + color: Some(Color::Accent.color(cx)), + underline: Some(gpui::UnderlineStyle { + thickness: px(1.), + color: Some(Color::Accent.color(cx)), + wavy: false, + }), + ..Default::default() + }, + syntax: cx.theme().syntax().clone(), + selection_background_color: { + let mut selection = cx.theme().players().local().selection; + selection.fade_out(0.7); + selection + }, + }, + language_registry, + cx, + ) + }) + }); + }); +} + +struct MarkdownExample { + markdown: View, +} + +impl MarkdownExample { + pub fn new( + text: String, + style: MarkdownStyle, + language_registry: Arc, + cx: &mut WindowContext, + ) -> Self { + let markdown = cx.new_view(|cx| Markdown::new(text, style, language_registry, cx)); + Self { markdown } + } +} + +impl Render for MarkdownExample { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + div() + .id("markdown-example") + .debug_selector(|| "foo".into()) + .relative() + .bg(gpui::white()) + .size_full() + .p_4() + .overflow_y_scroll() + .child(self.markdown.clone()) + } +} diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs new file mode 100644 index 0000000000..9d362ed6bd --- /dev/null +++ b/crates/markdown/src/markdown.rs @@ -0,0 +1,902 @@ +mod parser; + +use crate::parser::CodeBlockKind; +use futures::FutureExt; +use gpui::{ + point, quad, AnyElement, Bounds, CursorStyle, DispatchPhase, Edges, FontStyle, FontWeight, + GlobalElementId, Hitbox, Hsla, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, Point, + Render, StrikethroughStyle, Style, StyledText, Task, TextLayout, TextRun, TextStyle, + TextStyleRefinement, View, +}; +use language::{Language, LanguageRegistry, Rope}; +use parser::{parse_markdown, MarkdownEvent, MarkdownTag, MarkdownTagEnd}; +use std::{iter, mem, ops::Range, rc::Rc, sync::Arc}; +use theme::SyntaxTheme; +use ui::prelude::*; +use util::{ResultExt, TryFutureExt}; + +#[derive(Clone)] +pub struct MarkdownStyle { + pub code_block: TextStyleRefinement, + pub inline_code: TextStyleRefinement, + pub block_quote: TextStyleRefinement, + pub link: TextStyleRefinement, + pub rule_color: Hsla, + pub block_quote_border_color: Hsla, + pub syntax: Arc, + pub selection_background_color: Hsla, +} + +pub struct Markdown { + source: String, + selection: Selection, + pressed_link: Option, + autoscroll_request: Option, + style: MarkdownStyle, + parsed_markdown: ParsedMarkdown, + should_reparse: bool, + pending_parse: Option>>, + language_registry: Arc, +} + +impl Markdown { + pub fn new( + source: String, + style: MarkdownStyle, + language_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let mut this = Self { + source, + selection: Selection::default(), + pressed_link: None, + autoscroll_request: None, + style, + should_reparse: false, + parsed_markdown: ParsedMarkdown::default(), + pending_parse: None, + language_registry, + }; + this.parse(cx); + this + } + + pub fn append(&mut self, text: &str, cx: &mut ViewContext) { + self.source.push_str(text); + self.parse(cx); + } + + pub fn source(&self) -> &str { + &self.source + } + + fn parse(&mut self, cx: &mut ViewContext) { + if self.source.is_empty() { + return; + } + + if self.pending_parse.is_some() { + self.should_reparse = true; + return; + } + + let text = self.source.clone(); + let parsed = cx.background_executor().spawn(async move { + let text = SharedString::from(text); + let events = Arc::from(parse_markdown(text.as_ref())); + anyhow::Ok(ParsedMarkdown { + source: text, + events, + }) + }); + + self.should_reparse = false; + self.pending_parse = Some(cx.spawn(|this, mut cx| { + async move { + let parsed = parsed.await?; + this.update(&mut cx, |this, cx| { + this.parsed_markdown = parsed; + this.pending_parse.take(); + if this.should_reparse { + this.parse(cx); + } + cx.notify(); + }) + .ok(); + anyhow::Ok(()) + } + .log_err() + })); + } +} + +impl Render for Markdown { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + MarkdownElement::new( + cx.view().clone(), + self.style.clone(), + self.language_registry.clone(), + ) + } +} + +#[derive(Copy, Clone, Default, Debug)] +struct Selection { + start: usize, + end: usize, + reversed: bool, + pending: bool, +} + +impl Selection { + fn set_head(&mut self, head: usize) { + if head < self.tail() { + if !self.reversed { + self.end = self.start; + self.reversed = true; + } + self.start = head; + } else { + if self.reversed { + self.start = self.end; + self.reversed = false; + } + self.end = head; + } + } + + fn tail(&self) -> usize { + if self.reversed { + self.end + } else { + self.start + } + } +} + +#[derive(Clone)] +struct ParsedMarkdown { + source: SharedString, + events: Arc<[(Range, MarkdownEvent)]>, +} + +impl Default for ParsedMarkdown { + fn default() -> Self { + Self { + source: SharedString::default(), + events: Arc::from([]), + } + } +} + +pub struct MarkdownElement { + markdown: View, + style: MarkdownStyle, + language_registry: Arc, +} + +impl MarkdownElement { + fn new( + markdown: View, + style: MarkdownStyle, + language_registry: Arc, + ) -> Self { + Self { + markdown, + style, + language_registry, + } + } + + fn load_language(&self, name: &str, cx: &mut WindowContext) -> Option> { + let language = self + .language_registry + .language_for_name(name) + .map(|language| language.ok()) + .shared(); + + match language.clone().now_or_never() { + Some(language) => language, + None => { + let markdown = self.markdown.downgrade(); + cx.spawn(|mut cx| async move { + language.await; + markdown.update(&mut cx, |_, cx| cx.notify()) + }) + .detach_and_log_err(cx); + None + } + } + } + + fn paint_selection( + &mut self, + bounds: Bounds, + rendered_text: &RenderedText, + cx: &mut WindowContext, + ) { + let selection = self.markdown.read(cx).selection; + let selection_start = rendered_text.position_for_source_index(selection.start); + let selection_end = rendered_text.position_for_source_index(selection.end); + + if let Some(((start_position, start_line_height), (end_position, end_line_height))) = + selection_start.zip(selection_end) + { + if start_position.y == end_position.y { + cx.paint_quad(quad( + Bounds::from_corners( + start_position, + point(end_position.x, end_position.y + end_line_height), + ), + Pixels::ZERO, + self.style.selection_background_color, + Edges::default(), + Hsla::transparent_black(), + )); + } else { + cx.paint_quad(quad( + Bounds::from_corners( + start_position, + point(bounds.right(), start_position.y + start_line_height), + ), + Pixels::ZERO, + self.style.selection_background_color, + Edges::default(), + Hsla::transparent_black(), + )); + + if end_position.y > start_position.y + start_line_height { + cx.paint_quad(quad( + Bounds::from_corners( + point(bounds.left(), start_position.y + start_line_height), + point(bounds.right(), end_position.y), + ), + Pixels::ZERO, + self.style.selection_background_color, + Edges::default(), + Hsla::transparent_black(), + )); + } + + cx.paint_quad(quad( + Bounds::from_corners( + point(bounds.left(), end_position.y), + point(end_position.x, end_position.y + end_line_height), + ), + Pixels::ZERO, + self.style.selection_background_color, + Edges::default(), + Hsla::transparent_black(), + )); + } + } + } + + fn paint_mouse_listeners( + &mut self, + hitbox: &Hitbox, + rendered_text: &RenderedText, + cx: &mut WindowContext, + ) { + let is_hovering_link = hitbox.is_hovered(cx) + && !self.markdown.read(cx).selection.pending + && rendered_text + .link_for_position(cx.mouse_position()) + .is_some(); + + if is_hovering_link { + cx.set_cursor_style(CursorStyle::PointingHand, hitbox); + } else { + cx.set_cursor_style(CursorStyle::IBeam, hitbox); + } + + self.on_mouse_event(cx, { + let rendered_text = rendered_text.clone(); + let hitbox = hitbox.clone(); + move |markdown, event: &MouseDownEvent, phase, cx| { + if hitbox.is_hovered(cx) { + if phase.bubble() { + if let Some(link) = rendered_text.link_for_position(event.position) { + markdown.pressed_link = Some(link.clone()); + } else { + let source_index = + match rendered_text.source_index_for_position(event.position) { + Ok(ix) | Err(ix) => ix, + }; + markdown.selection = Selection { + start: source_index, + end: source_index, + reversed: false, + pending: true, + }; + } + + cx.notify(); + } + } else if phase.capture() { + markdown.selection = Selection::default(); + markdown.pressed_link = None; + cx.notify(); + } + } + }); + self.on_mouse_event(cx, { + let rendered_text = rendered_text.clone(); + let hitbox = hitbox.clone(); + let was_hovering_link = is_hovering_link; + move |markdown, event: &MouseMoveEvent, phase, cx| { + if phase.capture() { + return; + } + + if markdown.selection.pending { + let source_index = match rendered_text.source_index_for_position(event.position) + { + Ok(ix) | Err(ix) => ix, + }; + markdown.selection.set_head(source_index); + markdown.autoscroll_request = Some(source_index); + cx.notify(); + } else { + let is_hovering_link = hitbox.is_hovered(cx) + && rendered_text.link_for_position(event.position).is_some(); + if is_hovering_link != was_hovering_link { + cx.notify(); + } + } + } + }); + self.on_mouse_event(cx, { + let rendered_text = rendered_text.clone(); + move |markdown, event: &MouseUpEvent, phase, cx| { + if phase.bubble() { + if let Some(pressed_link) = markdown.pressed_link.take() { + if Some(&pressed_link) == rendered_text.link_for_position(event.position) { + cx.open_url(&pressed_link.destination_url); + } + } + } else { + if markdown.selection.pending { + markdown.selection.pending = false; + cx.notify(); + } + } + } + }); + } + + fn autoscroll(&mut self, rendered_text: &RenderedText, cx: &mut WindowContext) -> Option<()> { + let autoscroll_index = self + .markdown + .update(cx, |markdown, _| markdown.autoscroll_request.take())?; + let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?; + + let text_style = cx.text_style(); + let font_id = cx.text_system().resolve_font(&text_style.font()); + let font_size = text_style.font_size.to_pixels(cx.rem_size()); + let em_width = cx + .text_system() + .typographic_bounds(font_id, font_size, 'm') + .unwrap() + .size + .width; + cx.request_autoscroll(Bounds::from_corners( + point(position.x - 3. * em_width, position.y - 3. * line_height), + point(position.x + 3. * em_width, position.y + 3. * line_height), + )); + Some(()) + } + + fn on_mouse_event( + &self, + cx: &mut WindowContext, + mut f: impl 'static + FnMut(&mut Markdown, &T, DispatchPhase, &mut ViewContext), + ) { + cx.on_mouse_event({ + let markdown = self.markdown.downgrade(); + move |event, phase, cx| { + markdown + .update(cx, |markdown, cx| f(markdown, event, phase, cx)) + .log_err(); + } + }); + } +} + +impl Element for MarkdownElement { + type RequestLayoutState = RenderedMarkdown; + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + cx: &mut WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let mut builder = MarkdownElementBuilder::new(cx.text_style(), self.style.syntax.clone()); + let parsed_markdown = self.markdown.read(cx).parsed_markdown.clone(); + for (range, event) in parsed_markdown.events.iter() { + match event { + MarkdownEvent::Start(tag) => { + match tag { + MarkdownTag::Paragraph => { + builder.push_div(div().mb_2().line_height(rems(1.3))); + } + MarkdownTag::Heading { level, .. } => { + let mut heading = div().mb_2(); + heading = match level { + pulldown_cmark::HeadingLevel::H1 => heading.text_3xl(), + pulldown_cmark::HeadingLevel::H2 => heading.text_2xl(), + pulldown_cmark::HeadingLevel::H3 => heading.text_xl(), + pulldown_cmark::HeadingLevel::H4 => heading.text_lg(), + _ => heading, + }; + builder.push_div(heading); + } + MarkdownTag::BlockQuote => { + builder.push_text_style(self.style.block_quote.clone()); + builder.push_div( + div() + .pl_4() + .mb_2() + .border_l_4() + .border_color(self.style.block_quote_border_color), + ); + } + MarkdownTag::CodeBlock(kind) => { + let language = if let CodeBlockKind::Fenced(language) = kind { + self.load_language(language.as_ref(), cx) + } else { + None + }; + + builder.push_code_block(language); + builder.push_text_style(self.style.code_block.clone()); + builder.push_div(div().rounded_lg().p_4().mb_2().w_full().when_some( + self.style.code_block.background_color, + |div, color| div.bg(color), + )); + } + MarkdownTag::HtmlBlock => builder.push_div(div()), + MarkdownTag::List(bullet_index) => { + builder.push_list(*bullet_index); + builder.push_div(div().pl_4()); + } + MarkdownTag::Item => { + let bullet = if let Some(bullet_index) = builder.next_bullet_index() { + format!("{}.", bullet_index) + } else { + "•".to_string() + }; + builder.push_div( + div() + .h_flex() + .mb_2() + .line_height(rems(1.3)) + .items_start() + .gap_1() + .child(bullet), + ); + // Without `w_0`, text doesn't wrap to the width of the container. + builder.push_div(div().flex_1().w_0()); + } + MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement { + font_style: Some(FontStyle::Italic), + ..Default::default() + }), + MarkdownTag::Strong => builder.push_text_style(TextStyleRefinement { + font_weight: Some(FontWeight::BOLD), + ..Default::default() + }), + MarkdownTag::Strikethrough => { + builder.push_text_style(TextStyleRefinement { + strikethrough: Some(StrikethroughStyle { + thickness: px(1.), + color: None, + }), + ..Default::default() + }) + } + MarkdownTag::Link { dest_url, .. } => { + builder.push_link(dest_url.clone(), range.clone()); + builder.push_text_style(self.style.link.clone()) + } + _ => log::error!("unsupported markdown tag {:?}", tag), + } + } + MarkdownEvent::End(tag) => match tag { + MarkdownTagEnd::Paragraph => { + builder.pop_div(); + } + MarkdownTagEnd::Heading(_) => builder.pop_div(), + MarkdownTagEnd::BlockQuote => { + builder.pop_text_style(); + builder.pop_div() + } + MarkdownTagEnd::CodeBlock => { + builder.trim_trailing_newline(); + builder.pop_div(); + builder.pop_text_style(); + builder.pop_code_block(); + } + MarkdownTagEnd::HtmlBlock => builder.pop_div(), + MarkdownTagEnd::List(_) => { + builder.pop_list(); + builder.pop_div(); + } + MarkdownTagEnd::Item => { + builder.pop_div(); + builder.pop_div(); + } + MarkdownTagEnd::Emphasis => builder.pop_text_style(), + MarkdownTagEnd::Strong => builder.pop_text_style(), + MarkdownTagEnd::Strikethrough => builder.pop_text_style(), + MarkdownTagEnd::Link => builder.pop_text_style(), + _ => log::error!("unsupported markdown tag end: {:?}", tag), + }, + MarkdownEvent::Text => { + builder.push_text(&parsed_markdown.source[range.clone()], range.start); + } + MarkdownEvent::Code => { + builder.push_text_style(self.style.inline_code.clone()); + builder.push_text(&parsed_markdown.source[range.clone()], range.start); + builder.pop_text_style(); + } + MarkdownEvent::Html => { + builder.push_text(&parsed_markdown.source[range.clone()], range.start); + } + MarkdownEvent::InlineHtml => { + builder.push_text(&parsed_markdown.source[range.clone()], range.start); + } + MarkdownEvent::Rule => { + builder.push_div( + div() + .border_b_1() + .my_2() + .border_color(self.style.rule_color), + ); + builder.pop_div() + } + MarkdownEvent::SoftBreak => builder.push_text("\n", range.start), + MarkdownEvent::HardBreak => builder.push_text("\n", range.start), + _ => log::error!("unsupported markdown event {:?}", event), + } + } + + let mut rendered_markdown = builder.build(); + let child_layout_id = rendered_markdown.element.request_layout(cx); + let layout_id = cx.request_layout(&Style::default(), [child_layout_id]); + (layout_id, rendered_markdown) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + bounds: Bounds, + rendered_markdown: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> Self::PrepaintState { + let hitbox = cx.insert_hitbox(bounds, false); + rendered_markdown.element.prepaint(cx); + self.autoscroll(&rendered_markdown.text, cx); + hitbox + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + bounds: Bounds, + rendered_markdown: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + cx: &mut WindowContext, + ) { + self.paint_mouse_listeners(hitbox, &rendered_markdown.text, cx); + rendered_markdown.element.paint(cx); + self.paint_selection(bounds, &rendered_markdown.text, cx); + } +} + +impl IntoElement for MarkdownElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +struct MarkdownElementBuilder { + div_stack: Vec
, + rendered_lines: Vec, + pending_line: PendingLine, + rendered_links: Vec, + current_source_index: usize, + base_text_style: TextStyle, + text_style_stack: Vec, + code_block_stack: Vec>>, + list_stack: Vec, + syntax_theme: Arc, +} + +#[derive(Default)] +struct PendingLine { + text: String, + runs: Vec, + source_mappings: Vec, +} + +struct ListStackEntry { + bullet_index: Option, +} + +impl MarkdownElementBuilder { + fn new(base_text_style: TextStyle, syntax_theme: Arc) -> Self { + Self { + div_stack: vec![div().debug_selector(|| "inner".into())], + rendered_lines: Vec::new(), + pending_line: PendingLine::default(), + rendered_links: Vec::new(), + current_source_index: 0, + base_text_style, + text_style_stack: Vec::new(), + code_block_stack: Vec::new(), + list_stack: Vec::new(), + syntax_theme, + } + } + + fn push_text_style(&mut self, style: TextStyleRefinement) { + self.text_style_stack.push(style); + } + + fn text_style(&self) -> TextStyle { + let mut style = self.base_text_style.clone(); + for refinement in &self.text_style_stack { + style.refine(refinement); + } + style + } + + fn pop_text_style(&mut self) { + self.text_style_stack.pop(); + } + + fn push_div(&mut self, div: Div) { + self.flush_text(); + self.div_stack.push(div); + } + + fn pop_div(&mut self) { + self.flush_text(); + let div = self.div_stack.pop().unwrap().into_any(); + self.div_stack.last_mut().unwrap().extend(iter::once(div)); + } + + fn push_list(&mut self, bullet_index: Option) { + self.list_stack.push(ListStackEntry { bullet_index }); + } + + fn next_bullet_index(&mut self) -> Option { + self.list_stack.last_mut().and_then(|entry| { + let item_index = entry.bullet_index.as_mut()?; + *item_index += 1; + Some(*item_index - 1) + }) + } + + fn pop_list(&mut self) { + self.list_stack.pop(); + } + + fn push_code_block(&mut self, language: Option>) { + self.code_block_stack.push(language); + } + + fn pop_code_block(&mut self) { + self.code_block_stack.pop(); + } + + fn push_link(&mut self, destination_url: SharedString, source_range: Range) { + self.rendered_links.push(RenderedLink { + source_range, + destination_url, + }); + } + + fn push_text(&mut self, text: &str, source_index: usize) { + self.pending_line.source_mappings.push(SourceMapping { + rendered_index: self.pending_line.text.len(), + source_index, + }); + self.pending_line.text.push_str(text); + self.current_source_index = source_index + text.len(); + + if let Some(Some(language)) = self.code_block_stack.last() { + let mut offset = 0; + for (range, highlight_id) in language.highlight_text(&Rope::from(text), 0..text.len()) { + if range.start > offset { + self.pending_line + .runs + .push(self.text_style().to_run(range.start - offset)); + } + + let mut run_style = self.text_style(); + if let Some(highlight) = highlight_id.style(&self.syntax_theme) { + run_style = run_style.highlight(highlight); + } + self.pending_line.runs.push(run_style.to_run(range.len())); + offset = range.end; + } + + if offset < text.len() { + self.pending_line + .runs + .push(self.text_style().to_run(text.len() - offset)); + } + } else { + self.pending_line + .runs + .push(self.text_style().to_run(text.len())); + } + } + + fn trim_trailing_newline(&mut self) { + if self.pending_line.text.ends_with('\n') { + self.pending_line + .text + .truncate(self.pending_line.text.len() - 1); + self.pending_line.runs.last_mut().unwrap().len -= 1; + self.current_source_index -= 1; + } + } + + fn flush_text(&mut self) { + let line = mem::take(&mut self.pending_line); + if line.text.is_empty() { + return; + } + + let text = StyledText::new(line.text).with_runs(line.runs); + self.rendered_lines.push(RenderedLine { + layout: text.layout().clone(), + source_mappings: line.source_mappings, + source_end: self.current_source_index, + }); + self.div_stack.last_mut().unwrap().extend([text.into_any()]); + } + + fn build(mut self) -> RenderedMarkdown { + debug_assert_eq!(self.div_stack.len(), 1); + self.flush_text(); + RenderedMarkdown { + element: self.div_stack.pop().unwrap().into_any(), + text: RenderedText { + lines: self.rendered_lines.into(), + links: self.rendered_links.into(), + }, + } + } +} + +struct RenderedLine { + layout: TextLayout, + source_mappings: Vec, + source_end: usize, +} + +impl RenderedLine { + fn rendered_index_for_source_index(&self, source_index: usize) -> usize { + let mapping = match self + .source_mappings + .binary_search_by_key(&source_index, |probe| probe.source_index) + { + Ok(ix) => &self.source_mappings[ix], + Err(ix) => &self.source_mappings[ix - 1], + }; + mapping.rendered_index + (source_index - mapping.source_index) + } + + fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize { + let mapping = match self + .source_mappings + .binary_search_by_key(&rendered_index, |probe| probe.rendered_index) + { + Ok(ix) => &self.source_mappings[ix], + Err(ix) => &self.source_mappings[ix - 1], + }; + mapping.source_index + (rendered_index - mapping.rendered_index) + } + + fn source_index_for_position(&self, position: Point) -> Result { + let line_rendered_index; + let out_of_bounds; + match self.layout.index_for_position(position) { + Ok(ix) => { + line_rendered_index = ix; + out_of_bounds = false; + } + Err(ix) => { + line_rendered_index = ix; + out_of_bounds = true; + } + }; + let source_index = self.source_index_for_rendered_index(line_rendered_index); + if out_of_bounds { + Err(source_index) + } else { + Ok(source_index) + } + } +} + +#[derive(Copy, Clone, Debug, Default)] +struct SourceMapping { + rendered_index: usize, + source_index: usize, +} + +pub struct RenderedMarkdown { + element: AnyElement, + text: RenderedText, +} + +#[derive(Clone)] +struct RenderedText { + lines: Rc<[RenderedLine]>, + links: Rc<[RenderedLink]>, +} + +#[derive(Clone, Eq, PartialEq)] +struct RenderedLink { + source_range: Range, + destination_url: SharedString, +} + +impl RenderedText { + fn source_index_for_position(&self, position: Point) -> Result { + let mut lines = self.lines.iter().peekable(); + + while let Some(line) = lines.next() { + let line_bounds = line.layout.bounds(); + if position.y > line_bounds.bottom() { + if let Some(next_line) = lines.peek() { + if position.y < next_line.layout.bounds().top() { + return Err(line.source_end); + } + } + + continue; + } + + return line.source_index_for_position(position); + } + + Err(self.lines.last().map_or(0, |line| line.source_end)) + } + + fn position_for_source_index(&self, source_index: usize) -> Option<(Point, Pixels)> { + for line in self.lines.iter() { + let line_source_start = line.source_mappings.first().unwrap().source_index; + if source_index < line_source_start { + break; + } else if source_index > line.source_end { + continue; + } else { + let line_height = line.layout.line_height(); + let rendered_index_within_line = line.rendered_index_for_source_index(source_index); + let position = line.layout.position_for_index(rendered_index_within_line)?; + return Some((position, line_height)); + } + } + None + } + + fn link_for_position(&self, position: Point) -> Option<&RenderedLink> { + let source_index = self.source_index_for_position(position).ok()?; + self.links + .iter() + .find(|link| link.source_range.contains(&source_index)) + } +} diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs new file mode 100644 index 0000000000..131a614ef8 --- /dev/null +++ b/crates/markdown/src/parser.rs @@ -0,0 +1,274 @@ +use gpui::SharedString; +use linkify::LinkFinder; +pub use pulldown_cmark::TagEnd as MarkdownTagEnd; +use pulldown_cmark::{Alignment, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser}; +use std::ops::Range; + +pub fn parse_markdown(text: &str) -> Vec<(Range, MarkdownEvent)> { + let mut events = Vec::new(); + let mut within_link = false; + for (pulldown_event, mut range) in Parser::new_ext(text, Options::all()).into_offset_iter() { + match pulldown_event { + pulldown_cmark::Event::Start(tag) => { + if let pulldown_cmark::Tag::Link { .. } = tag { + within_link = true; + } + events.push((range, MarkdownEvent::Start(tag.into()))) + } + pulldown_cmark::Event::End(tag) => { + if let pulldown_cmark::TagEnd::Link = tag { + within_link = false; + } + events.push((range, MarkdownEvent::End(tag))); + } + pulldown_cmark::Event::Text(_) => { + // Automatically detect links in text if we're not already within a markdown + // link. + if !within_link { + let mut finder = LinkFinder::new(); + finder.kinds(&[linkify::LinkKind::Url]); + let text_range = range.clone(); + for link in finder.links(&text[text_range.clone()]) { + let link_range = + text_range.start + link.start()..text_range.start + link.end(); + + if link_range.start > range.start { + events.push((range.start..link_range.start, MarkdownEvent::Text)); + } + + events.push(( + link_range.clone(), + MarkdownEvent::Start(MarkdownTag::Link { + link_type: LinkType::Autolink, + dest_url: SharedString::from(link.as_str().to_string()), + title: SharedString::default(), + id: SharedString::default(), + }), + )); + events.push((link_range.clone(), MarkdownEvent::Text)); + events.push((link_range.clone(), MarkdownEvent::End(MarkdownTagEnd::Link))); + + range.start = link_range.end; + } + } + + if range.start < range.end { + events.push((range, MarkdownEvent::Text)); + } + } + pulldown_cmark::Event::Code(_) => { + range.start += 1; + range.end -= 1; + events.push((range, MarkdownEvent::Code)) + } + pulldown_cmark::Event::Html(_) => events.push((range, MarkdownEvent::Html)), + pulldown_cmark::Event::InlineHtml(_) => events.push((range, MarkdownEvent::InlineHtml)), + pulldown_cmark::Event::FootnoteReference(_) => { + events.push((range, MarkdownEvent::FootnoteReference)) + } + pulldown_cmark::Event::SoftBreak => events.push((range, MarkdownEvent::SoftBreak)), + pulldown_cmark::Event::HardBreak => events.push((range, MarkdownEvent::HardBreak)), + pulldown_cmark::Event::Rule => events.push((range, MarkdownEvent::Rule)), + pulldown_cmark::Event::TaskListMarker(checked) => { + events.push((range, MarkdownEvent::TaskListMarker(checked))) + } + } + } + events +} + +/// A static-lifetime equivalent of pulldown_cmark::Event so we can cache the +/// parse result for rendering without resorting to unsafe lifetime coercion. +#[derive(Clone, Debug, PartialEq)] +pub enum MarkdownEvent { + /// Start of a tagged element. Events that are yielded after this event + /// and before its corresponding `End` event are inside this element. + /// Start and end events are guaranteed to be balanced. + Start(MarkdownTag), + /// End of a tagged element. + End(MarkdownTagEnd), + /// A text node. + Text, + /// An inline code node. + Code, + /// An HTML node. + Html, + /// An inline HTML node. + InlineHtml, + /// A reference to a footnote with given label, which may or may not be defined + /// by an event with a `Tag::FootnoteDefinition` tag. Definitions and references to them may + /// occur in any order. + FootnoteReference, + /// A soft line break. + SoftBreak, + /// A hard line break. + HardBreak, + /// A horizontal ruler. + Rule, + /// A task list marker, rendered as a checkbox in HTML. Contains a true when it is checked. + TaskListMarker(bool), +} + +/// Tags for elements that can contain other elements. +#[derive(Clone, Debug, PartialEq)] +pub enum MarkdownTag { + /// A paragraph of text and other inline elements. + Paragraph, + + /// A heading, with optional identifier, classes and custom attributes. + /// The identifier is prefixed with `#` and the last one in the attributes + /// list is chosen, classes are prefixed with `.` and custom attributes + /// have no prefix and can optionally have a value (`myattr` o `myattr=myvalue`). + Heading { + level: HeadingLevel, + id: Option, + classes: Vec, + /// The first item of the tuple is the attr and second one the value. + attrs: Vec<(SharedString, Option)>, + }, + + BlockQuote, + + /// A code block. + CodeBlock(CodeBlockKind), + + /// A HTML block. + HtmlBlock, + + /// A list. If the list is ordered the field indicates the number of the first item. + /// Contains only list items. + List(Option), // TODO: add delim and tight for ast (not needed for html) + + /// A list item. + Item, + + /// A footnote definition. The value contained is the footnote's label by which it can + /// be referred to. + #[cfg_attr(feature = "serde", serde(borrow))] + FootnoteDefinition(SharedString), + + /// A table. Contains a vector describing the text-alignment for each of its columns. + Table(Vec), + + /// A table header. Contains only `TableCell`s. Note that the table body starts immediately + /// after the closure of the `TableHead` tag. There is no `TableBody` tag. + TableHead, + + /// A table row. Is used both for header rows as body rows. Contains only `TableCell`s. + TableRow, + TableCell, + + // span-level tags + Emphasis, + Strong, + Strikethrough, + + /// A link. + Link { + link_type: LinkType, + dest_url: SharedString, + title: SharedString, + /// Identifier of reference links, e.g. `world` in the link `[hello][world]`. + id: SharedString, + }, + + /// An image. The first field is the link type, the second the destination URL and the third is a title, + /// the fourth is the link identifier. + Image { + link_type: LinkType, + dest_url: SharedString, + title: SharedString, + /// Identifier of reference links, e.g. `world` in the link `[hello][world]`. + id: SharedString, + }, + + /// A metadata block. + MetadataBlock(MetadataBlockKind), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum CodeBlockKind { + Indented, + /// The value contained in the tag describes the language of the code, which may be empty. + Fenced(SharedString), +} + +impl From> for MarkdownTag { + fn from(tag: pulldown_cmark::Tag) -> Self { + match tag { + pulldown_cmark::Tag::Paragraph => MarkdownTag::Paragraph, + pulldown_cmark::Tag::Heading { + level, + id, + classes, + attrs, + } => { + let id = id.map(|id| SharedString::from(id.into_string())); + let classes = classes + .into_iter() + .map(|c| SharedString::from(c.into_string())) + .collect(); + let attrs = attrs + .into_iter() + .map(|(key, value)| { + ( + SharedString::from(key.into_string()), + value.map(|v| SharedString::from(v.into_string())), + ) + }) + .collect(); + MarkdownTag::Heading { + level, + id, + classes, + attrs, + } + } + pulldown_cmark::Tag::BlockQuote => MarkdownTag::BlockQuote, + pulldown_cmark::Tag::CodeBlock(kind) => match kind { + pulldown_cmark::CodeBlockKind::Indented => { + MarkdownTag::CodeBlock(CodeBlockKind::Indented) + } + pulldown_cmark::CodeBlockKind::Fenced(info) => MarkdownTag::CodeBlock( + CodeBlockKind::Fenced(SharedString::from(info.into_string())), + ), + }, + pulldown_cmark::Tag::List(start_number) => MarkdownTag::List(start_number), + pulldown_cmark::Tag::Item => MarkdownTag::Item, + pulldown_cmark::Tag::FootnoteDefinition(label) => { + MarkdownTag::FootnoteDefinition(SharedString::from(label.to_string())) + } + pulldown_cmark::Tag::Table(alignments) => MarkdownTag::Table(alignments), + pulldown_cmark::Tag::TableHead => MarkdownTag::TableHead, + pulldown_cmark::Tag::TableRow => MarkdownTag::TableRow, + pulldown_cmark::Tag::TableCell => MarkdownTag::TableCell, + pulldown_cmark::Tag::Emphasis => MarkdownTag::Emphasis, + pulldown_cmark::Tag::Strong => MarkdownTag::Strong, + pulldown_cmark::Tag::Strikethrough => MarkdownTag::Strikethrough, + pulldown_cmark::Tag::Link { + link_type, + dest_url, + title, + id, + } => MarkdownTag::Link { + link_type, + dest_url: SharedString::from(dest_url.into_string()), + title: SharedString::from(title.into_string()), + id: SharedString::from(id.into_string()), + }, + pulldown_cmark::Tag::Image { + link_type, + dest_url, + title, + id, + } => MarkdownTag::Image { + link_type, + dest_url: SharedString::from(dest_url.into_string()), + title: SharedString::from(title.into_string()), + id: SharedString::from(id.into_string()), + }, + pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, + pulldown_cmark::Tag::MetadataBlock(kind) => MarkdownTag::MetadataBlock(kind), + } + } +} diff --git a/crates/story/src/story.rs b/crates/story/src/story.rs index 5fc7ac68ed..1f37267cdb 100644 --- a/crates/story/src/story.rs +++ b/crates/story/src/story.rs @@ -67,7 +67,7 @@ impl StoryContainer { } impl ParentElement for StoryContainer { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } @@ -372,7 +372,7 @@ impl RenderOnce for StorySection { } impl ParentElement for StorySection { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index af6cd15417..3d79095614 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -407,7 +407,7 @@ impl VisibleOnHover for ButtonLike { } impl ParentElement for ButtonLike { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index a7a90eecf0..4c07ca94b3 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -89,7 +89,7 @@ impl LabelCommon for LabelLike { } impl ParentElement for LabelLike { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/list/list.rs b/crates/ui/src/components/list/list.rs index 9d102d7091..478a906def 100644 --- a/crates/ui/src/components/list/list.rs +++ b/crates/ui/src/components/list/list.rs @@ -40,7 +40,7 @@ impl List { } impl ParentElement for List { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 0cab450b25..6e0c9e51c6 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -141,7 +141,7 @@ impl Selectable for ListItem { } impl ParentElement for ListItem { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/modal.rs b/crates/ui/src/components/modal.rs index 107f61d5b4..34e955ec13 100644 --- a/crates/ui/src/components/modal.rs +++ b/crates/ui/src/components/modal.rs @@ -35,7 +35,7 @@ impl ModalHeader { } impl ParentElement for ModalHeader { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } @@ -86,7 +86,7 @@ impl ModalContent { } impl ParentElement for ModalContent { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } @@ -111,7 +111,7 @@ impl ModalRow { } impl ParentElement for ModalRow { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/popover.rs b/crates/ui/src/components/popover.rs index fea2a550fa..b9932eb401 100644 --- a/crates/ui/src/components/popover.rs +++ b/crates/ui/src/components/popover.rs @@ -74,7 +74,7 @@ impl Popover { } impl ParentElement for Popover { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/tab.rs b/crates/ui/src/components/tab.rs index 83def9a132..ade3abe63e 100644 --- a/crates/ui/src/components/tab.rs +++ b/crates/ui/src/components/tab.rs @@ -94,7 +94,7 @@ impl Selectable for Tab { } impl ParentElement for Tab { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index c514273288..0012c53ed8 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -83,7 +83,7 @@ impl TabBar { } impl ParentElement for TabBar { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/ui/src/components/title_bar/title_bar.rs b/crates/ui/src/components/title_bar/title_bar.rs index 81ebef7f89..ae083e6f90 100644 --- a/crates/ui/src/components/title_bar/title_bar.rs +++ b/crates/ui/src/components/title_bar/title_bar.rs @@ -69,7 +69,7 @@ impl InteractiveElement for TitleBar { impl StatefulInteractiveElement for TitleBar {} impl ParentElement for TitleBar { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 1a057bc20a..a8fbf92257 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -983,7 +983,7 @@ mod element { } impl ParentElement for PaneAxisElement { - fn extend(&mut self, elements: impl Iterator) { + fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements) } }