From 314efe8a4c03c767ee15a8e67de3af72883203ff Mon Sep 17 00:00:00 2001 From: Oleg Kossoy Date: Sat, 5 Sep 2026 10:14:43 +0000 Subject: [PATCH 1/4] text: render inline code spans in the mono font family An inline code span was styled through TextViewStyle::inline_code, a HighlightStyle, which carries no font family, so it kept the body face while fenced blocks rendered in the mono family. The highlight list payload is now InlineHighlight { style, font_family } and the run builder assigns run.font.family when a highlight names one. The family comes from TextViewStyle::inline_code_font_family, which defaults to the theme mono token in from_theme (and the component theme adapter), so the base element never reads the theme directly. Both measurement paths shape with the same runs the renderer uses: the inline-flow line wrapper receives a mono span as a fixed-width element of its shaped width, and table column max-content measures each cell line with its inline highlights. --- crates/base/src/text/inline.rs | 316 ++++++++++++++++++++++++++-- crates/base/src/text/inline_flow.rs | 256 ++++++++++++++++------ crates/base/src/text/node.rs | 288 ++++++++++++++++--------- crates/base/src/text/style.rs | 29 ++- crates/component/src/text/compat.rs | 5 + crates/component/src/text/mod.rs | 1 + crates/component/src/text/style.rs | 16 +- 7 files changed, 723 insertions(+), 188 deletions(-) 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 From 8a7a34f6640b6d2745d606977f97fd116991b9d1 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 8 Sep 2026 23:39:04 +0800 Subject: [PATCH 2/4] fix(text): wrap oversized inline code in mixed content --- crates/base/src/text/inline_flow.rs | 97 +++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/crates/base/src/text/inline_flow.rs b/crates/base/src/text/inline_flow.rs index 8558f7ac0f..95a4aeb871 100644 --- a/crates/base/src/text/inline_flow.rs +++ b/crates/base/src/text/inline_flow.rs @@ -2,6 +2,7 @@ use std::{ ops::Range, sync::{Arc, Mutex}, }; +use unicode_segmentation::UnicodeSegmentation as _; use gpui::{ AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, DefiniteLength, Element, ElementId, @@ -562,7 +563,7 @@ fn line_ranges( highlights, start..end, text_style, - font_size, + wrap_width, window, ); } @@ -606,16 +607,18 @@ fn line_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. +/// the wrapper as measured elements. Oversized spans retain word boundaries; +/// oversized words can break at grapheme boundaries without splitting Unicode. fn push_text_wrap_fragments<'a>( fragments: &mut Vec>, text: &'a str, highlights: &[(Range, InlineHighlight)], range: Range, text_style: &TextStyle, - font_size: Pixels, + wrap_width: Pixels, window: &mut Window, ) { + let font_size = text_style.font_size.to_pixels(window.rem_size()); let mut cursor = range.start; for (highlight_range, highlight) in highlights { if highlight.font_family.is_none() { @@ -630,16 +633,33 @@ fn push_text_wrap_fragments<'a>( 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())); + let measure = |text: &str| { + let runs = text_runs( + text.len(), + text_style, + &[(0..text.len(), highlight.clone())], + ); + window + .text_system() + .layout_line(text, font_size, &runs, None) + .width + }; + let width = measure(span); + if width <= wrap_width { + fragments.push(WrapLineFragment::element(width, span.len())); + } else { + for word in span.split_word_bounds() { + let width = measure(word); + if width <= wrap_width { + fragments.push(WrapLineFragment::element(width, word.len())); + } else { + for grapheme in word.graphemes(true) { + fragments + .push(WrapLineFragment::element(measure(grapheme), grapheme.len())); + } + } + } + } cursor = end; } if cursor < range.end { @@ -888,4 +908,55 @@ mod tests { "the trailing word wraps to a second line: {text_lines:?}" ); } + #[test] + fn long_inline_code_wraps_in_mixed_flow() { + use super::super::inline::test_fonts::{BODY, MONO, WideMonoTextSystem}; + use gpui::{Empty, TestApp}; + let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem)); + let mut window = app.open_window(|_, _| Empty); + let style = TextStyle { + font_family: BODY.into(), + font_size: AbsoluteLength::Pixels(px(10.)), + ..Default::default() + }; + for text in [ + "one two three four five six", + "very_long_unbroken_identifier", + "你好世界你好世界你好世界", + "e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}", + ] { + let items = vec![ + MeasureItem::Image { + url: "https://example.com/icon.png".into(), + width: None, + height: None, + }, + MeasureItem::Text { + text: text.into(), + links: vec![], + highlights: vec![( + 0..text.len(), + InlineHighlight { + font_family: Some(MONO.into()), + ..Default::default() + }, + )], + }, + ]; + let image_sizes = vec![Some(size(px(10.), px(10.))), None]; + let layout = window.update(|_, window, _| { + layout_flow(&items, &image_sizes, &style, Some(px(100.)), window) + }); + assert!(layout.size.width <= px(100.), "{text:?}: {:?}", layout.size); + let reconstructed: String = layout + .fragments + .iter() + .filter_map(|fragment| match fragment { + PositionedFragment::Text { text, .. } => Some(text.as_ref()), + _ => None, + }) + .collect(); + assert_eq!(reconstructed, text); + } + } } From 2ad1b860d4884a6973f292ad69b804141ee5a441 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 8 Sep 2026 23:49:38 +0800 Subject: [PATCH 3/4] fix(text): use theme mono font without adding style configuration --- crates/base/src/text/node.rs | 35 +++++++++++++++----------- crates/base/src/text/style.rs | 29 ++------------------- crates/component/src/text/compat.rs | 5 ---- crates/component/src/text/mod.rs | 1 - crates/component/src/text/style.rs | 15 +---------- crates/component/tests/theme_compat.rs | 19 ++++++++++++++ 6 files changed, 42 insertions(+), 62 deletions(-) diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index 30a19b5559..33bcc20bdb 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -1359,7 +1359,7 @@ 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 { +fn mark_highlight(mark: &TextMark, node_cx: &NodeContext, cx: &App) -> InlineHighlight { let mut highlight = HighlightStyle::default(); if mark.bold { highlight.font_weight = Some(FontWeight::BOLD); @@ -1382,7 +1382,7 @@ fn mark_highlight(mark: &TextMark, node_cx: &NodeContext) -> InlineHighlight { 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(); + font_family = Some(cx.theme().tokens.typography.mono.clone()); } if let Some(color) = mark.highlight { highlight.background_color = Some(color); @@ -1397,7 +1397,11 @@ 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)> { + fn inline_highlights( + &self, + node_cx: &NodeContext, + cx: &App, + ) -> Vec<(Range, InlineHighlight)> { let mut highlights = vec![]; let mut offset = 0; for inline_node in &self.children { @@ -1407,7 +1411,7 @@ impl Paragraph { .map(|(range, mark)| { ( (offset + range.start)..(offset + range.end), - mark_highlight(mark, node_cx), + mark_highlight(mark, node_cx, cx), ) }) .collect::>(); @@ -1504,7 +1508,7 @@ 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 = mark_highlight(style, node_cx); + let mut highlight = mark_highlight(style, node_cx, cx); if let Some(mut link_mark) = style.link.clone() { highlight.style.color = Some(node_cx.style.link()); @@ -1561,7 +1565,7 @@ impl Paragraph { has_image && has_text } - fn inline_flow_items(&self, node_cx: &NodeContext, _cx: &mut App) -> Vec { + 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, InlineHighlight)> = vec![]; @@ -1601,7 +1605,7 @@ 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 = mark_highlight(style, node_cx); + let mut highlight = mark_highlight(style, node_cx, cx); if let Some(mut link_mark) = style.link.clone() { highlight.style.color = Some(node_cx.style.link()); @@ -1656,6 +1660,7 @@ fn measure_table_columns( col_count: usize, node_cx: &NodeContext, window: &mut Window, + cx: &App, ) -> Vec { let text_style = window.text_style(); let font_size = text_style.font_size.to_pixels(window.rem_size()); @@ -1666,7 +1671,7 @@ fn measure_table_columns( continue; }; let text = cell.children.text(); - let highlights = cell.children.inline_highlights(node_cx); + let highlights = cell.children.inline_highlights(node_cx, cx); let mut w = 0.0_f32; let mut line_start = 0; for line in text.split('\n') { @@ -2123,7 +2128,7 @@ impl BlockNode { const CELL_WRAP_MAX_PX: f32 = 480.0; const TABLE_BORDER_PX: f32 = 2.0; // the track's border_1, left + right - let col_w = measure_table_columns(table, col_count, node_cx, window); + let col_w = measure_table_columns(table, col_count, node_cx, window, cx); 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, @@ -2520,17 +2525,17 @@ mod tests { column_aligns: vec![], span: None, }; - let node_cx = NodeContext { - style: TextViewStyle::default().with_inline_code_font_family(Some(MONO.into())), - ..Default::default() - }; + let node_cx = NodeContext::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 (col_w, font_size) = window.update(|_, window, cx| { + let mut theme = crate::Theme::default(); + theme.tokens.typography.mono = MONO.into(); + cx.set_global(theme); let font_size = window.text_style().font_size.to_pixels(window.rem_size()); ( - measure_table_columns(&table, 1, &node_cx, window), + measure_table_columns(&table, 1, &node_cx, window, cx), font_size, ) }); diff --git a/crates/base/src/text/style.rs b/crates/base/src/text/style.rs index b0171ec3d8..fba02ebcb8 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, SharedString, StyleRefinement, px, rems}; +use gpui::{HighlightStyle, Hsla, Pixels, Rems, StyleRefinement, px, rems}; -use crate::{ColorTokens, TypographyTokens}; +use crate::ColorTokens; /// TextViewStyle used to customize the style for [`super::TextView`]. /// @@ -26,7 +26,6 @@ pub struct TextViewStyle { table_head: StyleRefinement, table_cell: StyleRefinement, inline_code: HighlightStyle, - inline_code_font_family: Option, is_dark: bool, } @@ -53,7 +52,6 @@ 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 } } @@ -71,7 +69,6 @@ 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. @@ -98,7 +95,6 @@ impl TextViewStyle { background_color: Some(colors.accent), ..Default::default() }, - inline_code_font_family: Some(TypographyTokens::default().mono), is_dark, } } @@ -181,15 +177,6 @@ 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). /// @@ -302,11 +289,6 @@ 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 @@ -339,7 +321,6 @@ 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] @@ -396,13 +377,7 @@ 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 c8cf48de06..199f130f4f 100644 --- a/crates/component/src/text/compat.rs +++ b/crates/component/src/text/compat.rs @@ -266,10 +266,6 @@ 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) @@ -278,7 +274,6 @@ 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 5e5283e781..74df236223 100644 --- a/crates/component/src/text/mod.rs +++ b/crates/component/src/text/mod.rs @@ -57,7 +57,6 @@ 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 34354c1664..724e6fba8b 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, SharedString, StyleRefinement, px, rems}; +use gpui::{HighlightStyle, Pixels, Rems, StyleRefinement, px, rems}; use crate::highlighter::HighlightTheme; @@ -47,11 +47,6 @@ pub struct TextViewStyle { /// Default is [`HighlightStyle::default()`], the `background_color` will /// fallback to `cx.theme().accent`, if it is `None`. pub inline_code: HighlightStyle, - /// 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, } @@ -68,7 +63,6 @@ impl Default for TextViewStyle { table_head: StyleRefinement::default(), table_cell: StyleRefinement::default(), inline_code: HighlightStyle::default(), - inline_code_font_family: None, is_dark: false, } } @@ -92,7 +86,6 @@ 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 } } @@ -122,12 +115,6 @@ 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 diff --git a/crates/component/tests/theme_compat.rs b/crates/component/tests/theme_compat.rs index 931351e82b..cde13bade7 100644 --- a/crates/component/tests/theme_compat.rs +++ b/crates/component/tests/theme_compat.rs @@ -1,5 +1,24 @@ use gpui_component::theme::{ThemeConfig, ThemeConfigColors, ThemeMode}; +#[test] +fn legacy_text_view_style_struct_literal_shape_is_unchanged() { + use gpui_component::text::TextViewStyle; + + let defaults = TextViewStyle::default(); + let _ = TextViewStyle { + paragraph_gap: defaults.paragraph_gap, + heading_base_font_size: defaults.heading_base_font_size, + heading_font_size: defaults.heading_font_size, + highlight_theme: defaults.highlight_theme, + code_block: defaults.code_block, + table: defaults.table, + table_head: defaults.table_head, + table_cell: defaults.table_cell, + inline_code: defaults.inline_code, + is_dark: defaults.is_dark, + }; +} + #[test] fn legacy_theme_config_struct_literal_shape_is_unchanged() { let _ = ThemeConfig { From 148995f7bcf1683ed87dade273af849a2bc54c38 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 9 Sep 2026 00:30:55 +0800 Subject: [PATCH 4/4] fix(text): refine inline code sizing and background alignment --- crates/base/src/text/inline.rs | 68 ++++++++ crates/base/src/text/inline_flow.rs | 261 +++++++++++++++++++++++----- crates/base/src/text/node.rs | 42 +++-- crates/base/src/text/text_view.rs | 61 +++++++ 4 files changed, 381 insertions(+), 51 deletions(-) diff --git a/crates/base/src/text/inline.rs b/crates/base/src/text/inline.rs index 7b579c1958..9858535f83 100644 --- a/crates/base/src/text/inline.rs +++ b/crates/base/src/text/inline.rs @@ -31,6 +31,7 @@ use crate::{ pub(super) struct InlineHighlight { pub(super) style: HighlightStyle, pub(super) font_family: Option, + pub(super) font_size_scale: Option, } impl InlineHighlight { @@ -40,6 +41,9 @@ impl InlineHighlight { if other.font_family.is_some() { self.font_family = other.font_family.clone(); } + if other.font_size_scale.is_some() { + self.font_size_scale = other.font_size_scale; + } self } } @@ -49,6 +53,7 @@ impl From for InlineHighlight { Self { style, font_family: None, + font_size_scale: None, } } } @@ -122,6 +127,36 @@ pub(super) fn text_runs( runs } +/// Splits text into contiguous ranges sharing one font size. GPUI runs can +/// vary the font but not its size, so each range needs its own shaped line. +pub(super) fn text_size_ranges( + text_len: usize, + highlights: &[(Range, InlineHighlight)], +) -> Vec<(Range, f32)> { + let mut ranges: Vec<(Range, f32)> = Vec::new(); + let mut push = |range: Range, scale: f32| { + if range.is_empty() { + return; + } + if let Some((last, last_scale)) = ranges.last_mut() + && *last_scale == scale + && last.end == range.start + { + last.end = range.end; + } else { + ranges.push((range, scale)); + } + }; + let mut cursor = 0; + for (range, highlight) in highlights { + push(cursor..range.start, 1.); + push(range.clone(), highlight.font_size_scale.unwrap_or(1.)); + cursor = range.end; + } + push(cursor..text_len, 1.); + ranges +} + /// A inline element used to render a inline text and support selectable. /// /// All text in TextView (including the CodeBlock) used this for text rendering. @@ -131,6 +166,8 @@ pub(super) struct Inline { links: Rc, LinkMark)>>, highlights: Vec<(Range, InlineHighlight)>, styled_text: StyledText, + paint_origin: Option>, + selection_source: Option<(Arc>, Range)>, link_click_handler: Option>, state: Arc>, @@ -171,11 +208,28 @@ impl Inline { highlights, text: text.clone(), styled_text: StyledText::new(text), + paint_origin: None, + selection_source: None, link_click_handler, state, } } + /// Preserve the shared inline-flow baseline through GPUI's element-bound snapping. + pub(super) fn paint_origin(mut self, origin: Point) -> Self { + self.paint_origin = Some(origin); + self + } + + pub(super) fn selection_source( + mut self, + state: Arc>, + range: Range, + ) -> Self { + self.selection_source = Some((state, range)); + self + } + /// Get link at given mouse position. fn link_for_position( layout: &TextLayout, @@ -475,6 +529,7 @@ impl Element for Inline { window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { + let bounds = Bounds::new(self.paint_origin.unwrap_or(bounds.origin), bounds.size); self.styled_text .prepaint(id, inspector_id, bounds, &mut (), window, cx); @@ -509,6 +564,7 @@ impl Element for Inline { window: &mut Window, cx: &mut App, ) { + let bounds = Bounds::new(self.paint_origin.unwrap_or(bounds.origin), bounds.size); let current_view = window.current_view(); let hitbox = prepaint; let Ok(mut state) = self.state.lock() else { @@ -524,6 +580,17 @@ impl Element for Inline { self.layout_selections(&text_layout, &bounds, window, cx); state.selection = selection; + if let Some((source, range)) = &self.selection_source + && let Some(selection) = selection + && let Ok(mut source) = source.lock() + { + let start = range.start + selection.start; + let end = range.start + selection.end; + source.selection = Some(match source.selection { + Some(previous) => Selection::new(previous.start.min(start), previous.end.max(end)), + None => Selection::new(start, end), + }); + } if is_selection || is_selectable { window.set_cursor_style(CursorStyle::IBeam, &hitbox); @@ -891,6 +958,7 @@ mod tests { InlineHighlight { style, font_family: Some(SharedString::from("Mono")), + font_size_scale: None, } } diff --git a/crates/base/src/text/inline_flow.rs b/crates/base/src/text/inline_flow.rs index 95a4aeb871..349f585aa3 100644 --- a/crates/base/src/text/inline_flow.rs +++ b/crates/base/src/text/inline_flow.rs @@ -7,20 +7,22 @@ use unicode_segmentation::UnicodeSegmentation as _; use gpui::{ AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, DefiniteLength, Element, ElementId, 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, + LineFragment as WrapLineFragment, ObjectFit, ParentElement as _, Pixels, ShapedLine, + SharedString, SharedUri, Size, StatefulInteractiveElement as _, Styled, StyledImage as _, + TextRun, TextStyle, WhiteSpace, Window, div, img, point, prelude::FluentBuilder as _, px, + relative, size, }; use crate::text::text_view::{LinkClickHandlerFn, handle_link_click}; use super::{ - inline::{Inline, InlineHighlight, InlineState, text_runs}, + inline::{Inline, InlineHighlight, InlineState, text_runs, text_size_ranges}, node::LinkMark, utils::image_source, }; const IMAGE_LEN: usize = 1; +pub(super) const INLINE_CODE_PADDING: f32 = 2.; pub(super) struct InlineFlow { id: ElementId, @@ -62,6 +64,7 @@ enum PositionedFragment { origin: gpui::Point, size: Size, source_range: Range, + font_size: Pixels, text: SharedString, links: Vec<(Range, LinkMark)>, highlights: Vec<(Range, InlineHighlight)>, @@ -91,10 +94,12 @@ struct LineFragmentLayout { kind: LineFragmentKind, size: Size, source_range: Range, + baseline_adjustment: Pixels, } enum LineFragmentKind { Text { + font_size: Pixels, text: SharedString, links: Vec<(Range, LinkMark)>, highlights: Vec<(Range, InlineHighlight)>, @@ -170,7 +175,7 @@ impl IntoElement for InlineFlow { impl Element for InlineFlow { type RequestLayoutState = InlineFlowLayoutState; - type PrepaintState = Vec; + type PrepaintState = Vec<(AnyElement, Option<(Bounds, gpui::Hsla)>)>; fn id(&self) -> Option { Some(self.id.clone()) @@ -263,41 +268,93 @@ impl Element for InlineFlow { origin, size: fragment_size, source_range, + font_size, text, links, - highlights, + mut highlights, .. } => { - let state = match &self.items[item_ix] { - InlineFlowItem::Text { - state, - text: source, - .. - } if source_range == (0..source.len()) => state.clone(), - _ => Arc::new(Mutex::new(InlineState::default())), + let InlineFlowItem::Text { + state: source_state, + .. + } = &self.items[item_ix] + else { + continue; }; + let state = Arc::new(Mutex::new(InlineState::default())); if let Ok(mut state) = state.lock() { - state.set_text(text); + state.set_text(text.clone()); } - let mut element = Inline::new( + let is_code = highlights.iter().any(|(_, h)| h.font_size_scale.is_some()); + let padding = if is_code { + px(INLINE_CODE_PADDING) + } else { + Pixels::ZERO + }; + let background = if is_code { + let style = window.text_style(); + let runs = text_runs(text.len(), &style, &highlights); + let line = shape_line(text.clone(), font_size, &runs, window); + let baseline = + (fragment_size.height - line.ascent - line.descent) / 2. + line.ascent; + let cap_height = runs + .iter() + .map(|run| { + let font = window.text_system().resolve_font(&run.font); + window.text_system().cap_height(font, font_size) + }) + .fold(Pixels::ZERO, Pixels::max); + // Center the background on the capital-height body of the text. + // Share descender room between both sides instead of adding it only below. + let vertical_padding = font_size * 0.125 + line.descent / 2.; + let color = highlights + .iter() + .find_map(|(_, h)| h.style.background_color); + for (_, highlight) in &mut highlights { + highlight.style.background_color = None; + } + color.map(|color| { + ( + Bounds::new( + bounds.origin + + origin + + point( + Pixels::ZERO, + baseline - cap_height - vertical_padding, + ), + size(fragment_size.width, cap_height + vertical_padding * 2.), + ), + color, + ) + }) + } else { + None + }; + let inline = Inline::new( elements.len(), state, links, highlights, self.link_click_handler.clone(), ) - .into_any_element(); + .selection_source(source_state.clone(), source_range) + .paint_origin(bounds.origin + origin + point(padding, Pixels::ZERO)); + let mut element = div() + .text_size(font_size) + .line_height(fragment_size.height) + .child(inline) + .into_any_element(); element.prepaint_as_root( - bounds.origin + origin, + bounds.origin + origin + point(padding, Pixels::ZERO), size( - AvailableSpace::Definite(fragment_size.width), + AvailableSpace::Definite(fragment_size.width - padding * 2.), AvailableSpace::Definite(fragment_size.height), ), window, cx, ); - elements.push(element); + elements.push((element, background)); } PositionedFragment::Image { item_ix, @@ -327,7 +384,7 @@ impl Element for InlineFlow { window, cx, ); - elements.push(element); + elements.push((element, None)); } } } @@ -345,7 +402,18 @@ impl Element for InlineFlow { window: &mut Window, cx: &mut App, ) { - for element in prepaint { + for item in &self.items { + if let InlineFlowItem::Text { state, .. } = item + && let Ok(mut state) = state.lock() + { + state.selection = None; + } + } + let radius = crate::Theme::global(cx).tokens.radius.sm; + for (element, background) in prepaint { + if let Some((bounds, color)) = background { + window.paint_quad(gpui::fill(*bounds, *color).corner_radii(radius)); + } element.paint(window, cx); } } @@ -392,7 +460,7 @@ fn layout_flow( wrap_width: Option, window: &mut Window, ) -> InlineFlowLayout { - let line_height = window.line_height(); + let line_height = window.pixel_snap(window.line_height()); let rem_size = window.rem_size(); let total_len = items.iter().map(MeasureItem::len).sum::(); if total_len == 0 { @@ -429,28 +497,56 @@ fn layout_flow( } => { let local_start = line_range.start.max(item_start) - item_start; let local_end = line_range.end.min(item_end) - item_start; - if local_start < local_end { - let subtext = SharedString::from(text[local_start..local_end].to_string()); - let highlights = - slice_ranges(highlights, local_start, local_end, |range, style| { - (range, style.clone()) - }); - let links = slice_ranges(links, local_start, local_end, |range, link| { - (range, link.clone()) + for (segment, scale) in text_size_ranges(text.len(), highlights) { + let start = local_start.max(segment.start); + let end = local_end.min(segment.end); + if start >= end { + continue; + } + let subtext = SharedString::from(text[start..end].to_string()); + let highlights = slice_ranges(highlights, start, end, |range, style| { + (range, style.clone()) }); + let links = + slice_ranges(links, start, end, |range, link| (range, link.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(); + let segment_font_size = font_size * scale; + let shaped_line = + shape_line(subtext.clone(), segment_font_size, &runs, window); + let is_code = highlights.iter().any(|(_, h)| h.font_size_scale.is_some()); + let padding = if is_code { + px(INLINE_CODE_PADDING * 2.) + } else { + Pixels::ZERO + }; + let width = shaped_line.width() + padding; + // Keep the glyph paint layer large enough for ascenders and descenders. + // The compact code background is painted independently. + let segment_line_height = window + .pixel_snap(line_height.max(shaped_line.ascent + shaped_line.descent)); + let baseline = + (segment_line_height - shaped_line.ascent - shaped_line.descent) / 2. + + shaped_line.ascent; + actual_line_height = actual_line_height.max(segment_line_height); + let body_font = window.text_system().resolve_font(&text_style.font()); + let body_baseline = + window + .text_system() + .baseline_offset(body_font, font_size, line_height); line_width += width; line_fragments.push(LineFragmentLayout { item_ix, kind: LineFragmentKind::Text { + font_size: segment_font_size, text: subtext, links, highlights, }, - size: size(width, line_height), - source_range: local_start..local_end, + size: size(width, segment_line_height), + source_range: start..end, + baseline_adjustment: body_baseline + - baseline + - (line_height - segment_line_height) / 2., }); } } @@ -465,6 +561,7 @@ fn layout_flow( kind: LineFragmentKind::Image, size, source_range: 0..IMAGE_LEN, + baseline_adjustment: Pixels::ZERO, }); } } @@ -475,9 +572,13 @@ fn layout_flow( let mut x = Pixels::ZERO; for fragment in line_fragments { - let origin = point(x, y + (actual_line_height - fragment.size.height) / 2.); + let origin = point( + x, + y + (actual_line_height - fragment.size.height) / 2. + fragment.baseline_adjustment, + ); let positioned = match fragment.kind { LineFragmentKind::Text { + font_size, text, links, highlights, @@ -486,6 +587,7 @@ fn layout_flow( origin, size: fragment.size, source_range: fragment.source_range, + font_size, text, links, highlights, @@ -641,21 +743,33 @@ fn push_text_wrap_fragments<'a>( ); window .text_system() - .layout_line(text, font_size, &runs, None) + .layout_line( + text, + font_size * highlight.font_size_scale.unwrap_or(1.), + &runs, + None, + ) .width }; - let width = measure(span); + let padding = if highlight.font_size_scale.is_some() { + px(INLINE_CODE_PADDING * 2.) + } else { + Pixels::ZERO + }; + let width = measure(span) + padding; if width <= wrap_width { fragments.push(WrapLineFragment::element(width, span.len())); } else { for word in span.split_word_bounds() { - let width = measure(word); + let width = measure(word) + padding; if width <= wrap_width { fragments.push(WrapLineFragment::element(width, word.len())); } else { for grapheme in word.graphemes(true) { - fragments - .push(WrapLineFragment::element(measure(grapheme), grapheme.len())); + fragments.push(WrapLineFragment::element( + measure(grapheme) + padding, + grapheme.len(), + )); } } } @@ -771,7 +885,7 @@ fn shape_line( window.text_system().shape_line(text, font_size, runs, None) } -fn slice_ranges( +pub(super) fn slice_ranges( ranges: &[(Range, T)], start: usize, end: usize, @@ -834,6 +948,7 @@ mod tests { let code_highlight = InlineHighlight { style: HighlightStyle::default(), font_family: Some(SharedString::from(MONO)), + font_size_scale: None, }; let items = vec![ MeasureItem::Text { @@ -959,4 +1074,68 @@ mod tests { assert_eq!(reconstructed, text); } } + #[test] + fn inline_code_size_is_relative_and_shares_the_body_baseline() { + use super::super::inline::test_fonts::{BODY, MONO, WideMonoTextSystem}; + use gpui::{Empty, TestApp}; + let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem)); + let mut window = app.open_window(|_, _| Empty); + for body_size in [16., 24.] { + let style = TextStyle { + font_family: BODY.into(), + font_size: AbsoluteLength::Pixels(px(body_size)), + ..Default::default() + }; + let items = vec![MeasureItem::Text { + text: "a code z".into(), + links: vec![], + highlights: vec![( + 2..6, + InlineHighlight { + font_family: Some(MONO.into()), + font_size_scale: Some(0.875), + ..Default::default() + }, + )], + }]; + window.update(|_, window, _| { + let layout = layout_flow(&items, &[None], &style, None, window); + let text_fragments = layout + .fragments + .iter() + .filter_map(|fragment| match fragment { + PositionedFragment::Text { + text, + font_size, + origin, + size, + .. + } => Some((text.as_ref(), *font_size, origin.y, *size)), + _ => None, + }) + .collect::>(); + assert_eq!(text_fragments.len(), 3); + assert_eq!(text_fragments[0].1, px(body_size)); + assert_eq!(text_fragments[1].0, "code"); + assert_eq!(text_fragments[1].1, px(body_size * 0.875)); + assert!(text_fragments[1].3.height >= text_fragments[0].3.height); + assert_eq!( + text_fragments[1].3.width, + WideMonoTextSystem::width_of("code", MONO, px(body_size * 0.875)) + + px(INLINE_CODE_PADDING * 2.) + ); + let baseline = |family, fragment: &(&str, Pixels, Pixels, Size)| { + let font = window.text_system().resolve_font(&gpui::font(family)); + fragment.2 + + window + .text_system() + .baseline_offset(font, fragment.1, fragment.3.height) + }; + assert!( + (baseline(BODY, &text_fragments[0]) - baseline(MONO, &text_fragments[1])).abs() + < px(0.01) + ); + }); + } + } } diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index 33bcc20bdb..ad46da4ab4 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -20,8 +20,10 @@ use crate::{ CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions, MarkdownNode, TableActionsFn, document::NodeRenderOptions, - inline::{Inline, InlineHighlight, InlineState, combine_highlights, text_runs}, - inline_flow::{InlineFlow, InlineFlowItem}, + inline::{ + Inline, InlineHighlight, InlineState, combine_highlights, text_runs, text_size_ranges, + }, + inline_flow::{InlineFlow, InlineFlowItem, slice_ranges}, text_view::handle_link_click, }, theme::ActiveTheme as _, @@ -1390,6 +1392,7 @@ fn mark_highlight(mark: &TextMark, node_cx: &NodeContext, cx: &App) -> InlineHig InlineHighlight { style: highlight, font_family, + font_size_scale: mark.code.then_some(0.875), } } @@ -1562,7 +1565,11 @@ impl Paragraph { fn should_render_inline_flow(&self) -> bool { let has_image = self.children.iter().any(|child| child.image.is_some()); let has_text = self.children.iter().any(|child| !child.text.is_empty()); - has_image && has_text + (has_image && has_text) + || self + .children + .iter() + .any(|child| child.marks.iter().any(|(_, mark)| mark.code)) } fn inline_flow_items(&self, node_cx: &NodeContext, cx: &mut App) -> Vec { @@ -1695,11 +1702,23 @@ fn measure_table_columns( }) }) .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; + let mut line_w = gpui::Pixels::ZERO; + for (range, scale) in text_size_ranges(line.len(), &line_highlights) { + let highlights = slice_ranges( + &line_highlights, + range.start, + range.end, + |range, highlight| (range, highlight.clone()), + ); + if highlights.iter().any(|(_, h)| h.font_size_scale.is_some()) { + line_w += px(crate::text::inline_flow::INLINE_CODE_PADDING * 2.); + } + let runs = text_runs(range.len(), &text_style, &highlights); + line_w += window + .text_system() + .layout_line(&line[range], font_size * scale, &runs, None) + .width; + } w = w.max(f32::from(line_w)); } // Border-box widths, so the padding and border the cell draws @@ -2540,9 +2559,12 @@ mod tests { ) }); - let mono_w = f32::from(WideMonoTextSystem::width_of(code, MONO, font_size)); + let mono_w = f32::from(WideMonoTextSystem::width_of(code, MONO, font_size * 0.875)); assert!( - col_w[0] >= mono_w + CELL_PAD_PX, + (col_w[0] + - (mono_w + CELL_PAD_PX + crate::text::inline_flow::INLINE_CODE_PADDING * 2.)) + .abs() + < 0.01, "col_w {} must fit the mono width {} plus padding {}", col_w[0], mono_w, diff --git a/crates/base/src/text/text_view.rs b/crates/base/src/text/text_view.rs index caed75b42f..1f927b3682 100644 --- a/crates/base/src/text/text_view.rs +++ b/crates/base/src/text/text_view.rs @@ -1440,6 +1440,67 @@ mod tests { assert_eq!(cx.opened_url(), None); } + #[gpui::test] + fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) { + struct SelectionRoot { + text_view: Entity, + format: crate::text::SelectionFormat, + } + impl Render for SelectionRoot { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(160.)) + .child(crate::TextSelectionLayer) + .child(TextView::new(&self.text_view).selection_format(self.format)) + } + } + cx.update(crate::init); + let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot { + format: crate::text::SelectionFormat::Plain, + text_view: cx + .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)), + }); + let cx: &mut VisualTestContext = cx; + cx.run_until_parked(); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(cx.opened_url(), Some("https://example.com".to_string())); + cx.simulate_mouse_down( + point(px(3.), px(8.)), + MouseButton::Left, + Modifiers::default(), + ); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.simulate_mouse_move( + point(px(155.), px(20.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.simulate_mouse_up( + point(px(155.), px(20.)), + MouseButton::Left, + Modifiers::default(), + ); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text()); + assert_eq!(selected.trim(), "code after"); + view.update(cx, |view, cx| { + view.format = crate::text::SelectionFormat::Source; + cx.notify(); + }); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text()); + assert_eq!(selected.trim(), "[`code`](https://example.com) after"); + } + #[gpui::test] fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) { cx.update(crate::init);