diff --git a/crates/base/src/text/inline.rs b/crates/base/src/text/inline.rs index 09abe59c8b..7b579c1958 100644 --- a/crates/base/src/text/inline.rs +++ b/crates/base/src/text/inline.rs @@ -9,7 +9,7 @@ use gpui::{ App, BorderStyle, Bounds, ClickEvent, CursorStyle, Edges, Element, ElementId, GlobalElementId, Half, HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, - SharedString, StyledText, TextLayout, Window, point, px, quad, + SharedString, StyledText, TextLayout, TextRun, TextStyle, Window, point, px, quad, }; use crate::{ @@ -22,6 +22,106 @@ use crate::{ text::text_view::{LinkClickHandlerFn, handle_link_click}, }; +/// The style applied to one range of inline text. +/// +/// A [`HighlightStyle`] carries no font family, so the family an inline code +/// span is set in rides beside it; `None` keeps the family of the enclosing +/// text style. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) struct InlineHighlight { + pub(super) style: HighlightStyle, + pub(super) font_family: Option, +} + +impl InlineHighlight { + /// Layers `other` over `self`, the way [`HighlightStyle::highlight`] does. + fn highlight(mut self, other: &InlineHighlight) -> Self { + self.style = self.style.highlight(other.style); + if other.font_family.is_some() { + self.font_family = other.font_family.clone(); + } + self + } +} + +impl From for InlineHighlight { + fn from(style: HighlightStyle) -> Self { + Self { + style, + font_family: None, + } + } +} + +/// Merges two highlight lists over one text into non-overlapping ranges, +/// cutting at every endpoint of every input range. Same sweep as +/// [`gpui::combine_highlights`], for [`InlineHighlight`] payloads. +pub(super) fn combine_highlights( + a: impl IntoIterator, InlineHighlight)>, + b: impl IntoIterator, InlineHighlight)>, +) -> Vec<(Range, InlineHighlight)> { + let mut endpoints = Vec::new(); + let mut highlights = Vec::new(); + for (range, highlight) in a.into_iter().chain(b) { + if !range.is_empty() { + let id = highlights.len(); + endpoints.push((range.start, id, true)); + endpoints.push((range.end, id, false)); + highlights.push(highlight); + } + } + endpoints.sort_unstable_by_key(|(position, _, _)| *position); + + let mut combined = Vec::new(); + let mut active: Vec = Vec::new(); + let mut ix = 0; + for (position, id, is_start) in endpoints { + if position > ix && !active.is_empty() { + let style = active.iter().fold(InlineHighlight::default(), |acc, id| { + acc.highlight(&highlights[*id]) + }); + combined.push((ix..position, style)); + } + ix = position; + if is_start { + active.push(id); + } else { + active.retain(|active_id| *active_id != id); + } + } + combined +} + +/// Builds the [`TextRun`]s for `text_len` bytes of inline text: each +/// highlight refines `default_style` over its range, and a highlight that +/// names a font family shapes its run in that family. +pub(super) fn text_runs( + text_len: usize, + default_style: &TextStyle, + highlights: &[(Range, InlineHighlight)], +) -> Vec { + let mut runs = Vec::with_capacity(highlights.len() * 2 + 1); + let mut ix = 0; + for (range, highlight) in highlights { + if ix < range.start { + runs.push(default_style.clone().to_run(range.start - ix)); + } + let mut run = default_style + .clone() + .highlight(highlight.style) + .to_run(range.len()); + if let Some(family) = &highlight.font_family { + run.font.family = family.clone(); + } + runs.push(run); + ix = range.end; + } + if ix < text_len { + runs.push(default_style.to_run(text_len - ix)); + } + runs +} + /// A inline element used to render a inline text and support selectable. /// /// All text in TextView (including the CodeBlock) used this for text rendering. @@ -29,7 +129,7 @@ pub(super) struct Inline { id: ElementId, text: SharedString, links: Rc, LinkMark)>>, - highlights: Vec<(Range, HighlightStyle)>, + highlights: Vec<(Range, InlineHighlight)>, styled_text: StyledText, link_click_handler: Option>, @@ -57,7 +157,7 @@ impl Inline { id: impl Into, state: Arc>, links: Vec<(Range, LinkMark)>, - highlights: Vec<(Range, HighlightStyle)>, + highlights: Vec<(Range, InlineHighlight)>, link_click_handler: Option>, ) -> Self { let text = state @@ -356,19 +456,7 @@ impl Element for Inline { cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let text_style = window.text_style(); - - let mut runs = Vec::new(); - let mut ix = 0; - for (range, highlight) in self.highlights.iter() { - if ix < range.start { - runs.push(text_style.clone().to_run(range.start - ix)); - } - runs.push(text_style.clone().highlight(*highlight).to_run(range.len())); - ix = range.end; - } - if ix < self.text.len() { - runs.push(text_style.to_run(self.text.len() - ix)); - } + let runs = text_runs(self.text.len(), &text_style, &self.highlights); self.styled_text = StyledText::new(self.text.clone()).with_runs(runs); let (layout_id, _) = @@ -649,10 +737,202 @@ fn point_in_text_selection( } } +/// A platform text system for tests where the `Mono` family shapes twice as +/// wide as every other family, so a measurement that ignores the family of a +/// run comes out visibly short. +#[cfg(test)] +pub(super) mod test_fonts { + use gpui::{ + Bounds, DevicePixels, Font, FontId, FontMetrics, FontRun, GlyphId, LineLayout, Pixels, + PlatformTextSystem, RenderGlyphParams, ShapedGlyph, ShapedRun, Size, TextRenderingMode, + point, px, size, + }; + use std::borrow::Cow; + + pub(crate) const BODY: &str = "Body"; + pub(crate) const MONO: &str = "Mono"; + const BODY_ID: FontId = FontId(1); + const MONO_ID: FontId = FontId(2); + const UNITS_PER_EM: f32 = 1000.; + + pub(crate) struct WideMonoTextSystem; + + impl WideMonoTextSystem { + /// Advance of one glyph in `font_id`, in em units. + fn advance_units(font_id: FontId) -> f32 { + if font_id == MONO_ID { 1000. } else { 500. } + } + + /// Width of `text` shaped entirely in `family` at `font_size`. + pub(crate) fn width_of(text: &str, family: &str, font_size: Pixels) -> Pixels { + let font_id = if family == MONO { MONO_ID } else { BODY_ID }; + font_size * (Self::advance_units(font_id) / UNITS_PER_EM) * text.chars().count() as f32 + } + } + + impl PlatformTextSystem for WideMonoTextSystem { + fn add_fonts(&self, _fonts: Vec>) -> anyhow::Result<()> { + Ok(()) + } + + fn all_font_names(&self) -> Vec { + vec![BODY.into(), MONO.into()] + } + + fn font_id(&self, descriptor: &Font) -> anyhow::Result { + Ok(if descriptor.family.as_ref() == MONO { + MONO_ID + } else { + BODY_ID + }) + } + + fn font_metrics(&self, _font_id: FontId) -> FontMetrics { + FontMetrics { + units_per_em: UNITS_PER_EM as u32, + ascent: 800., + descent: -200., + line_gap: 0., + underline_position: -100., + underline_thickness: 50., + cap_height: 700., + x_height: 500., + bounding_box: Bounds { + origin: point(0., -200.), + size: size(1000., 1000.), + }, + } + } + + fn typographic_bounds( + &self, + font_id: FontId, + _glyph_id: GlyphId, + ) -> anyhow::Result> { + Ok(Bounds { + origin: point(0., 0.), + size: size(Self::advance_units(font_id), 700.), + }) + } + + fn advance(&self, font_id: FontId, _glyph_id: GlyphId) -> anyhow::Result> { + Ok(size(Self::advance_units(font_id), 0.)) + } + + fn glyph_for_char(&self, _font_id: FontId, ch: char) -> Option { + Some(GlyphId(ch as u32)) + } + + fn glyph_raster_bounds( + &self, + _params: &RenderGlyphParams, + ) -> anyhow::Result> { + Ok(Bounds::default()) + } + + fn rasterize_glyph( + &self, + _params: &RenderGlyphParams, + raster_bounds: Bounds, + ) -> anyhow::Result<(Size, Vec)> { + Ok((raster_bounds.size, Vec::new())) + } + + fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout { + let mut position = px(0.); + let mut shaped_runs = Vec::new(); + let mut run_start = 0; + for run in runs { + let run_text = &text[run_start..run_start + run.len]; + let advance = font_size * (Self::advance_units(run.font_id) / UNITS_PER_EM); + let mut glyphs = Vec::new(); + for (ix, ch) in run_text.char_indices() { + glyphs.push(ShapedGlyph { + id: GlyphId(ch as u32), + position: point(position, px(0.)), + index: run_start + ix, + is_emoji: false, + }); + position += advance; + } + shaped_runs.push(ShapedRun { + font_id: run.font_id, + glyphs, + }); + run_start += run.len; + } + let metrics = self.font_metrics(BODY_ID); + LineLayout { + font_size, + width: position, + ascent: font_size * (metrics.ascent / UNITS_PER_EM), + descent: font_size * (metrics.descent / UNITS_PER_EM), + runs: shaped_runs, + len: text.len(), + } + } + + fn recommended_rendering_mode( + &self, + _font_id: FontId, + _font_size: Pixels, + ) -> TextRenderingMode { + TextRenderingMode::Grayscale + } + } +} + #[cfg(test)] mod tests { - use super::point_in_text_selection; - use gpui::{point, px}; + use super::{InlineHighlight, combine_highlights, point_in_text_selection, text_runs}; + use gpui::{FontWeight, HighlightStyle, SharedString, TextStyle, point, px}; + + fn mono(style: HighlightStyle) -> InlineHighlight { + InlineHighlight { + style, + font_family: Some(SharedString::from("Mono")), + } + } + + #[test] + fn text_runs_shape_a_code_highlight_in_its_font_family() { + let style = TextStyle { + font_family: SharedString::from("Body"), + ..Default::default() + }; + let highlights = vec![(4..8, mono(HighlightStyle::default()))]; + + let runs = text_runs(12, &style, &highlights); + + let families = runs + .iter() + .map(|run| (run.len, run.font.family.as_ref())) + .collect::>(); + assert_eq!(families, vec![(4, "Body"), (4, "Mono"), (4, "Body")]); + } + + #[test] + fn combine_highlights_cuts_a_bold_span_at_the_code_boundary() { + // `**bold `code`**`: the bold mark spans the code mark, so the + // combined list carries the weight on both sides and the family on + // the code side only. + let bold = InlineHighlight::from(HighlightStyle { + font_weight: Some(FontWeight::BOLD), + ..Default::default() + }); + let combined = combine_highlights( + vec![(0..10, bold)], + vec![(6..10, mono(HighlightStyle::default()))], + ); + + assert_eq!(combined.len(), 2); + assert_eq!(combined[0].0, 0..6); + assert_eq!(combined[0].1.style.font_weight, Some(FontWeight::BOLD)); + assert_eq!(combined[0].1.font_family, None); + assert_eq!(combined[1].0, 6..10); + assert_eq!(combined[1].1.style.font_weight, Some(FontWeight::BOLD)); + assert_eq!(combined[1].1.font_family.as_deref(), Some("Mono")); + } #[test] fn test_point_in_text_selection() { diff --git a/crates/base/src/text/inline_flow.rs b/crates/base/src/text/inline_flow.rs index 0170601415..8558f7ac0f 100644 --- a/crates/base/src/text/inline_flow.rs +++ b/crates/base/src/text/inline_flow.rs @@ -5,16 +5,16 @@ use std::{ use gpui::{ AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, DefiniteLength, Element, ElementId, - GlobalElementId, HighlightStyle, InspectorElementId, InteractiveElement as _, IntoElement, - LayoutId, LineFragment as WrapLineFragment, ObjectFit, Pixels, ShapedLine, SharedString, - SharedUri, Size, StatefulInteractiveElement as _, Styled, StyledImage as _, TextRun, TextStyle, - WhiteSpace, Window, img, point, prelude::FluentBuilder as _, px, relative, size, + GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId, + LineFragment as WrapLineFragment, ObjectFit, Pixels, ShapedLine, SharedString, SharedUri, Size, + StatefulInteractiveElement as _, Styled, StyledImage as _, TextRun, TextStyle, WhiteSpace, + Window, img, point, prelude::FluentBuilder as _, px, relative, size, }; use crate::text::text_view::{LinkClickHandlerFn, handle_link_click}; use super::{ - inline::{Inline, InlineState}, + inline::{Inline, InlineHighlight, InlineState, text_runs}, node::LinkMark, utils::image_source, }; @@ -32,7 +32,7 @@ pub(super) enum InlineFlowItem { state: Arc>, text: SharedString, links: Vec<(Range, LinkMark)>, - highlights: Vec<(Range, HighlightStyle)>, + highlights: Vec<(Range, InlineHighlight)>, }, Image { url: SharedUri, @@ -63,7 +63,7 @@ enum PositionedFragment { source_range: Range, text: SharedString, links: Vec<(Range, LinkMark)>, - highlights: Vec<(Range, HighlightStyle)>, + highlights: Vec<(Range, InlineHighlight)>, }, Image { item_ix: usize, @@ -76,7 +76,7 @@ enum MeasureItem { Text { text: SharedString, links: Vec<(Range, LinkMark)>, - highlights: Vec<(Range, HighlightStyle)>, + highlights: Vec<(Range, InlineHighlight)>, }, Image { url: SharedUri, @@ -96,7 +96,7 @@ enum LineFragmentKind { Text { text: SharedString, links: Vec<(Range, LinkMark)>, - highlights: Vec<(Range, HighlightStyle)>, + highlights: Vec<(Range, InlineHighlight)>, }, Image, } @@ -432,12 +432,12 @@ fn layout_flow( let subtext = SharedString::from(text[local_start..local_end].to_string()); let highlights = slice_ranges(highlights, local_start, local_end, |range, style| { - (range, *style) + (range, style.clone()) }); let links = slice_ranges(links, local_start, local_end, |range, link| { (range, link.clone()) }); - let runs = runs_for_highlights(&subtext, text_style, highlights.clone()); + let runs = text_runs(subtext.len(), text_style, &highlights); let shaped_line = shape_line(subtext.clone(), font_size, &runs, window); let width = shaped_line.width(); line_width += width; @@ -545,36 +545,42 @@ fn line_ranges( for hard_line in hard_lines { let mut item_start = 0; - let wrap_fragments = items - .iter() - .enumerate() - .filter_map(|(ix, item)| { - let item_end = item_start + item.len(); - let fragment = if item_end <= hard_line.start || item_start >= hard_line.end { - None - } else { - match item { - MeasureItem::Text { text, .. } => { - let start = hard_line.start.max(item_start) - item_start; - let end = hard_line.end.min(item_end) - item_start; - (start < end).then(|| WrapLineFragment::text(&text[start..end])) + let mut wrap_fragments = Vec::new(); + for (ix, item) in items.iter().enumerate() { + let item_end = item_start + item.len(); + if item_end > hard_line.start && item_start < hard_line.end { + match item { + MeasureItem::Text { + text, highlights, .. + } => { + let start = hard_line.start.max(item_start) - item_start; + let end = hard_line.end.min(item_end) - item_start; + if start < end { + push_text_wrap_fragments( + &mut wrap_fragments, + text, + highlights, + start..end, + text_style, + font_size, + window, + ); } - MeasureItem::Image { .. } => (hard_line.start <= item_start - && item_end <= hard_line.end) - .then(|| { - WrapLineFragment::element( - image_sizes[ix] - .expect("image size should be measured before wrapping") - .width, - IMAGE_LEN, - ) - }), } - }; - item_start = item_end; - fragment - }) - .collect::>(); + MeasureItem::Image { .. } => { + if hard_line.start <= item_start && item_end <= hard_line.end { + wrap_fragments.push(WrapLineFragment::element( + image_sizes[ix] + .expect("image size should be measured before wrapping") + .width, + IMAGE_LEN, + )); + } + } + } + } + item_start = item_end; + } let boundaries = wrapper .wrap_line(&wrap_fragments, wrap_width) @@ -597,6 +603,50 @@ fn line_ranges( ranges } +/// Appends the wrap fragments for `range` of `text`. The line wrapper +/// measures text fragments in the body font, so a span whose highlight sets +/// another family is shaped with the same run the renderer uses and enters +/// the wrapper as one fixed-width element: it breaks around, not inside. +fn push_text_wrap_fragments<'a>( + fragments: &mut Vec>, + text: &'a str, + highlights: &[(Range, InlineHighlight)], + range: Range, + text_style: &TextStyle, + font_size: Pixels, + window: &mut Window, +) { + let mut cursor = range.start; + for (highlight_range, highlight) in highlights { + if highlight.font_family.is_none() { + continue; + } + let start = highlight_range.start.max(cursor); + let end = highlight_range.end.min(range.end); + if start >= end { + continue; + } + if cursor < start { + fragments.push(WrapLineFragment::text(&text[cursor..start])); + } + let span = &text[start..end]; + let runs = text_runs( + span.len(), + text_style, + &[(0..span.len(), highlight.clone())], + ); + let width = window + .text_system() + .layout_line(span, font_size, &runs, None) + .width; + fragments.push(WrapLineFragment::element(width, span.len())); + cursor = end; + } + if cursor < range.end { + fragments.push(WrapLineFragment::text(&text[cursor..range.end])); + } +} + #[allow(clippy::too_many_arguments)] fn measure_image_size( ix: usize, @@ -692,34 +742,6 @@ fn inline_image_size_for_line( size((height * aspect_ratio).max(px(1.)), height.max(px(1.))) } -fn runs_for_highlights( - text: &str, - default_style: &TextStyle, - highlights: Vec<(Range, HighlightStyle)>, -) -> Vec { - let mut runs = Vec::new(); - let mut ix = 0; - - for (range, highlight) in highlights { - if ix < range.start { - runs.push(default_style.clone().to_run(range.start - ix)); - } - runs.push( - default_style - .clone() - .highlight(highlight) - .to_run(range.len()), - ); - ix = range.end; - } - - if ix < text.len() { - runs.push(default_style.to_run(text.len() - ix)); - } - - runs -} - fn shape_line( text: SharedString, font_size: Pixels, @@ -766,4 +788,104 @@ mod tests { assert_eq!(measured, size(px(15.), px(15.))); } + + /// Line breaking must see the width of an inline code span in its own + /// family. With a body-font-only wrapper the span below is measured at + /// half its shaped width, the line is kept whole, and the flow reports a + /// width past `wrap_width`. + #[test] + fn inline_code_near_the_wrap_width_does_not_overflow_the_flow() { + use super::super::inline::test_fonts::{BODY, MONO, WideMonoTextSystem}; + use gpui::{AbsoluteLength, Empty, HighlightStyle, TestApp}; + + let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem)); + let mut window = app.open_window(|_, _| Empty); + + let font_size = px(10.); + let text_style = TextStyle { + font_family: SharedString::from(BODY), + font_size: AbsoluteLength::Pixels(font_size), + ..Default::default() + }; + let lead = SharedString::from("See "); + let tail_text = " with code_span_here end"; + let code = tail_text.find("code_span_here").unwrap(); + let code_range = code..code + "code_span_here".len(); + let code_highlight = InlineHighlight { + style: HighlightStyle::default(), + font_family: Some(SharedString::from(MONO)), + }; + let items = vec![ + MeasureItem::Text { + text: lead.clone(), + links: vec![], + highlights: vec![], + }, + MeasureItem::Image { + url: SharedUri::from("https://example.com/badge.png"), + width: None, + height: None, + }, + MeasureItem::Text { + text: SharedString::from(tail_text), + links: vec![], + highlights: vec![(code_range.clone(), code_highlight)], + }, + ]; + let image_size = size(px(10.), px(10.)); + let image_sizes = vec![None, Some(image_size), None]; + // Body text, the image and the mono span fill the wrap width exactly; + // the trailing "end" only fits if the span is under-measured. + let wrap_width = WideMonoTextSystem::width_of("See with ", BODY, font_size) + + image_size.width + + WideMonoTextSystem::width_of("code_span_here", MONO, font_size); + + let layout = window.update(|_, window, _| { + layout_flow(&items, &image_sizes, &text_style, Some(wrap_width), window) + }); + + assert!( + layout.size.width <= wrap_width, + "flow width {:?} exceeds wrap width {:?}", + layout.size.width, + wrap_width + ); + let mono_fragment_width = layout + .fragments + .iter() + .find_map(|fragment| match fragment { + PositionedFragment::Text { text, size, .. } if text.contains("code_span_here") => { + Some(size.width) + } + _ => None, + }) + .expect("the code span is laid out as a text fragment"); + assert!( + mono_fragment_width >= WideMonoTextSystem::width_of("code_span_here", MONO, font_size), + "the code span fragment is shaped in the mono family" + ); + // The image sits vertically centred in its line, so line membership is + // read off the text fragments only. + let text_lines = layout + .fragments + .iter() + .filter_map(|fragment| match fragment { + PositionedFragment::Text { text, origin, .. } => Some((text.trim(), origin.y)), + PositionedFragment::Image { .. } => None, + }) + .collect::>(); + let first_y = text_lines[0].1; + assert!( + text_lines + .iter() + .any(|(text, y)| text.contains("code_span_here") && *y == first_y), + "the span stays on the first line: {text_lines:?}" + ); + assert!( + text_lines + .iter() + .any(|(text, y)| *text == "end" && *y > first_y), + "the trailing word wraps to a second line: {text_lines:?}" + ); + } } diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index a008ccc4de..30a19b5559 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -20,7 +20,7 @@ use crate::{ CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions, MarkdownNode, TableActionsFn, document::NodeRenderOptions, - inline::{Inline, InlineState}, + inline::{Inline, InlineHighlight, InlineState, combine_highlights, text_runs}, inline_flow::{InlineFlow, InlineFlowItem}, text_view::handle_link_click, }, @@ -1305,7 +1305,10 @@ impl CodeBlock { .code_block_highlighter .as_ref() .map(|highlighter| self.highlighted_styles(highlighter)) - .unwrap_or_default(), + .unwrap_or_default() + .into_iter() + .map(|(range, style)| (range, InlineHighlight::from(style))) + .collect(), node_cx.link_click_handler.clone(), )) .when_some(node_cx.code_block_actions.clone(), |this, actions| { @@ -1354,7 +1357,66 @@ impl PartialEq for NodeContext { } } +/// The highlight a text mark renders with. The link decoration is applied by +/// the caller, which also has to record the link range. +fn mark_highlight(mark: &TextMark, node_cx: &NodeContext) -> InlineHighlight { + let mut highlight = HighlightStyle::default(); + if mark.bold { + highlight.font_weight = Some(FontWeight::BOLD); + } + if mark.italic { + highlight.font_style = Some(FontStyle::Italic); + } + if mark.strikethrough { + highlight.strikethrough = Some(gpui::StrikethroughStyle { + thickness: gpui::px(1.), + ..Default::default() + }); + } + if mark.underline { + highlight.underline = Some(gpui::UnderlineStyle { + thickness: gpui::px(1.), + ..Default::default() + }); + } + let mut font_family = None; + if mark.code { + highlight = highlight.highlight(node_cx.style.inline_code_highlight()); + font_family = node_cx.style.inline_code_font_family().cloned(); + } + if let Some(color) = mark.highlight { + highlight.background_color = Some(color); + } + InlineHighlight { + style: highlight, + font_family, + } +} + impl Paragraph { + /// The highlights over [`Self::text`], for measuring the paragraph with + /// the runs it renders with. Link colors are left out: they do not move + /// glyphs. + fn inline_highlights(&self, node_cx: &NodeContext) -> Vec<(Range, InlineHighlight)> { + let mut highlights = vec![]; + let mut offset = 0; + for inline_node in &self.children { + let node_highlights = inline_node + .marks + .iter() + .map(|(range, mark)| { + ( + (offset + range.start)..(offset + range.end), + mark_highlight(mark, node_cx), + ) + }) + .collect::>(); + highlights = combine_highlights(highlights, node_highlights); + offset += inline_node.text.len(); + } + highlights + } + fn render(&self, node_cx: &NodeContext, _window: &mut Window, cx: &mut App) -> AnyElement { let span = self.span; let children = &self.children; @@ -1371,7 +1433,7 @@ impl Paragraph { let mut child_nodes: Vec = vec![]; let mut text = String::new(); - let mut highlights: Vec<(Range, HighlightStyle)> = vec![]; + let mut highlights: Vec<(Range, InlineHighlight)> = vec![]; let mut links: Vec<(Range, LinkMark)> = vec![]; let mut offset = 0; @@ -1442,36 +1504,11 @@ impl Paragraph { let mut node_highlights = vec![]; for (range, style) in &inline_node.marks { let inner_range = (offset + range.start)..(offset + range.end); - - let mut highlight = HighlightStyle::default(); - if style.bold { - highlight.font_weight = Some(FontWeight::BOLD); - } - if style.italic { - highlight.font_style = Some(FontStyle::Italic); - } - if style.strikethrough { - highlight.strikethrough = Some(gpui::StrikethroughStyle { - thickness: gpui::px(1.), - ..Default::default() - }); - } - if style.underline { - highlight.underline = Some(gpui::UnderlineStyle { - thickness: gpui::px(1.), - ..Default::default() - }); - } - if style.code { - highlight = highlight.highlight(node_cx.style.inline_code_highlight()); - } - if let Some(color) = style.highlight { - highlight.background_color = Some(color); - } + let mut highlight = mark_highlight(style, node_cx); if let Some(mut link_mark) = style.link.clone() { - highlight.color = Some(node_cx.style.link()); - highlight.underline = Some(gpui::UnderlineStyle { + highlight.style.color = Some(node_cx.style.link()); + highlight.style.underline = Some(gpui::UnderlineStyle { thickness: gpui::px(1.), ..Default::default() }); @@ -1489,7 +1526,7 @@ impl Paragraph { node_highlights.push((inner_range, highlight)); } - highlights = gpui::combine_highlights(highlights, node_highlights).collect(); + highlights = combine_highlights(highlights, node_highlights); offset += text_len; } ix += 1; @@ -1527,7 +1564,7 @@ impl Paragraph { fn inline_flow_items(&self, node_cx: &NodeContext, _cx: &mut App) -> Vec { let mut items = Vec::new(); let mut text = String::new(); - let mut highlights: Vec<(Range, HighlightStyle)> = vec![]; + let mut highlights: Vec<(Range, InlineHighlight)> = vec![]; let mut links: Vec<(Range, LinkMark)> = vec![]; let mut offset = 0; @@ -1564,36 +1601,11 @@ impl Paragraph { let mut node_highlights = vec![]; for (range, style) in &inline_node.marks { let inner_range = (offset + range.start)..(offset + range.end); - - let mut highlight = HighlightStyle::default(); - if style.bold { - highlight.font_weight = Some(FontWeight::BOLD); - } - if style.italic { - highlight.font_style = Some(FontStyle::Italic); - } - if style.strikethrough { - highlight.strikethrough = Some(gpui::StrikethroughStyle { - thickness: gpui::px(1.), - ..Default::default() - }); - } - if style.underline { - highlight.underline = Some(gpui::UnderlineStyle { - thickness: gpui::px(1.), - ..Default::default() - }); - } - if style.code { - highlight = highlight.highlight(node_cx.style.inline_code_highlight()); - } - if let Some(color) = style.highlight { - highlight.background_color = Some(color); - } + let mut highlight = mark_highlight(style, node_cx); if let Some(mut link_mark) = style.link.clone() { - highlight.color = Some(node_cx.style.link()); - highlight.underline = Some(gpui::UnderlineStyle { + highlight.style.color = Some(node_cx.style.link()); + highlight.style.underline = Some(gpui::UnderlineStyle { thickness: gpui::px(1.), ..Default::default() }); @@ -1610,7 +1622,7 @@ impl Paragraph { node_highlights.push((inner_range, highlight)); } - highlights = gpui::combine_highlights(highlights, node_highlights).collect(); + highlights = combine_highlights(highlights, node_highlights); offset += text_len; } } @@ -1631,6 +1643,73 @@ impl Paragraph { } } +const CELL_PAD_PX: f32 = 16.0; // px_2 horizontal padding +const CELL_MIN_PX: f32 = 48.0; +const CELL_BORDER_PX: f32 = 1.0; // border_r_1 drawn by every column but the last + +/// The max-content width of every table column: the widest cell line, +/// shaped with the runs the cell renders with, plus the cell's padding and +/// border. Never capped: a cap would clip overflowing text *and* leave it +/// outside the scrollable width, making it unreachable. +fn measure_table_columns( + table: &Table, + col_count: usize, + node_cx: &NodeContext, + window: &mut Window, +) -> Vec { + let text_style = window.text_style(); + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let mut col_w = vec![CELL_MIN_PX; col_count]; + for row in table.children.iter() { + for (ix, cell) in row.children.iter().enumerate() { + let Some(slot) = col_w.get_mut(ix) else { + continue; + }; + let text = cell.children.text(); + let highlights = cell.children.inline_highlights(node_cx); + let mut w = 0.0_f32; + let mut line_start = 0; + for line in text.split('\n') { + let start = line_start + (line.len() - line.trim_start().len()); + let line_end = line_start + line.len(); + line_start = line_end + 1; + let line = line.trim(); + if line.is_empty() { + continue; + } + let end = start + line.len(); + let line_highlights = highlights + .iter() + .filter_map(|(range, highlight)| { + let clipped = range.start.max(start)..range.end.min(end); + (clipped.start < clipped.end).then(|| { + ( + clipped.start - start..clipped.end - start, + highlight.clone(), + ) + }) + }) + .collect::>(); + let runs = text_runs(line.len(), &text_style, &line_highlights); + let line_w = window + .text_system() + .layout_line(line, font_size, &runs, None) + .width; + w = w.max(f32::from(line_w)); + } + // Border-box widths, so the padding and border the cell draws + // must leave the measured text its full width. + let border = if ix + 1 < col_count { + CELL_BORDER_PX + } else { + 0. + }; + *slot = slot.max(w + CELL_PAD_PX + border); + } + } + col_w +} + impl Paragraph { fn to_markdown(&self) -> String { let mut text = self @@ -2034,8 +2113,6 @@ impl BlockNode { window: &mut Window, cx: &mut App, ) -> AnyElement { - const CELL_PAD_PX: f32 = 16.0; // px_2 horizontal padding - const CELL_MIN_PX: f32 = 48.0; // Shrinking columns stop (and the table starts to scroll) at a floor // scaled to their content: roughly the width at which the text wraps // to `CELL_WRAP_MAX_LINES` lines, clamped between the two bounds so @@ -2044,43 +2121,9 @@ impl BlockNode { const CELL_WRAP_MAX_LINES: f32 = 2.0; const CELL_WRAP_MIN_PX: f32 = 160.0; const CELL_WRAP_MAX_PX: f32 = 480.0; - const CELL_BORDER_PX: f32 = 1.0; // border_r_1 drawn by every column but the last const TABLE_BORDER_PX: f32 = 2.0; // the track's border_1, left + right - // Measure the widest text per column (max-content width). Never - // capped: a cap would clip overflowing text *and* leave it outside - // the scrollable width, making it unreachable. - let text_style = window.text_style(); - let font_size = text_style.font_size.to_pixels(window.rem_size()); - let mut col_w = vec![CELL_MIN_PX; col_count]; - for row in table.children.iter() { - for (ix, cell) in row.children.iter().enumerate() { - let Some(slot) = col_w.get_mut(ix) else { - continue; - }; - let mut w = 0.0_f32; - for line in cell.children.text().split('\n') { - let line = line.trim(); - if line.is_empty() { - continue; - } - let run = text_style.to_run(line.len()); - let line_w = window - .text_system() - .layout_line(line, font_size, &[run], None) - .width; - w = w.max(f32::from(line_w)); - } - // Border-box widths, so the padding and border the cell draws - // must leave the measured text its full width. - let border = if ix + 1 < col_count { - CELL_BORDER_PX - } else { - 0. - }; - *slot = slot.max(w + CELL_PAD_PX + border); - } - } + let col_w = measure_table_columns(table, col_count, node_cx, window); let style = &node_cx.style; // Nowrap cells (via the `table_cell` refinement, which cascades to // the cell text) must never shrink below their single-line content, @@ -2455,6 +2498,53 @@ impl BlockNode { mod tests { use super::*; + /// Table columns are sized from shaped text, so a column of inline code + /// has to be measured in the code family. Measured in the body font, the + /// wide-mono test font makes `col_w` come out at half the rendered width. + #[test] + fn table_column_of_inline_code_cells_fits_the_mono_width() { + use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem}; + use gpui::{Empty, TestApp}; + + let code = "method_name()"; + let mut paragraph = Paragraph::default(); + paragraph + .push(InlineNode::new(code).marks(vec![(0..code.len(), TextMark::default().code())])); + let table = Table { + children: vec![TableRow { + children: vec![TableCell { + children: paragraph, + width: None, + }], + }], + column_aligns: vec![], + span: None, + }; + let node_cx = NodeContext { + style: TextViewStyle::default().with_inline_code_font_family(Some(MONO.into())), + ..Default::default() + }; + + let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem)); + let mut window = app.open_window(|_, _| Empty); + let (col_w, font_size) = window.update(|_, window, _| { + let font_size = window.text_style().font_size.to_pixels(window.rem_size()); + ( + measure_table_columns(&table, 1, &node_cx, window), + font_size, + ) + }); + + let mono_w = f32::from(WideMonoTextSystem::width_of(code, MONO, font_size)); + assert!( + col_w[0] >= mono_w + CELL_PAD_PX, + "col_w {} must fit the mono width {} plus padding {}", + col_w[0], + mono_w, + CELL_PAD_PX + ); + } + #[test] fn code_block_highlights_are_cached_by_highlighter_identity() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/base/src/text/style.rs b/crates/base/src/text/style.rs index fba02ebcb8..b0171ec3d8 100644 --- a/crates/base/src/text/style.rs +++ b/crates/base/src/text/style.rs @@ -1,8 +1,8 @@ use std::sync::Arc; -use gpui::{HighlightStyle, Hsla, Pixels, Rems, StyleRefinement, px, rems}; +use gpui::{HighlightStyle, Hsla, Pixels, Rems, SharedString, StyleRefinement, px, rems}; -use crate::ColorTokens; +use crate::{ColorTokens, TypographyTokens}; /// TextViewStyle used to customize the style for [`super::TextView`]. /// @@ -26,6 +26,7 @@ pub struct TextViewStyle { table_head: StyleRefinement, table_cell: StyleRefinement, inline_code: HighlightStyle, + inline_code_font_family: Option, is_dark: bool, } @@ -52,6 +53,7 @@ impl PartialEq for TextViewStyle { && self.table_head == other.table_head && self.table_cell == other.table_cell && self.inline_code == other.inline_code + && self.inline_code_font_family == other.inline_code_font_family && self.is_dark == other.is_dark } } @@ -69,6 +71,7 @@ impl TextViewStyle { &theme.tokens.colors, theme.appearance == crate::ThemeAppearance::Dark, ) + .with_inline_code_font_family(Some(theme.tokens.typography.mono.clone())) } /// Derives rich-text colors from one palette. @@ -95,6 +98,7 @@ impl TextViewStyle { background_color: Some(colors.accent), ..Default::default() }, + inline_code_font_family: Some(TypographyTokens::default().mono), is_dark, } } @@ -177,6 +181,15 @@ impl TextViewStyle { self } + /// Sets the font family inline code spans are shaped in. + /// + /// Defaults to the theme's mono family. `None` keeps inline code in the + /// body face, with only [`Self::with_inline_code`] distinguishing it. + pub fn with_inline_code_font_family(mut self, family: Option) -> Self { + self.inline_code_font_family = family; + self + } + /// Sets the style refinement for the table container (the bordered wrapper /// in wrap mode, the scroll viewport in horizontal-scroll mode). /// @@ -289,6 +302,11 @@ impl TextViewStyle { self.inline_code } + /// The font family inline code spans are shaped in, if any. + pub fn inline_code_font_family(&self) -> Option<&SharedString> { + self.inline_code_font_family.as_ref() + } + /// Whether content-specific assets should use their dark variant. pub fn is_dark(&self) -> bool { self.is_dark @@ -321,6 +339,7 @@ mod tests { assert!(base != base.clone().with_table_cell(table)); assert!(base != base.clone().with_dark(true)); + assert!(base != base.clone().with_inline_code_font_family(None)); } #[test] @@ -377,7 +396,13 @@ mod tests { theme.tokens.colors.border = gpui::rgb(0x778899).into(); theme.tokens.colors.selection = gpui::rgb(0x55a0fc).into(); + theme.tokens.typography.mono = "Test Mono".into(); + let style = TextViewStyle::from_theme(&theme); + assert_eq!( + style.inline_code_font_family().map(|f| f.as_ref()), + Some("Test Mono") + ); assert_eq!(style.foreground(), theme.tokens.colors.foreground); assert_eq!(style.link(), theme.tokens.colors.primary); assert_eq!(style.selection(), theme.tokens.colors.selection); diff --git a/crates/component/src/text/compat.rs b/crates/component/src/text/compat.rs index a59bb5fb82..165c1595c3 100644 --- a/crates/component/src/text/compat.rs +++ b/crates/component/src/text/compat.rs @@ -266,6 +266,10 @@ pub(super) fn resolve_component_style( // a dark theme. let is_dark = themed.is_dark() || legacy.is_dark; + let inline_code_font_family = legacy + .inline_code_font_family + .or_else(|| themed.inline_code_font_family().cloned()); + let mut style = themed .with_paragraph_gap(legacy.paragraph_gap) .with_heading_base_font_size(legacy.heading_base_font_size) @@ -274,6 +278,7 @@ pub(super) fn resolve_component_style( .with_table_head(table_head) .with_table_cell(table_cell) .with_inline_code(inline_code) + .with_inline_code_font_family(inline_code_font_family) .with_dark(is_dark); if let Some(heading_font_size) = legacy.heading_font_size { style = style.with_heading_font_size(move |level, base| heading_font_size(level, base)); diff --git a/crates/component/src/text/mod.rs b/crates/component/src/text/mod.rs index d594bd57e0..d3935ab1c2 100644 --- a/crates/component/src/text/mod.rs +++ b/crates/component/src/text/mod.rs @@ -55,6 +55,7 @@ pub(crate) fn base_text_view_style(theme: &crate::Theme) -> gpui_base::TextViewS background_color: Some(theme.accent), ..Default::default() }) + .with_inline_code_font_family(Some(theme.mono_font_family.clone())) .with_dark(theme.is_dark()) } diff --git a/crates/component/src/text/style.rs b/crates/component/src/text/style.rs index c59a4a03df..34354c1664 100644 --- a/crates/component/src/text/style.rs +++ b/crates/component/src/text/style.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use gpui::{HighlightStyle, Pixels, Rems, StyleRefinement, px, rems}; +use gpui::{HighlightStyle, Pixels, Rems, SharedString, StyleRefinement, px, rems}; use crate::highlighter::HighlightTheme; @@ -47,7 +47,11 @@ pub struct TextViewStyle { /// Default is [`HighlightStyle::default()`], the `background_color` will /// fallback to `cx.theme().accent`, if it is `None`. pub inline_code: HighlightStyle, - /// Whether content-specific rendering should use dark-mode assets. + /// The font family for inline code spans. + /// + /// `None` keeps the themed family (`cx.theme().mono_font_family`); set + /// `Some` to shape inline code in another family. + pub inline_code_font_family: Option, /// Whether content-specific rendering should use dark-mode assets. pub is_dark: bool, } @@ -64,6 +68,7 @@ impl Default for TextViewStyle { table_head: StyleRefinement::default(), table_cell: StyleRefinement::default(), inline_code: HighlightStyle::default(), + inline_code_font_family: None, is_dark: false, } } @@ -87,6 +92,7 @@ impl PartialEq for TextViewStyle { && self.table_head == other.table_head && self.table_cell == other.table_cell && self.inline_code == other.inline_code + && self.inline_code_font_family == other.inline_code_font_family && self.is_dark == other.is_dark } } @@ -116,6 +122,12 @@ impl TextViewStyle { self.inline_code = style; self } + /// Set the font family for inline code spans. Defaults to the themed + /// mono family. + pub fn inline_code_font_family(mut self, family: impl Into) -> Self { + self.inline_code_font_family = Some(family.into()); + self + } /// Set extra style for the table container. /// /// Set `overflow_x: scroll` on the refinement for adaptive layout: cells