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 e25fca7aaf6c7c8d4c810251d6fee8561a5d00e7..bd0ae52784bfa93ba1a6e237f374bf69127eb615 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 a4134c677177970f3902d2feea99c4bd1afdb1af..6768e1132bf4926c3e92bb5e3118b075da376b63 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 8efe2c5bb5f48b479477d5591c48f7b2c2b8625b..14dae9cd2c3a3d55dfea4ea8c4cd8c087cb4ebb3 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 d421fd9b9db90144dcd071094125d4fe871c89de..2dc472f801a0f28f20b7614955da0168f50978dc 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 427e6d61ef0b70de7260520a3edd7b3c96d20a66..e573b11d6e6b98275dead65a699aa3ccaade390f 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 27b86849f449c795d0b07984372f53f818822a24..f161521f57d559ef63e2d54008972734528de995 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 e77d42bafe5f124c00cce8e395d5c348ca712dbc..41ad33e588ef18a1afa2a122c491fe82d0d6969e 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 0e795712b40956bc3f49de2a8303fff88f59db3e..2adb2dbf549bd75ebffab152aa852cf802c58e04 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 067dbca17d0ed30284085d8f56ad4eedbb905b61..aa1b96b1cc822193e8df0f0251aee836efd37eab 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 0000000000000000000000000000000000000000..00fbb0a50a340aa0c39a672b6df773053bca9444 --- /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 0000000000000000000000000000000000000000..576637953ebe73fb7a667e6e58f17d32360d66d8 --- /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 0000000000000000000000000000000000000000..9d362ed6bde1b07d5edec1bb07919a95629b6197 --- /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 0000000000000000000000000000000000000000..131a614ef805179e02a8f1203181395aa58c66a8 --- /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 5fc7ac68ed78a7e61d87315ec9817b5372a3653c..1f37267cdb90c5d753d24bcd359b2c5dd38f033c 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 af6cd1541769fb2675c3d80d55b19bf82f80e192..3d7909561468d2183b071875fde6647b2ff5dee3 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 a7a90eecf0ae18a4a44d33fa283eefc1f26d6fd7..4c07ca94b3963897ff47b73925dd2978741a7c88 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 9d102d709138b6271988663acc08a9a1a86dc392..478a906def7d6d0ca42b25af730d74058f130f3c 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 0cab450b25eedf4f87676eba2ff3118e37886ef2..6e0c9e51c6e34faaea6dcb3681338fbc50800a49 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 107f61d5b47469c3a301904e7f5be96c304fdeb9..34e955ec133f053415b5a5a7e63586e613ab4df9 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 fea2a550fa92acd3d6a0cd5908a54de63066eed9..b9932eb401a85d8d15010a65d4dacef7fd1f27e8 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 83def9a1329c0aba60d72fc3a59124d94bae0eb7..ade3abe63e741ac42b35a4cc37526d268b08ddbe 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 c514273288144f82e4ab7e03c8e5d6d2ee899dd1..0012c53ed810819d02e29af2f3e691a74340355e 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 81ebef7f89eb67db81c82d4660838b7b0488f6ec..ae083e6f90060931fd9a09d231d099c40c3bd7ab 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 1a057bc20aaf7d131afddab9369cfcc967a1191d..a8fbf92257bd90edb3cad68f511646e45d4e3cbf 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) } }