Skip to content

text: render inline code spans in the theme's mono font - #2949

Open
kossoy wants to merge 1 commit into
longbridge:mainfrom
kossoy:feat/inline-code-mono-font-family
Open

text: render inline code spans in the theme's mono font#2949
kossoy wants to merge 1 commit into
longbridge:mainfrom
kossoy:feat/inline-code-mono-font-family

Conversation

@kossoy

@kossoy kossoy commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

An inline code span (`like this`) is styled through
TextViewStyle::inline_code_highlight(), which returns a gpui HighlightStyle.
A HighlightStyle carries color, weight, style, background, underline,
strikethrough and fade — but no font family. So inline code gets the code
background and nothing else: it is shaped in the body font, while fenced code
blocks (which go through a div().font_family(theme.tokens.typography.mono))
render in the mono font. The two kinds of code in one document disagree on the
typeface.

Reproduction

Set a distinct sans and mono family on the theme (any pair that differ visibly,
e.g. Noto Sans / JetBrainsMono Nerd Font) and render:

Body text with `inline_code()` in it.

```rust
fn fenced() {}

The fenced block is mono; the inline span is the body sans, with a mono-less
accent background. Every inline code span in a 3 900-note vault rendered this
way.

## Fix

`HighlightStyle` cannot carry a family, so the paragraph builders in `node.rs`
record the inline-code ranges in a parallel `Vec<Range<usize>>` beside
`highlights`, threaded through `Inline` and the `InlineFlow` items exactly as
`links` already are. One new function, `inline::text_runs`, builds the
`TextRun`s for both the plain `Inline` element and the inline-flow line
measurer (it replaces `inline_flow::runs_for_highlights`, which duplicated the
`Inline::request_layout` loop): highlights refine the default style over their
ranges, then each run is split at the code boundaries and the code half gets
`run.font.family = theme.tokens.typography.mono`. Weight, style, color and
background from the highlight survive the split, so bold-in-code and
code-in-link keep working.

The family comes from the Base theme's typography tokens, the same source the
fenced code block already reads, so a theme that sets `mono_font_family` gets
consistent code typography without a new style field.

`crates/base/src/text/{inline,inline_flow,node}.rs`, +166 −46 including two
unit tests on `text_runs` (family switch inside a highlight; a bold highlight
split at a code boundary keeps its weight). No gpui core change.

@huacnlee huacnlee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requirement is right — inline code should render in the mono family, and consolidating the two duplicate run builders into one text_runs is a genuine cleanup. But I don't think the shape of the change is the one this needs, and there are two measurement paths that were left behind. Requesting changes on the modeling question first, since fixing it makes most of the rest go away.

Inline code should ride in highlights, not in a second parallel list

code_ranges is introduced as an independent partition of the same text, keyed to the same byte offsets, and then threaded through every layer that already carries highlights: InlineFlowItem, MeasureItem, LineFragmentKind, PositionedFragment, Inline, and both Paragraph builders. Two lists over one coordinate space now have to be sliced, cloned and cleared in lockstep at every one of those sites. That is the source of most of the +166.

It also forces text_runs to reconcile the two partitions at render time — sorting, clamping, and splitting a highlight run at a code boundary. That reconciliation is dead code for every input this pipeline can actually produce. In node.rs the same inner_range goes into both lists:

if style.code {
    highlight = highlight.highlight(node_cx.style.inline_code_highlight());
    code_ranges.push(inner_range.clone());
}
// ...
node_highlights.push((inner_range, highlight));

Every code range therefore has a highlights entry with identical bounds, and gpui::combine_highlights is an endpoint sweep that cuts at the start and end of every input range. So each code range's endpoints are always cut points, and a highlight segment arriving at text_runs is either entirely inside a code range or entirely outside it. It can never straddle one.

That makes text_runs_split_a_highlight_at_the_code_boundary a test for an input the production path cannot construct: bold 0..10 with code 6..10 reaches text_runs already split into 0..6 bold and 6..10 bold+code.

The suggestion: change the payload of the existing list from HighlightStyle to a small struct carrying the highlight plus the mono flag (HighlightStyle has no family field, which is the real constraint here — that part of the diagnosis is correct). Same files touched, one list instead of two, no splitting, no sort, no per-fragment Vec<(Range<usize>, ())> allocated in inline_flow.rs:453 just to reuse slice_ranges, and the run builder stays the original loop plus one assignment to run.font.family.

Two measurement paths still assume a single font

These are the reason I'd rather not land the current shape: the information lives on a channel that the measurement code does not subscribe to.

inline_flow.rs:573 — line breaking is measured in the body font only. line_ranges() builds one line_wrapper(text_style.font(), font_size) and wraps the whole flow with it, while layout_flow now shapes each fragment with mono runs and sums those shaped widths into line_width/max_width. Mono glyphs are generally wider, so the break points chosen for the body font yield a line whose shaped width exceeds wrap_width, and InlineFlowLayout.size reports max_width > wrap_width — horizontal overflow. Repro shape, in a narrow text view:

Text ![badge](x.png) with a fairly long `inline_code_span` at the end.

The same class of error existed for bold and italic, but the proportional-to-mono delta is much larger.

node.rs:2081 — table column widths are measured with a body-font run per cell. The max-content pass does text_style.to_run(line.len()) over the cell's plain text with no knowledge of the code ranges, but cells render through Paragraph::renderInline and are now shaped in mono. A column of `method_name()` values gets a col_w narrower than the content, so cells wrap to extra lines or clip inside CELL_PAD_PX.

Smaller points

  • The family bypasses TextViewStyle, with no opt-out. It is read straight from cx.theme().tokens.typography.mono inside the base element (inline.rs:364, inline_flow.rs:216), but TextViewStyle::inline_code is the documented seam for inline code presentation. An app that styles inline code as a badge in the body face can no longer do so, and inline code cannot be given a family distinct from fenced blocks. CLAUDE.md asks the base layer to stay visually unopinionated with presentation above the seam, so this wants an optional family on TextViewStyle defaulting to the mono token.
  • Per-frame work. text_runs re-clones, re-filters and re-sorts on every layout pass, and the push closure rescans the whole vec for each run, giving O(highlights × code_ranges) plus an allocation per call — in Inline::request_layout (per frame, per visible inline element) and once per line fragment in layout_flow. Folding the flag into the highlights payload removes this entirely.

What I verified

cargo check -p gpui-base passes, clippy is clean with --all-targets, and both new tests pass. I traced the text_runs splitting logic for length and underflow correctness and it is sound in isolation: run lengths always sum to text_len, and empty, reversed, unsorted, overlapping and out-of-range inputs are all handled. All three Inline::new call sites and both Paragraph builders were updated consistently, and the per-line slice_ranges keeps the code ranges in the same coordinate space as the sliced text and highlights. The problems are the modeling and the two measurement paths, not the run construction.


🤖 Review assisted by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants