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 <conrad.irwin@gmail.com>
Co-authored-by: Antonio <ascii@zed.dev>
This commit is contained in:
Ephram 2024-07-10 23:14:34 -04:00 committed by GitHub
parent f1281c14dd
commit 945764e409
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 592 additions and 396 deletions

1
Cargo.lock generated
View File

@ -3605,6 +3605,7 @@ dependencies = [
"linkify",
"log",
"lsp",
"markdown",
"multi_buffer",
"ordered-float 2.10.0",
"parking_lot",

View File

@ -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

View File

@ -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();
}

View File

@ -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)
{

View File

@ -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<Editor>) {
/// depending on whether a point to hover over is provided.
pub fn hover_at(editor: &mut Editor, anchor: Option<Anchor>, cx: &mut ViewContext<Editor>) {
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<Anchor>, cx: &mut ViewContex
}
}
pub fn show_keyboard_hover(editor: &mut Editor, cx: &mut ViewContext<Editor>) -> 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::<FuturesUnordered<_>>();
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<LanguageRegistry>,
language: Option<Arc<Language>>,
) -> 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<View<Markdown>> {
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<DisplayRow>,
max_size: Size<Pixels>,
workspace: Option<WeakView<Workspace>>,
_workspace: Option<WeakView<Workspace>>,
cx: &mut ViewContext<Editor>,
) -> Option<(DisplayPoint, Vec<AnyElement>)> {
// 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<Editor>) -> 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<View<Markdown>>,
pub scroll_handle: ScrollHandle,
pub keyboard_grace: Rc<RefCell<bool>>,
pub anchor: Option<Anchor>,
}
impl InfoPopover {
pub fn render(
&mut self,
style: &EditorStyle,
max_size: Size<Pixels>,
workspace: Option<WeakView<Workspace>>,
cx: &mut ViewContext<Editor>,
) -> AnyElement {
div()
pub fn render(&mut self, max_size: Size<Pixels>, cx: &mut ViewContext<Editor>) -> 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<Editor>) {
@ -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::<lsp::request::HoverRequest, _, _>(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::<lsp::request::HoverRequest, _, _>(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<HoverBlock>,
expected_marked_text: String,
expected_styles: Vec<HighlightStyle>,
}
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::<Vec<_>>();
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"
);

View File

@ -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);

View File

@ -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<LanguageRegistry>,
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 }
}
}

View File

@ -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::<AllLanguageSettings>(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<Markdown>,
}
impl Render for HelloWorld {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> 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())
}
}

View File

@ -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<SyntaxTheme>,
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<Task<Option<()>>>,
focus_handle: FocusHandle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
}
actions!(markdown, [Copy]);
@ -49,6 +71,7 @@ impl Markdown {
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut ViewContext<Self>,
fallback_code_block_language: Option<String>,
) -> 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<Self>) {
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<usize>, MarkdownEvent)]>,
}
impl ParsedMarkdown {
pub fn source(&self) -> &SharedString {
&self.source
}
pub fn events(&self) -> &Arc<[(Range<usize>, MarkdownEvent)]> {
return &self.events;
}
}
impl Default for ParsedMarkdown {
fn default() -> Self {
Self {
@ -203,6 +245,7 @@ pub struct MarkdownElement {
markdown: View<Markdown>,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
}
impl MarkdownElement {
@ -210,19 +253,31 @@ impl MarkdownElement {
markdown: View<Markdown>,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
) -> Self {
Self {
markdown,
style,
language_registry,
fallback_code_block_language,
}
}
fn load_language(&self, name: &str, cx: &mut WindowContext) -> Option<Arc<Language>> {
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<usize>, 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);
}

View File

@ -7,11 +7,22 @@ use std::ops::Range;
pub fn parse_markdown(text: &str) -> Vec<(Range<usize>, 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())))
}

View File

@ -5801,7 +5801,7 @@ impl Project {
.await
.into_iter()
.filter_map(|hover| remove_empty_hover_blocks(hover?))
.collect()
.collect::<Vec<Hover>>()
})
} else if let Some(project_id) = self.remote_id() {
let request_task = self.client().request(proto::MultiLspQuery {

View File

@ -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),