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) } }