diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index e048c26425..62368c5db4 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -39,6 +39,7 @@ use gpui::{ MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledImage, StyledText, Subscription, Task, TextAlign, TextLayout, TextRun, TextStyle, TextStyleRefinement, WrappedLineLayout, actions, canvas, img, point, quad, relative, + size, }; use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; @@ -110,6 +111,7 @@ pub struct MarkdownStyle { pub code_block: StyleRefinement, pub code_block_overflow_x_scroll: bool, pub inline_code: TextStyleRefinement, + pub inline_code_corner_radius: Pixels, pub block_quote: TextStyleRefinement, pub link: TextStyleRefinement, pub link_callback: Option, @@ -121,6 +123,12 @@ pub struct MarkdownStyle { pub heading: StyleRefinement, pub heading_level_styles: Option, pub heading_border_color: Option, + pub paragraph_spacing: Pixels, + pub paragraph_line_height: DefiniteLength, + /// Bottom margin of top-level lists only + pub list_spacing: Pixels, + /// Horizontal (`x`) and vertical (`y`) padding of table cells + pub table_cell_padding: Point, pub height_is_multiple_of_line_height: bool, pub prevent_mouse_interaction: bool, pub table_columns_min_size: bool, @@ -135,6 +143,7 @@ impl Default for MarkdownStyle { code_block: Default::default(), code_block_overflow_x_scroll: false, inline_code: Default::default(), + inline_code_corner_radius: px(0.), block_quote: Default::default(), link: Default::default(), link_callback: None, @@ -146,6 +155,10 @@ impl Default for MarkdownStyle { heading: Default::default(), heading_level_styles: None, heading_border_color: None, + paragraph_spacing: px(8.), + paragraph_line_height: rems(1.3).into(), + list_spacing: px(0.), + table_cell_padding: point(px(4.), px(2.)), height_is_multiple_of_line_height: false, prevent_mouse_interaction: false, table_columns_min_size: false, @@ -334,38 +347,58 @@ impl MarkdownStyle { } fn with_preview_overrides(mut self, colors: &theme::ThemeColors) -> Self { - let body_font_size = rems(0.92); + let body_font_size = rems(1.0); self.base_text_style.font_size = body_font_size.into(); self.container_style.text.font_size = Some(body_font_size.into()); - self.base_text_style.color = colors.text_muted.blend(colors.text.opacity(0.25)); - self.inline_code.color = Some(colors.text); - self.heading.text.color = Some(colors.text); + self.base_text_style.color = colors.text; + self.base_text_style.line_height = relative(1.5); + self.paragraph_spacing = px(16.); + self.paragraph_line_height = relative(1.5); + self.list_spacing = px(12.); + self.table_cell_padding = point(px(10.), px(4.)); + self.inline_code.color = Some(colors.text); + self.inline_code.font_size = Some(rems(0.875).into()); + self.inline_code_corner_radius = px(4.); + + self.link.background_color = None; + + self.block_quote.color = Some(colors.text_muted); + + self.code_block.padding = EdgesRefinement { + top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(12.)))), + left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(12.)))), + right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(12.)))), + bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(12.)))), + }; + self.code_block.margin.top = Some(Length::Definite(px(16.).into())); + self.code_block.margin.bottom = Some(Length::Definite(px(16.).into())); + let code_block_corner_radius = AbsoluteLength::Pixels(px(6.)); + self.code_block.corner_radii.top_left = Some(code_block_corner_radius); + self.code_block.corner_radii.top_right = Some(code_block_corner_radius); + self.code_block.corner_radii.bottom_left = Some(code_block_corner_radius); + self.code_block.corner_radii.bottom_right = Some(code_block_corner_radius); + + self.heading.text.color = Some(colors.text); + self.heading.margin.top = Some(Length::Definite(px(24.).into())); + self.heading.margin.bottom = Some(Length::Definite(px(12.).into())); + + let heading_text_style = |font_size: Rems| TextStyleRefinement { + font_size: Some(font_size.into()), + font_weight: Some(FontWeight::SEMIBOLD), + line_height: Some(relative(1.25)), + ..Default::default() + }; self.heading_level_styles = Some(HeadingLevelStyles { - h1: Some(TextStyleRefinement { - font_size: Some(rems(1.45).into()), - ..Default::default() - }), - h2: Some(TextStyleRefinement { - font_size: Some(rems(1.3).into()), - ..Default::default() - }), - h3: Some(TextStyleRefinement { - font_size: Some(rems(1.1).into()), - ..Default::default() - }), - h4: Some(TextStyleRefinement { - font_size: Some(rems(1.01).into()), - ..Default::default() - }), - h5: Some(TextStyleRefinement { - font_size: Some(rems(0.95).into()), - ..Default::default() - }), + h1: Some(heading_text_style(rems(1.75))), + h2: Some(heading_text_style(rems(1.4))), + h3: Some(heading_text_style(rems(1.2))), + h4: Some(heading_text_style(rems(1.0))), + h5: Some(heading_text_style(rems(0.875))), h6: Some(TextStyleRefinement { - font_size: Some(rems(0.85).into()), - ..Default::default() + color: Some(colors.text_muted), + ..heading_text_style(rems(0.85)) }), }); @@ -1748,6 +1781,13 @@ impl MarkdownElement { None }; + let mut code_style = self.style.inline_code.clone(); + let chip_background = if self.style.inline_code_corner_radius > px(0.) { + code_style.background_color.take() + } else { + None + }; + if let Some(url) = link_url { builder.push_link(url.clone(), range.clone()); let link_style = self @@ -1756,18 +1796,17 @@ impl MarkdownElement { .as_ref() .and_then(|callback| callback(url.as_ref(), cx)) .unwrap_or_else(|| self.style.link.clone()); - builder.push_text_style(self.style.inline_code.clone()); + builder.push_text_style(code_style); builder.push_text_style(link_style); - builder.push_text(text, range); + builder.push_code_chip_text(text, range, chip_background); builder.pop_text_style(); builder.pop_text_style(); } else { - let mut code_style = self.style.inline_code.clone(); if builder.link_depth > 0 { code_style.color = self.style.link.color.or(code_style.color); } builder.push_text_style(code_style); - builder.push_text(text, range); + builder.push_code_chip_text(text, range, chip_background); builder.pop_text_style(); } } @@ -1860,7 +1899,8 @@ impl MarkdownElement { ) { let align = text_align_override.unwrap_or(self.style.base_text_style.text_align); let mut paragraph = div().when(!self.style.height_is_multiple_of_line_height, |el| { - el.mb_2().line_height(rems(1.3)) + el.mb(self.style.paragraph_spacing) + .line_height(self.style.paragraph_line_height) }); paragraph = match align { @@ -1957,7 +1997,11 @@ impl MarkdownElement { .into_any_element() }); - let block_div = div().pl_4().mb_2().border_l_4().border_color(border_color); + let block_div = div() + .pl_4() + .mb(self.style.paragraph_spacing) + .border_l_4() + .border_color(border_color); let block_div = match header { Some(header) => block_div.child(header), None => block_div, @@ -2090,7 +2134,9 @@ impl MarkdownElement { builder.push_div( div() .when(!self.style.height_is_multiple_of_line_height, |el| { - el.mb_1().gap_1().line_height(rems(1.3)) + el.mb_1() + .gap_1() + .line_height(self.style.paragraph_line_height) }) .h_flex() .items_start() @@ -2451,6 +2497,7 @@ impl Element for MarkdownElement { self.style.base_text_style.clone(), self.style.syntax.clone(), highlights, + self.style.inline_code_corner_radius, ); let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = { let markdown = self.markdown.read(cx); @@ -2720,7 +2767,14 @@ impl Element for MarkdownElement { } MarkdownTag::List(bullet_index) => { builder.push_list(*bullet_index); - builder.push_div(div().pl_2p5(), range, markdown_end); + let is_top_level = builder.list_stack.len() == 1; + builder.push_div( + div() + .pl_2p5() + .when(is_top_level, |this| this.mb(self.style.list_spacing)), + range, + markdown_end, + ); } MarkdownTag::Item => { let bullet = if let Some((task_range, checked)) = @@ -2889,8 +2943,8 @@ impl Element for MarkdownElement { .when(col_index > 0, |this| this.border_l_1()) .when(row_index > 0, |this| this.border_t_1()) .border_color(cx.theme().colors().border) - .px_1() - .py_0p5() + .px(self.style.table_cell_padding.x) + .py(self.style.table_cell_padding.y) .when(is_header, |this| { this.bg(cx.theme().colors().title_bar_background) }) @@ -3120,7 +3174,7 @@ impl Element for MarkdownElement { builder.push_div( div() .border_b_1() - .my_2() + .my(self.style.paragraph_spacing) .border_color(self.style.rule_color), range, markdown_end, @@ -3283,15 +3337,16 @@ fn apply_heading_style( _ => heading.mt_6(), }; - if let Some(border_color) = border_color - && matches!( - level, - pulldown_cmark::HeadingLevel::H1 - | pulldown_cmark::HeadingLevel::H2 - | pulldown_cmark::HeadingLevel::H3 - ) - { - heading = heading.pb_1().border_b_1().border_color(border_color); + if let Some(border_color) = border_color { + heading = match level { + pulldown_cmark::HeadingLevel::H1 => { + heading.pb_2().border_b_1().border_color(border_color) + } + pulldown_cmark::HeadingLevel::H2 => { + heading.pb_1().border_b_1().border_color(border_color) + } + _ => heading, + }; } if let Some(styles) = custom_styles { @@ -3535,6 +3590,7 @@ struct MarkdownElementBuilder { table: TableState, syntax_theme: Arc, highlights: MarkdownHighlights, + code_chip_corner_radius: Pixels, } struct MarkdownHighlights { @@ -3616,6 +3672,9 @@ struct PendingLine { text: String, runs: Vec, source_mappings: Vec, + /// Rendered (not source) indices, so chips hug the glyphs and ignore + /// unrendered characters like the surrounding backticks + code_chips: Vec<(Range, Hsla)>, } struct ListStackEntry { @@ -3628,6 +3687,7 @@ impl MarkdownElementBuilder { base_text_style: TextStyle, syntax_theme: Arc, highlights: MarkdownHighlights, + code_chip_corner_radius: Pixels, ) -> Self { Self { div_stack: vec![{ @@ -3651,6 +3711,25 @@ impl MarkdownElementBuilder { table: TableState::default(), syntax_theme, highlights, + code_chip_corner_radius, + } + } + + fn push_code_chip_text( + &mut self, + text: &str, + source_range: Range, + chip_background: Option, + ) { + let chip_start = self.pending_line.text.len(); + self.push_text(text, source_range); + if let Some(background) = chip_background { + let chip_end = self.pending_line.text.len(); + if chip_start < chip_end { + self.pending_line + .code_chips + .push((chip_start..chip_end, background)); + } } } @@ -3971,6 +4050,8 @@ impl MarkdownElementBuilder { language: None, text_align: TextAlign::Left, highlights: SmallVec::new(), + code_chips: SmallVec::new(), + code_chip_corner_radius: px(0.), })); div() .absolute() @@ -4004,8 +4085,10 @@ impl MarkdownElementBuilder { language: self.code_block_stack.last().cloned().flatten(), text_align, highlights, + code_chips: line.code_chips.into_iter().collect(), + code_chip_corner_radius: self.code_chip_corner_radius, }); - if rendered_line.highlights.is_empty() { + if rendered_line.highlights.is_empty() && rendered_line.code_chips.is_empty() { self.rendered_lines.push(rendered_line); self.append_child(text.into_any()); } else { @@ -4087,6 +4170,7 @@ impl Element for HighlightedLine { window: &mut Window, cx: &mut App, ) { + self.line.paint_code_chips(window); self.text.paint(window, cx); self.line.paint_highlights(window); } @@ -4106,11 +4190,58 @@ struct RenderedLine { source_end: usize, language: Option>, text_align: TextAlign, - /// Highlighted source ranges intersecting this line, in paint order. + /// Highlighted source ranges intersecting this line, in paint order highlights: SmallVec<[(Range, Hsla); 1]>, + /// Inline code chip ranges intersecting this line, in rendered indices + code_chips: SmallVec<[(Range, Hsla); 1]>, + code_chip_corner_radius: Pixels, } impl RenderedLine { + /// Painted before the glyphs so the text renders on top of the chips + fn paint_code_chips(&self, window: &mut Window) { + if self.code_chips.is_empty() { + return; + } + let wrapped_line_segments = self.wrapped_line_segments(); + if wrapped_line_segments.is_empty() { + return; + } + + for (rendered_range, color) in &self.code_chips { + self.for_each_bounds_in_rendered_range( + &wrapped_line_segments, + rendered_range.clone(), + |bounds| { + // Kept to a hair since the layout reserves no padding and + // anything wider eats the gap to neighboring words + let horizontal_outset = px(1.); + // Inset vertically so the chip hugs the glyphs like a badge + // instead of filling the whole line box + let vertical_inset = bounds.size.height * 0.1; + let chip_bounds = Bounds { + origin: point( + bounds.origin.x - horizontal_outset, + bounds.origin.y + vertical_inset, + ), + size: size( + bounds.size.width + horizontal_outset * 2., + bounds.size.height - vertical_inset * 2., + ), + }; + window.paint_quad(quad( + chip_bounds, + self.code_chip_corner_radius, + *color, + Edges::default(), + Hsla::transparent_black(), + BorderStyle::default(), + )); + }, + ); + } + } + fn paint_highlights(&self, window: &mut Window) { if self.highlights.is_empty() { return; @@ -4166,18 +4297,33 @@ impl RenderedLine { &self, wrapped_line_segments: &[WrappedLineSegment], range: Range, - mut f: impl FnMut(Bounds), + f: impl FnMut(Bounds), ) { if range.start >= range.end { return; } + let rendered_start = self.rendered_index_for_source_index(range.start); + let rendered_end = self.rendered_index_for_source_index(range.end); + self.for_each_bounds_in_rendered_range( + wrapped_line_segments, + rendered_start..rendered_end, + f, + ); + } + + fn for_each_bounds_in_rendered_range( + &self, + wrapped_line_segments: &[WrappedLineSegment], + rendered_range: Range, + mut f: impl FnMut(Bounds), + ) { let layout = &self.layout; let line_bounds = layout.bounds(); let line_height = layout.line_height(); - let rendered_start = self.rendered_index_for_source_index(range.start); - let rendered_end = self.rendered_index_for_source_index(range.end); + let rendered_start = rendered_range.start; + let rendered_end = rendered_range.end; for wrapped_line_segment in wrapped_line_segments { if wrapped_line_segment.start >= rendered_end { @@ -5182,6 +5328,59 @@ mod tests { render_markdown_entity_in_view(markdown, MarkdownStyle::default(), None, None, cx) } + fn rendered_code_chips( + markdown: &str, + style: MarkdownStyle, + cx: &mut TestAppContext, + ) -> Vec<(String, Hsla)> { + ensure_theme_initialized(cx); + let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx)); + cx.run_until_parked(); + let rendered = render_markdown_entity_in_view(markdown, style, None, None, cx); + rendered + .lines + .iter() + .flat_map(|line| { + let text = line.layout.text(); + line.code_chips + .iter() + .map(|(range, color)| (text[range.clone()].to_string(), *color)) + .collect::>() + }) + .collect() + } + + #[gpui::test] + fn test_inline_code_chips_cover_exactly_the_code_span_glyphs(cx: &mut TestAppContext) { + let chip_background = gpui::red(); + let style_with_chips = || MarkdownStyle { + inline_code: TextStyleRefinement { + background_color: Some(chip_background), + ..Default::default() + }, + inline_code_corner_radius: px(4.), + ..Default::default() + }; + + // Chip ranges are rendered indices, so each chip must cover the code + // span's glyphs exactly, unaffected by the unrendered backticks. + assert_eq!( + rendered_code_chips("one `two` three `four`", style_with_chips(), cx), + vec![ + ("two".to_string(), chip_background), + ("four".to_string(), chip_background) + ] + ); + + // Without a corner radius, backgrounds stay on the text runs and no + // chips are recorded. + let sharp_style = MarkdownStyle { + inline_code_corner_radius: px(0.), + ..style_with_chips() + }; + assert_eq!(rendered_code_chips("one `two`", sharp_style, cx), vec![]); + } + fn render_markdown_with_image_resolver( markdown: &str, options: MarkdownOptions,