From 945764e40945054ce7204d5365c43265e8388d6d Mon Sep 17 00:00:00 2001 From: Ephram Date: Wed, 10 Jul 2024 23:14:34 -0400 Subject: [PATCH] Selectable popover text (#12918) Release Notes: - Fixed #5236 - Added the ability to select and copy text from information popovers https://github.com/zed-industries/zed/assets/50590465/d5c86623-342b-474b-913e-d07cc3f76de4 --------- Co-authored-by: Conrad Irwin Co-authored-by: Antonio --- Cargo.lock | 1 + crates/editor/Cargo.toml | 1 + crates/editor/src/editor.rs | 5 +- crates/editor/src/element.rs | 3 + crates/editor/src/hover_popover.rs | 563 ++++++++---------- crates/gpui/src/text_system/line.rs | 8 +- crates/markdown/examples/markdown.rs | 82 +-- crates/markdown/examples/markdown_as_child.rs | 120 ++++ crates/markdown/src/markdown.rs | 164 ++++- crates/markdown/src/parser.rs | 15 +- crates/project/src/project.rs | 2 +- crates/recent_projects/src/dev_servers.rs | 24 +- 12 files changed, 592 insertions(+), 396 deletions(-) create mode 100644 crates/markdown/examples/markdown_as_child.rs diff --git a/Cargo.lock b/Cargo.lock index 86e023be39..64c34f6ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3605,6 +3605,7 @@ dependencies = [ "linkify", "log", "lsp", + "markdown", "multi_buffer", "ordered-float 2.10.0", "parking_lot", diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 7ab3b729ab..3dae65a5db 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -49,6 +49,7 @@ lazy_static.workspace = true linkify.workspace = true log.workspace = true lsp.workspace = true +markdown.workspace = true multi_buffer.workspace = true ordered-float.workspace = true parking_lot.workspace = true diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4c5aab6ebe..79ac478c29 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11604,8 +11604,11 @@ impl Editor { if let Some(blame) = self.blame.as_ref() { blame.update(cx, GitBlame::blur) } + if !self.hover_state.focused(cx) { + hide_hover(self, cx); + } + self.hide_context_menu(cx); - hide_hover(self, cx); cx.emit(EditorEvent::Blurred); cx.notify(); } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 15a0162008..5d26ecf75a 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3740,6 +3740,9 @@ impl EditorElement { move |event: &MouseMoveEvent, phase, cx| { if phase == DispatchPhase::Bubble { editor.update(cx, |editor, cx| { + if editor.hover_state.focused(cx) { + return; + } if event.pressed_button == Some(MouseButton::Left) || event.pressed_button == Some(MouseButton::Middle) { diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 192c2d5928..8f179f8fdb 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -5,24 +5,26 @@ use crate::{ Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot, EditorStyle, Hover, RangeToAnchorExt, }; -use futures::{stream::FuturesUnordered, FutureExt}; use gpui::{ - div, px, AnyElement, CursorStyle, Hsla, InteractiveElement, IntoElement, MouseButton, - ParentElement, Pixels, ScrollHandle, SharedString, Size, StatefulInteractiveElement, Styled, - Task, ViewContext, WeakView, + div, px, AnyElement, AsyncWindowContext, CursorStyle, FontWeight, Hsla, InteractiveElement, + IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, SharedString, Size, + StatefulInteractiveElement, StyleRefinement, Styled, Task, TextStyleRefinement, View, + ViewContext, WeakView, }; -use language::{markdown, DiagnosticEntry, Language, LanguageRegistry, ParsedMarkdown}; - +use itertools::Itertools; +use language::{DiagnosticEntry, Language, LanguageRegistry}; use lsp::DiagnosticSeverity; +use markdown::{Markdown, MarkdownStyle}; use multi_buffer::ToOffset; -use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; +use project::{HoverBlock, InlayHintLabelPart}; use settings::Settings; -use smol::stream::StreamExt; +use std::rc::Rc; +use std::{borrow::Cow, cell::RefCell}; use std::{ops::Range, sync::Arc, time::Duration}; +use theme::ThemeSettings; use ui::{prelude::*, window_is_transparent, Tooltip}; use util::TryFutureExt; use workspace::Workspace; - pub const HOVER_DELAY_MILLIS: u64 = 350; pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200; @@ -40,6 +42,9 @@ pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext) { /// depending on whether a point to hover over is provided. pub fn hover_at(editor: &mut Editor, anchor: Option, cx: &mut ViewContext) { if EditorSettings::get_global(cx).hover_popover_enabled { + if show_keyboard_hover(editor, cx) { + return; + } if let Some(anchor) = anchor { show_hover(editor, anchor, false, cx); } else { @@ -48,6 +53,20 @@ pub fn hover_at(editor: &mut Editor, anchor: Option, cx: &mut ViewContex } } +pub fn show_keyboard_hover(editor: &mut Editor, cx: &mut ViewContext) -> bool { + let info_popovers = editor.hover_state.info_popovers.clone(); + for p in info_popovers { + let keyboard_grace = p.keyboard_grace.borrow(); + if *keyboard_grace { + if let Some(anchor) = p.anchor { + show_hover(editor, anchor, false, cx); + return true; + } + } + } + return false; +} + pub struct InlayHover { pub range: InlayHighlight, pub tooltip: HoverBlock, @@ -113,12 +132,14 @@ pub fn hover_at_inlay(editor: &mut Editor, inlay_hover: InlayHover, cx: &mut Vie let language_registry = project.update(&mut cx, |p, _| p.languages().clone())?; let blocks = vec![inlay_hover.tooltip]; - let parsed_content = parse_blocks(&blocks, &language_registry, None).await; + let parsed_content = parse_blocks(&blocks, &language_registry, None, &mut cx).await; let hover_popover = InfoPopover { symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()), parsed_content, scroll_handle: ScrollHandle::new(), + keyboard_grace: Rc::new(RefCell::new(false)), + anchor: None, }; this.update(&mut cx, |this, cx| { @@ -291,39 +312,40 @@ fn show_hover( let snapshot = this.update(&mut cx, |this, cx| this.snapshot(cx))?; let mut hover_highlights = Vec::with_capacity(hovers_response.len()); let mut info_popovers = Vec::with_capacity(hovers_response.len()); - let mut info_popover_tasks = hovers_response - .into_iter() - .map(|hover_result| async { - // Create symbol range of anchors for highlighting and filtering of future requests. - let range = hover_result - .range - .and_then(|range| { - let start = snapshot - .buffer_snapshot - .anchor_in_excerpt(excerpt_id, range.start)?; - let end = snapshot - .buffer_snapshot - .anchor_in_excerpt(excerpt_id, range.end)?; + let mut info_popover_tasks = Vec::with_capacity(hovers_response.len()); - Some(start..end) - }) - .unwrap_or_else(|| anchor..anchor); + for hover_result in hovers_response { + // Create symbol range of anchors for highlighting and filtering of future requests. + let range = hover_result + .range + .and_then(|range| { + let start = snapshot + .buffer_snapshot + .anchor_in_excerpt(excerpt_id, range.start)?; + let end = snapshot + .buffer_snapshot + .anchor_in_excerpt(excerpt_id, range.end)?; - let blocks = hover_result.contents; - let language = hover_result.language; - let parsed_content = parse_blocks(&blocks, &language_registry, language).await; + Some(start..end) + }) + .unwrap_or_else(|| anchor..anchor); - ( - range.clone(), - InfoPopover { - symbol_range: RangeInEditor::Text(range), - parsed_content, - scroll_handle: ScrollHandle::new(), - }, - ) - }) - .collect::>(); - while let Some((highlight_range, info_popover)) = info_popover_tasks.next().await { + let blocks = hover_result.contents; + let language = hover_result.language; + let parsed_content = + parse_blocks(&blocks, &language_registry, language, &mut cx).await; + info_popover_tasks.push(( + range.clone(), + InfoPopover { + symbol_range: RangeInEditor::Text(range), + parsed_content, + scroll_handle: ScrollHandle::new(), + keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), + anchor: Some(anchor), + }, + )); + } + for (highlight_range, info_popover) in info_popover_tasks { hover_highlights.push(highlight_range); info_popovers.push(info_popover); } @@ -357,72 +379,81 @@ async fn parse_blocks( blocks: &[HoverBlock], language_registry: &Arc, language: Option>, -) -> markdown::ParsedMarkdown { - let mut text = String::new(); - let mut highlights = Vec::new(); - let mut region_ranges = Vec::new(); - let mut regions = Vec::new(); + cx: &mut AsyncWindowContext, +) -> Option> { + let fallback_language_name = if let Some(ref l) = language { + let l = Arc::clone(l); + Some(l.lsp_id().clone()) + } else { + None + }; - for block in blocks { - match &block.kind { - HoverBlockKind::PlainText => { - markdown::new_paragraph(&mut text, &mut Vec::new()); - text.push_str(&block.text.replace("\\n", "\n")); + let combined_text = blocks + .iter() + .map(|block| match &block.kind { + project::HoverBlockKind::PlainText | project::HoverBlockKind::Markdown => { + Cow::Borrowed(block.text.trim()) } - - HoverBlockKind::Markdown => { - markdown::parse_markdown_block( - &block.text.replace("\\n", "\n"), - language_registry, - language.clone(), - &mut text, - &mut highlights, - &mut region_ranges, - &mut regions, - ) - .await + project::HoverBlockKind::Code { language } => { + Cow::Owned(format!("```{}\n{}\n```", language, block.text.trim())) } + }) + .join("\n\n"); - HoverBlockKind::Code { language } => { - if let Some(language) = language_registry - .language_for_name(language) - .now_or_never() - .and_then(Result::ok) - { - markdown::highlight_code(&mut text, &mut highlights, &block.text, &language); - } else { - text.push_str(&block.text); - } - } - } - } + let rendered_block = cx + .new_view(|cx| { + let settings = ThemeSettings::get_global(cx); + let buffer_font_family = settings.buffer_font.family.clone(); + let mut base_style = cx.text_style(); + base_style.refine(&TextStyleRefinement { + font_family: Some(buffer_font_family.clone()), + color: Some(cx.theme().colors().editor_foreground), + ..Default::default() + }); - let leading_space = text.chars().take_while(|c| c.is_whitespace()).count(); - if leading_space > 0 { - highlights = highlights - .into_iter() - .map(|(range, style)| { - ( - range.start.saturating_sub(leading_space) - ..range.end.saturating_sub(leading_space), - style, - ) - }) - .collect(); - region_ranges = region_ranges - .into_iter() - .map(|range| { - range.start.saturating_sub(leading_space)..range.end.saturating_sub(leading_space) - }) - .collect(); - } + let markdown_style = MarkdownStyle { + base_text_style: base_style, + code_block: StyleRefinement::default().mt(rems(1.)).mb(rems(1.)), + inline_code: TextStyleRefinement { + background_color: Some(cx.theme().colors().background), + ..Default::default() + }, + rule_color: Color::Muted.color(cx), + block_quote_border_color: Color::Muted.color(cx), + block_quote: TextStyleRefinement { + color: Some(Color::Muted.color(cx)), + ..Default::default() + }, + link: TextStyleRefinement { + color: Some(cx.theme().colors().editor_foreground), + underline: Some(gpui::UnderlineStyle { + thickness: px(1.), + color: Some(cx.theme().colors().editor_foreground), + wavy: false, + }), + ..Default::default() + }, + syntax: cx.theme().syntax().clone(), + selection_background_color: { cx.theme().players().local().selection }, + break_style: Default::default(), + heading: StyleRefinement::default() + .font_weight(FontWeight::BOLD) + .text_base() + .mt(rems(1.)) + .mb_0(), + }; - ParsedMarkdown { - text: text.trim().to_string(), - highlights, - region_ranges, - regions, - } + Markdown::new( + combined_text, + markdown_style.clone(), + Some(language_registry.clone()), + cx, + fallback_language_name, + ) + }) + .ok(); + + rendered_block } #[derive(Default, Debug)] @@ -444,7 +475,7 @@ impl HoverState { style: &EditorStyle, visible_rows: Range, max_size: Size, - workspace: Option>, + _workspace: Option>, cx: &mut ViewContext, ) -> Option<(DisplayPoint, Vec)> { // If there is a diagnostic, position the popovers based on that. @@ -482,29 +513,39 @@ impl HoverState { elements.push(diagnostic_popover.render(style, max_size, cx)); } for info_popover in &mut self.info_popovers { - elements.push(info_popover.render(style, max_size, workspace.clone(), cx)); + elements.push(info_popover.render(max_size, cx)); } Some((point, elements)) } + + pub fn focused(&self, cx: &mut ViewContext) -> bool { + let mut hover_popover_is_focused = false; + for info_popover in &self.info_popovers { + for markdown_view in &info_popover.parsed_content { + if markdown_view.focus_handle(cx).is_focused(cx) { + hover_popover_is_focused = true; + } + } + } + return hover_popover_is_focused; + } } -#[derive(Clone, Debug)] +#[derive(Debug, Clone)] + pub struct InfoPopover { pub symbol_range: RangeInEditor, - pub parsed_content: ParsedMarkdown, + pub parsed_content: Option>, pub scroll_handle: ScrollHandle, + pub keyboard_grace: Rc>, + pub anchor: Option, } impl InfoPopover { - pub fn render( - &mut self, - style: &EditorStyle, - max_size: Size, - workspace: Option>, - cx: &mut ViewContext, - ) -> AnyElement { - div() + pub fn render(&mut self, max_size: Size, cx: &mut ViewContext) -> AnyElement { + let keyboard_grace = Rc::clone(&self.keyboard_grace); + let mut d = div() .id("info_popover") .elevation_2(cx) .overflow_y_scroll() @@ -514,15 +555,17 @@ impl InfoPopover { // Prevent a mouse down/move on the popover from being propagated to the editor, // because that would dismiss the popover. .on_mouse_move(|_, cx| cx.stop_propagation()) - .on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation()) - .child(div().p_2().child(crate::render_parsed_markdown( - "content", - &self.parsed_content, - style, - workspace, - cx, - ))) - .into_any_element() + .on_mouse_down(MouseButton::Left, move |_, cx| { + let mut keyboard_grace = keyboard_grace.borrow_mut(); + *keyboard_grace = false; + cx.stop_propagation(); + }) + .p_2(); + + if let Some(markdown) = &self.parsed_content { + d = d.child(markdown.clone()); + } + d.into_any_element() } pub fn scroll(&self, amount: &ScrollAmount, cx: &mut ViewContext) { @@ -642,17 +685,33 @@ mod tests { InlayId, PointForPosition, }; use collections::BTreeSet; - use gpui::{FontWeight, HighlightStyle, UnderlineStyle}; use indoc::indoc; use language::{language_settings::InlayHintSettings, Diagnostic, DiagnosticSet}; use lsp::LanguageServerId; - use project::{HoverBlock, HoverBlockKind}; + use markdown::parser::MarkdownEvent; use smol::stream::StreamExt; use std::sync::atomic; use std::sync::atomic::AtomicUsize; use text::Bias; - use unindent::Unindent; - use util::test::marked_text_ranges; + + impl InfoPopover { + fn get_rendered_text(&self, cx: &gpui::AppContext) -> String { + let mut rendered_text = String::new(); + if let Some(parsed_content) = self.parsed_content.clone() { + let markdown = parsed_content.read(cx); + let text = markdown.parsed_markdown().source().to_string(); + let data = markdown.parsed_markdown().events(); + let slice = data; + + for (range, event) in slice.iter() { + if [MarkdownEvent::Text, MarkdownEvent::Code].contains(event) { + rendered_text.push_str(&text[range.clone()]) + } + } + } + rendered_text + } + } #[gpui::test] async fn test_mouse_hover_info_popover_with_autocomplete_popover( @@ -736,7 +795,7 @@ mod tests { .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100)); requests.next().await; - cx.editor(|editor, _| { + cx.editor(|editor, cx| { assert!(editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popovers.len(), @@ -744,14 +803,13 @@ mod tests { "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers ); - let rendered = editor + let rendered_text = editor .hover_state .info_popovers .first() - .cloned() .unwrap() - .parsed_content; - assert_eq!(rendered.text, "some basic docs".to_string()) + .get_rendered_text(cx); + assert_eq!(rendered_text, "some basic docs".to_string()) }); // check that the completion menu is still visible and that there still has only been 1 completion request @@ -777,7 +835,7 @@ mod tests { assert_eq!(counter.load(atomic::Ordering::Acquire), 1); //verify the information popover is still visible and unchanged - cx.editor(|editor, _| { + cx.editor(|editor, cx| { assert!(editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popovers.len(), @@ -785,14 +843,14 @@ mod tests { "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers ); - let rendered = editor + let rendered_text = editor .hover_state .info_popovers .first() - .cloned() .unwrap() - .parsed_content; - assert_eq!(rendered.text, "some basic docs".to_string()) + .get_rendered_text(cx); + + assert_eq!(rendered_text, "some basic docs".to_string()) }); // Mouse moved with no hover response dismisses @@ -870,7 +928,7 @@ mod tests { .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100)); requests.next().await; - cx.editor(|editor, _| { + cx.editor(|editor, cx| { assert!(editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popovers.len(), @@ -878,14 +936,14 @@ mod tests { "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers ); - let rendered = editor + let rendered_text = editor .hover_state .info_popovers .first() - .cloned() .unwrap() - .parsed_content; - assert_eq!(rendered.text, "some basic docs".to_string()) + .get_rendered_text(cx); + + assert_eq!(rendered_text, "some basic docs".to_string()) }); // Mouse moved with no hover response dismisses @@ -931,34 +989,49 @@ mod tests { let symbol_range = cx.lsp_range(indoc! {" «fn» test() { println!(); } "}); - cx.handle_request::(move |_, _, _| async move { - Ok(Some(lsp::Hover { - contents: lsp::HoverContents::Markup(lsp::MarkupContent { - kind: lsp::MarkupKind::Markdown, - value: "some other basic docs".to_string(), - }), - range: Some(symbol_range), - })) - }) - .next() - .await; + + cx.editor(|editor, _cx| { + assert!(!editor.hover_state.visible()); + + assert_eq!( + editor.hover_state.info_popovers.len(), + 0, + "Expected no hovers but got but got: {:?}", + editor.hover_state.info_popovers + ); + }); + + let mut requests = + cx.handle_request::(move |_, _, _| async move { + Ok(Some(lsp::Hover { + contents: lsp::HoverContents::Markup(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "some other basic docs".to_string(), + }), + range: Some(symbol_range), + })) + }); + + requests.next().await; + cx.dispatch_action(Hover); cx.condition(|editor, _| editor.hover_state.visible()).await; - cx.editor(|editor, _| { + cx.editor(|editor, cx| { assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers ); - let rendered = editor + + let rendered_text = editor .hover_state .info_popovers .first() - .cloned() .unwrap() - .parsed_content; - assert_eq!(rendered.text, "some other basic docs".to_string()) + .get_rendered_text(cx); + + assert_eq!(rendered_text, "some other basic docs".to_string()) }); } @@ -998,24 +1071,25 @@ mod tests { }) .next() .await; + cx.dispatch_action(Hover); cx.condition(|editor, _| editor.hover_state.visible()).await; - cx.editor(|editor, _| { + cx.editor(|editor, cx| { assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers ); - let rendered = editor + let rendered_text = editor .hover_state .info_popovers .first() - .cloned() .unwrap() - .parsed_content; + .get_rendered_text(cx); + assert_eq!( - rendered.text, + rendered_text, "regular text for hover to show".to_string(), "No empty string hovers should be shown" ); @@ -1063,24 +1137,25 @@ mod tests { .next() .await; + cx.dispatch_action(Hover); + cx.condition(|editor, _| editor.hover_state.visible()).await; - cx.editor(|editor, _| { + cx.editor(|editor, cx| { assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers ); - let rendered = editor + let rendered_text = editor .hover_state .info_popovers .first() - .cloned() .unwrap() - .parsed_content; + .get_rendered_text(cx); + assert_eq!( - rendered.text, - code_str.trim(), + rendered_text, code_str, "Should not have extra line breaks at end of rendered hover" ); }); @@ -1156,153 +1231,6 @@ mod tests { }); } - #[gpui::test] - fn test_render_blocks(cx: &mut gpui::TestAppContext) { - init_test(cx, |_| {}); - - let languages = Arc::new(LanguageRegistry::test(cx.executor())); - let editor = cx.add_window(|cx| Editor::single_line(cx)); - editor - .update(cx, |editor, _cx| { - let style = editor.style.clone().unwrap(); - - struct Row { - blocks: Vec, - expected_marked_text: String, - expected_styles: Vec, - } - - let rows = &[ - // Strong emphasis - Row { - blocks: vec![HoverBlock { - text: "one **two** three".to_string(), - kind: HoverBlockKind::Markdown, - }], - expected_marked_text: "one «two» three".to_string(), - expected_styles: vec![HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..Default::default() - }], - }, - // Links - Row { - blocks: vec![HoverBlock { - text: "one [two](https://the-url) three".to_string(), - kind: HoverBlockKind::Markdown, - }], - expected_marked_text: "one «two» three".to_string(), - expected_styles: vec![HighlightStyle { - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - }], - }, - // Lists - Row { - blocks: vec![HoverBlock { - text: " - lists: - * one - - a - - b - * two - - [c](https://the-url) - - d" - .unindent(), - kind: HoverBlockKind::Markdown, - }], - expected_marked_text: " - lists: - - one - - a - - b - - two - - «c» - - d" - .unindent(), - expected_styles: vec![HighlightStyle { - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - }], - }, - // Multi-paragraph list items - Row { - blocks: vec![HoverBlock { - text: " - * one two - three - - * four five - * six seven - eight - - nine - * ten - * six" - .unindent(), - kind: HoverBlockKind::Markdown, - }], - expected_marked_text: " - - one two three - - four five - - six seven eight - - nine - - ten - - six" - .unindent(), - expected_styles: vec![HighlightStyle { - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - }], - }, - ]; - - for Row { - blocks, - expected_marked_text, - expected_styles, - } in &rows[0..] - { - let rendered = smol::block_on(parse_blocks(&blocks, &languages, None)); - - let (expected_text, ranges) = marked_text_ranges(expected_marked_text, false); - let expected_highlights = ranges - .into_iter() - .zip(expected_styles.iter().cloned()) - .collect::>(); - assert_eq!( - rendered.text, expected_text, - "wrong text for input {blocks:?}" - ); - - let rendered_highlights: Vec<_> = rendered - .highlights - .iter() - .filter_map(|(range, highlight)| { - let highlight = highlight.to_highlight_style(&style.syntax)?; - Some((range.clone(), highlight)) - }) - .collect(); - - assert_eq!( - rendered_highlights, expected_highlights, - "wrong highlights for input {blocks:?}" - ); - } - }) - .unwrap(); - } - #[gpui::test] async fn test_hover_inlay_label_parts(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { @@ -1546,9 +1474,8 @@ mod tests { "Popover range should match the new type label part" ); assert_eq!( - popover.parsed_content.text, - format!("A tooltip for `{new_type_label}`"), - "Rendered text should not anyhow alter backticks" + popover.get_rendered_text(cx), + format!("A tooltip for {new_type_label}"), ); }); @@ -1602,7 +1529,7 @@ mod tests { "Popover range should match the struct label part" ); assert_eq!( - popover.parsed_content.text, + popover.get_rendered_text(cx), format!("A tooltip for {struct_label}"), "Rendered markdown element should remove backticks from text" ); diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index a9a52f0757..240654e57e 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -109,7 +109,13 @@ fn paint_line( wrap_boundaries: &[WrapBoundary], cx: &mut WindowContext, ) -> Result<()> { - let line_bounds = Bounds::new(origin, size(layout.width, line_height)); + let line_bounds = Bounds::new( + origin, + size( + layout.width, + line_height * (wrap_boundaries.len() as f32 + 1.), + ), + ); cx.paint_layer(line_bounds, |cx| { let padding_top = (line_height - layout.ascent - layout.descent) / 2.; let baseline_offset = point(px(0.), padding_top + layout.ascent); diff --git a/crates/markdown/examples/markdown.rs b/crates/markdown/examples/markdown.rs index 3161d57183..19314df313 100644 --- a/crates/markdown/examples/markdown.rs +++ b/crates/markdown/examples/markdown.rs @@ -1,5 +1,5 @@ use assets::Assets; -use gpui::{prelude::*, App, KeyBinding, Task, View, WindowOptions}; +use gpui::{prelude::*, rgb, App, KeyBinding, StyleRefinement, Task, View, WindowOptions}; use language::{language_settings::AllLanguageSettings, LanguageRegistry}; use markdown::{Markdown, MarkdownStyle}; use node_runtime::FakeNodeRuntime; @@ -105,44 +105,49 @@ pub fn main() { cx.activate(true); cx.open_window(WindowOptions::default(), |cx| { cx.new_view(|cx| { + let markdown_style = MarkdownStyle { + base_text_style: gpui::TextStyle { + font_family: "Zed Plex Sans".into(), + color: cx.theme().colors().terminal_ansi_black, + ..Default::default() + }, + code_block: StyleRefinement::default() + .font_family("Zed Plex Mono") + .m(rems(1.)) + .bg(rgb(0xAAAAAAA)), + inline_code: 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() + }, + 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 + }, + ..Default::default() + }; + MarkdownExample::new( MARKDOWN_EXAMPLE.to_string(), - MarkdownStyle { - code_block: gpui::TextStyleRefinement { - font_family: Some("Zed Plex 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 Plex 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 - }, - }, + markdown_style, language_registry, cx, ) @@ -163,7 +168,8 @@ impl MarkdownExample { language_registry: Arc, cx: &mut WindowContext, ) -> Self { - let markdown = cx.new_view(|cx| Markdown::new(text, style, Some(language_registry), cx)); + let markdown = + cx.new_view(|cx| Markdown::new(text, style, Some(language_registry), cx, None)); Self { markdown } } } diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs new file mode 100644 index 0000000000..86ebb1651c --- /dev/null +++ b/crates/markdown/examples/markdown_as_child.rs @@ -0,0 +1,120 @@ +use assets::Assets; +use gpui::*; +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::div; +use ui::prelude::*; + +const MARKDOWN_EXAMPLE: &'static str = r#" +this text should be selectable + +wow so cool + +## Heading 2 +"#; +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, |_| {}); + }); + cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]); + + 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); + let _ = cx.open_window(WindowOptions::default(), |cx| { + cx.new_view(|cx| { + let markdown_style = MarkdownStyle { + base_text_style: gpui::TextStyle { + font_family: "Zed Mono".into(), + color: cx.theme().colors().text, + ..Default::default() + }, + code_block: StyleRefinement { + text: Some(gpui::TextStyleRefinement { + font_family: Some("Zed Mono".into()), + background_color: Some(cx.theme().colors().editor_background), + ..Default::default() + }), + margin: gpui::EdgesRefinement { + top: Some(Length::Definite(rems(4.).into())), + left: Some(Length::Definite(rems(4.).into())), + right: Some(Length::Definite(rems(4.).into())), + bottom: Some(Length::Definite(rems(4.).into())), + }, + ..Default::default() + }, + inline_code: gpui::TextStyleRefinement { + font_family: Some("Zed Mono".into()), + 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 + }, + break_style: Default::default(), + heading: Default::default(), + }; + let markdown = cx.new_view(|cx| { + Markdown::new(MARKDOWN_EXAMPLE.into(), markdown_style, None, cx, None) + }); + + HelloWorld { markdown } + }) + }); + }); +} +struct HelloWorld { + markdown: View, +} + +impl Render for HelloWorld { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + div() + .flex() + .bg(rgb(0x2e7d32)) + .size(Length::Definite(Pixels(700.0).into())) + .justify_center() + .items_center() + .shadow_lg() + .border_1() + .border_color(rgb(0x0000ff)) + .text_xl() + .text_color(rgb(0xffffff)) + .child(div().child(self.markdown.clone()).p_20()) + } +} diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index c2754a4f75..c2c46b92fa 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1,16 +1,17 @@ -mod parser; +pub mod parser; use crate::parser::CodeBlockKind; use futures::FutureExt; use gpui::{ actions, point, quad, AnyElement, AppContext, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, FocusHandle, FocusableView, FontStyle, FontWeight, GlobalElementId, - Hitbox, Hsla, KeyContext, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, Point, - Render, StrikethroughStyle, Style, StyledText, Task, TextLayout, TextRun, TextStyle, - TextStyleRefinement, View, + Hitbox, Hsla, KeyContext, Length, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, + Point, Render, StrikethroughStyle, StyleRefinement, 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::*; @@ -18,7 +19,8 @@ use util::{ResultExt, TryFutureExt}; #[derive(Clone)] pub struct MarkdownStyle { - pub code_block: TextStyleRefinement, + pub base_text_style: TextStyle, + pub code_block: StyleRefinement, pub inline_code: TextStyleRefinement, pub block_quote: TextStyleRefinement, pub link: TextStyleRefinement, @@ -26,8 +28,27 @@ pub struct MarkdownStyle { pub block_quote_border_color: Hsla, pub syntax: Arc, pub selection_background_color: Hsla, + pub break_style: StyleRefinement, + pub heading: StyleRefinement, } +impl Default for MarkdownStyle { + fn default() -> Self { + Self { + base_text_style: Default::default(), + code_block: Default::default(), + inline_code: Default::default(), + block_quote: Default::default(), + link: Default::default(), + rule_color: Default::default(), + block_quote_border_color: Default::default(), + syntax: Arc::new(SyntaxTheme::default()), + selection_background_color: Default::default(), + break_style: Default::default(), + heading: Default::default(), + } + } +} pub struct Markdown { source: String, selection: Selection, @@ -39,6 +60,7 @@ pub struct Markdown { pending_parse: Option>>, focus_handle: FocusHandle, language_registry: Option>, + fallback_code_block_language: Option, } actions!(markdown, [Copy]); @@ -49,6 +71,7 @@ impl Markdown { style: MarkdownStyle, language_registry: Option>, cx: &mut ViewContext, + fallback_code_block_language: Option, ) -> Self { let focus_handle = cx.focus_handle(); let mut this = Self { @@ -62,6 +85,7 @@ impl Markdown { pending_parse: None, focus_handle, language_registry, + fallback_code_block_language, }; this.parse(cx); this @@ -89,7 +113,14 @@ impl Markdown { &self.source } + pub fn parsed_markdown(&self) -> &ParsedMarkdown { + &self.parsed_markdown + } + fn copy(&self, text: &RenderedText, cx: &mut ViewContext) { + if self.selection.end <= self.selection.start { + return; + } let text = text.text_for_range(self.selection.start..self.selection.end); cx.write_to_clipboard(ClipboardItem::new(text)); } @@ -140,6 +171,7 @@ impl Render for Markdown { cx.view().clone(), self.style.clone(), self.language_registry.clone(), + self.fallback_code_block_language.clone(), ) } } @@ -185,11 +217,21 @@ impl Selection { } #[derive(Clone)] -struct ParsedMarkdown { +pub struct ParsedMarkdown { source: SharedString, events: Arc<[(Range, MarkdownEvent)]>, } +impl ParsedMarkdown { + pub fn source(&self) -> &SharedString { + &self.source + } + + pub fn events(&self) -> &Arc<[(Range, MarkdownEvent)]> { + return &self.events; + } +} + impl Default for ParsedMarkdown { fn default() -> Self { Self { @@ -203,6 +245,7 @@ pub struct MarkdownElement { markdown: View, style: MarkdownStyle, language_registry: Option>, + fallback_code_block_language: Option, } impl MarkdownElement { @@ -210,19 +253,31 @@ impl MarkdownElement { markdown: View, style: MarkdownStyle, language_registry: Option>, + fallback_code_block_language: Option, ) -> Self { Self { markdown, style, language_registry, + fallback_code_block_language, } } fn load_language(&self, name: &str, cx: &mut WindowContext) -> Option> { + let language_test = self.language_registry.as_ref()?.language_for_name(name); + + let language_name = match language_test.now_or_never() { + Some(Ok(_)) => String::from(name), + Some(Err(_)) if !name.is_empty() && self.fallback_code_block_language.is_some() => { + self.fallback_code_block_language.clone().unwrap() + } + _ => String::new(), + }; + let language = self .language_registry .as_ref()? - .language_for_name(name) + .language_for_name(language_name.as_str()) .map(|language| language.ok()) .shared(); @@ -417,7 +472,7 @@ impl MarkdownElement { .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 text_style = self.style.base_text_style.clone(); 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 @@ -462,14 +517,26 @@ impl Element for MarkdownElement { _id: Option<&GlobalElementId>, cx: &mut WindowContext, ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mut builder = MarkdownElementBuilder::new(cx.text_style(), self.style.syntax.clone()); + let mut builder = MarkdownElementBuilder::new( + self.style.base_text_style.clone(), + self.style.syntax.clone(), + ); let parsed_markdown = self.markdown.read(cx).parsed_markdown.clone(); + let markdown_end = if let Some(last) = parsed_markdown.events.last() { + last.0.end + } else { + 0 + }; 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))); + builder.push_div( + div().mb_2().line_height(rems(1.3)), + range, + markdown_end, + ); } MarkdownTag::Heading { level, .. } => { let mut heading = div().mb_2(); @@ -480,7 +547,11 @@ impl Element for MarkdownElement { pulldown_cmark::HeadingLevel::H4 => heading.text_lg(), _ => heading, }; - builder.push_div(heading); + heading.style().refine(&self.style.heading); + builder.push_text_style( + self.style.heading.text_style().clone().unwrap_or_default(), + ); + builder.push_div(heading, range, markdown_end); } MarkdownTag::BlockQuote => { builder.push_text_style(self.style.block_quote.clone()); @@ -490,6 +561,8 @@ impl Element for MarkdownElement { .mb_2() .border_l_4() .border_color(self.style.block_quote_border_color), + range, + markdown_end, ); } MarkdownTag::CodeBlock(kind) => { @@ -499,17 +572,18 @@ impl Element for MarkdownElement { None }; + let mut d = div().w_full().rounded_lg(); + d.style().refine(&self.style.code_block); + if let Some(code_block_text_style) = &self.style.code_block.text { + builder.push_text_style(code_block_text_style.to_owned()); + } 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), - )); + builder.push_div(d, range, markdown_end); } - MarkdownTag::HtmlBlock => builder.push_div(div()), + MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end), MarkdownTag::List(bullet_index) => { builder.push_list(*bullet_index); - builder.push_div(div().pl_4()); + builder.push_div(div().pl_4(), range, markdown_end); } MarkdownTag::Item => { let bullet = if let Some(bullet_index) = builder.next_bullet_index() { @@ -525,9 +599,11 @@ impl Element for MarkdownElement { .items_start() .gap_1() .child(bullet), + range, + markdown_end, ); // Without `w_0`, text doesn't wrap to the width of the container. - builder.push_div(div().flex_1().w_0()); + builder.push_div(div().flex_1().w_0(), range, markdown_end); } MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement { font_style: Some(FontStyle::Italic), @@ -552,6 +628,7 @@ impl Element for MarkdownElement { builder.push_text_style(self.style.link.clone()) } } + MarkdownTag::MetadataBlock(_) => {} _ => log::error!("unsupported markdown tag {:?}", tag), } } @@ -559,7 +636,10 @@ impl Element for MarkdownElement { MarkdownTagEnd::Paragraph => { builder.pop_div(); } - MarkdownTagEnd::Heading(_) => builder.pop_div(), + MarkdownTagEnd::Heading(_) => { + builder.pop_div(); + builder.pop_text_style() + } MarkdownTagEnd::BlockQuote => { builder.pop_text_style(); builder.pop_div() @@ -567,8 +647,10 @@ impl Element for MarkdownElement { MarkdownTagEnd::CodeBlock => { builder.trim_trailing_newline(); builder.pop_div(); - builder.pop_text_style(); builder.pop_code_block(); + if self.style.code_block.text.is_some() { + builder.pop_text_style(); + } } MarkdownTagEnd::HtmlBlock => builder.pop_div(), MarkdownTagEnd::List(_) => { @@ -609,18 +691,24 @@ impl Element for MarkdownElement { .border_b_1() .my_2() .border_color(self.style.rule_color), + range, + markdown_end, ); builder.pop_div() } - MarkdownEvent::SoftBreak => builder.push_text("\n", range.start), - MarkdownEvent::HardBreak => builder.push_text("\n", range.start), + MarkdownEvent::SoftBreak => builder.push_text(" ", range.start), + MarkdownEvent::HardBreak => { + let mut d = div().py_3(); + d.style().refine(&self.style.break_style); + builder.push_div(d, range, markdown_end); + builder.pop_div() + } _ => 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]); + let layout_id = cx.request_layout(gpui::Style::default(), [child_layout_id]); (layout_id, rendered_markdown) } @@ -732,8 +820,32 @@ impl MarkdownElementBuilder { self.text_style_stack.pop(); } - fn push_div(&mut self, div: Div) { + fn push_div(&mut self, mut div: Div, range: &Range, markdown_end: usize) { self.flush_text(); + + if range.start == 0 { + //first element, remove top margin + div.style().refine(&StyleRefinement { + margin: gpui::EdgesRefinement { + top: Some(Length::Definite(px(0.).into())), + left: None, + right: None, + bottom: None, + }, + ..Default::default() + }); + } + if range.end == markdown_end { + div.style().refine(&StyleRefinement { + margin: gpui::EdgesRefinement { + top: None, + left: None, + right: None, + bottom: Some(Length::Definite(rems(0.).into())), + }, + ..Default::default() + }); + } self.div_stack.push(div); } diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index 3626b5e2a4..7ea9542a8b 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -7,11 +7,22 @@ use std::ops::Range; pub fn parse_markdown(text: &str) -> Vec<(Range, MarkdownEvent)> { let mut events = Vec::new(); let mut within_link = false; + let mut within_metadata = false; for (pulldown_event, mut range) in Parser::new_ext(text, Options::all()).into_offset_iter() { + if within_metadata { + if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock { .. }) = + pulldown_event + { + within_metadata = false; + } + continue; + } match pulldown_event { pulldown_cmark::Event::Start(tag) => { - if let pulldown_cmark::Tag::Link { .. } = tag { - within_link = true; + match tag { + pulldown_cmark::Tag::Link { .. } => within_link = true, + pulldown_cmark::Tag::MetadataBlock { .. } => within_metadata = true, + _ => {} } events.push((range, MarkdownEvent::Start(tag.into()))) } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 80edc90659..a34b5fdad7 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -5801,7 +5801,7 @@ impl Project { .await .into_iter() .filter_map(|hover| remove_empty_hover_blocks(hover?)) - .collect() + .collect::>() }) } else if let Some(project_id) = self.remote_id() { let request_task = self.client().request(proto::MultiLspQuery { diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 96968510c0..12c1d04028 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -114,25 +114,31 @@ impl DevServerProjects { cx.notify(); }); + let mut base_style = cx.text_style(); + base_style.refine(&gpui::TextStyleRefinement { + color: Some(cx.theme().colors().editor_foreground), + ..Default::default() + }); + let markdown_style = MarkdownStyle { - code_block: gpui::TextStyleRefinement { - font_family: Some("Zed Plex Mono".into()), - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(cx.theme().colors().editor_background), + base_text_style: base_style, + code_block: gpui::StyleRefinement { + text: Some(gpui::TextStyleRefinement { + font_family: Some("Zed Plex Mono".into()), + ..Default::default() + }), ..Default::default() }, - inline_code: Default::default(), - block_quote: Default::default(), link: gpui::TextStyleRefinement { color: Some(Color::Accent.color(cx)), ..Default::default() }, - rule_color: Default::default(), - block_quote_border_color: Default::default(), syntax: cx.theme().syntax().clone(), selection_background_color: cx.theme().players().local().selection, + ..Default::default() }; - let markdown = cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx)); + let markdown = + cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx, None)); Self { mode: Mode::Default(None),