InlineComposer turns the domain's runs into Flutter spans, and sets the
punctuation properly on the way past
(lib/api/render/inline_composer.dart).
Its reason for existing is the seam it sits on. The document keeps what the
author typed; the page shows what a typographer would have set
(lib/api/render/inline_composer.dart). Straight quotes become the marks
they stand for, pairs of hyphens become dashes, three dots become an ellipsis
— and none of it touches code, which is composed verbatim. Doing this here
rather than in the parser is what keeps the domain's text honest: search,
outlines and the model itself still see the author's characters.
It owns span construction and punctuation. It owns no sizes or colours — those come from Reading Theme — and no layout, which is Document View's.
compose(runs, {style, previous}) walks the runs, threading the last grapheme
actually emitted into the next one, because that decides whether a quote opens
or closes (lib/api/render/inline_composer.dart). The tail walk is
recursive, so whitespace or text inside nested emphasis and links still gives
the next quote its real context. It follows transformed output as well: an em
dash produced from three hyphens is punctuation, not a source hyphen.
Each run becomes a span (lib/api/render/inline_composer.dart):
| Run | Becomes |
|---|---|
TextRun |
A TextSpan whose text has been set (lib/api/render/inline_composer.dart) |
CodeRun |
Selectable verbatim text in contrast-safe accent mono; it rejects a link's decoration but retains authored insertion, subscript and superscript marks (lib/api/render/inline_composer.dart) |
MathRun |
A baseline-aligned MathInlineSpan, optically sized to the surrounding role and flattening back to authored TeX for copying (lib/api/render/inline_composer.dart) |
MarkedRun |
Italic, weight 700, deletion or insertion lines, or the face's designed subscript and superscript glyphs — one signal for each meaning (lib/api/render/inline_composer.dart) |
LinkRun |
linkFor(base), which preserves the complete heading, table or marked style and adds only link colour, underline and interaction (lib/api/render/inline_composer.dart) |
FootnoteReferenceRun |
A designed superscript numeral with link colour, underline, definition navigation and an explicit accessibility label (lib/api/render/inline_composer.dart) |
FootnoteBackReferenceRun |
The authored return arrow, repeated-occurrence superscript, citation navigation and an accessibility label which names the return action (lib/api/render/inline_composer.dart) |
ImageRun |
A DocumentImage widget when the source is remote or a document loader is present; otherwise its alternative in muted (lib/api/render/inline_composer.dart) |
LineBreakRun |
One selectable newline in the surrounding style; no widget, extra gap or source marker (lib/api/render/inline_composer.dart) |
A link or code run keeps its full context. A link in an h2 remains an h2,
a link in a table keeps lining tabular figures, and inline code in a heading
steps one logical pixel below that heading instead of collapsing to body-code
size
(lib/api/render/reading_theme.dart). Code is never promoted to an embedded
widget: the text remains in the paragraph's selectable, copyable and reflowable
span tree. Mono and code colour identify it; reserving the underline for actual
links keeps that interaction promise unambiguous. An ins container is the
explicit exception: its underline is authored meaning, so it remains present
when its recursive child is inline code.
Even an unbroken identifier stays in that flow and wraps inside a narrow
reading column (test/presentation/inline_composer_test.dart).
The explicit span type also stops widow binding from rewriting source text at
the end of a paragraph (lib/api/render/inline_composer.dart,
lib/api/render/document_view.dart). A
mouse-selection test copies only the code run and asserts its exact source text
(test/presentation/inline_composer_test.dart).
Inline mathematics is a painted widget because its fractions, limits and
radicals are not one font run. MathInlineSpan restores what an ordinary
WidgetSpan would lose: plain-text copying and semantics receive the TeX
source instead of an object-replacement character. Search still advances by
that source and paints a matching background behind the complete equation
(lib/api/widgets/math_expression.dart,
test/presentation/math_expression_test.dart).
An inline custom anchor is syntax rather than prose, but Flutter's keyed inline
geometry is a WidgetSpan and therefore one selectable placeholder code unit.
The parser removes that syntax before presentation. Exact navigation is
reserved for standalone AnchorBlock targets, while inline selection, copying,
search and semantics remain continuous
(lib/infrastructure/markdown/safe_html_text.dart,
lib/infrastructure/markdown/markdown_document_parser.dart,
test/presentation/inline_composer_test.dart).
An authored line stays inside the same span tree as its surrounding emphasis or
link. Its newline advances the same offset cursor used by document search, so a
match after the break highlights the intended characters while selection and
assistive technology receive the line itself
(test/presentation/inline_composer_test.dart).
An image remains an inline run, but its pixels need layout and asynchronous
loading that a TextSpan cannot provide. The composer therefore contributes a
middle-aligned WidgetSpan and gives Document Image
the current document, source, alternative and title. The alternative still
advances the source cursor even when pixels are painted, so a later search
match keeps its authored offset. Without a loader, the same words remain
visible instead of disappearing (lib/api/render/inline_composer.dart,
test/presentation/inline_composer_test.dart).
A Markdown link title remains domain data, but it is not used as the Flutter
span's semantics label. A semantics label replaces visible text for assistive
technology, while the link title is only advisory. _LinkTextSpan instead
presents the complete recursive label as one logical range: nested code,
strength and emphasis still paint independently, but hit testing and semantics
own the whole phrase. The visible words form one link node with a tap action,
and a real pointer tap reaches the same callback
(lib/api/render/inline_composer.dart,
test/presentation/inline_composer_test.dart).
The destination and title never enter the span tree, so neither can widen the
page. A long label remains ordinary inline text: Flutter may use emergency
breaks inside an otherwise unbreakable token, but never splits a grapheme, and
every resulting line still maps back to the one _LinkTextSpan interaction.
An empty label paints no glyph, creates no hit rectangle, and therefore exposes
no phantom link action (lib/api/render/inline_composer.dart,
test/presentation/inline_composer_test.dart).
Composition advances by extended grapheme cluster rather than UTF-16 code
unit. Search and syntax boundaries that land inside a combining sequence,
emoji modifier, flag, or ZWJ sequence are expanded to the complete visible
character before spans are cut. The background may therefore cover more code
units than the literal query, but the glyph can never be broken by a style
boundary (lib/api/render/inline_composer.dart).
The parser has already resolved both delimiter spellings to
InlineMark.emphasis, so composition never sees or paints a star or
underscore. It adds FontStyle.italic to the surrounding role and nothing
else: colour, weight, size, leading, decoration, direction and the selectable
text all remain inherited. Search background is an independent layer, so a
matched phrase stays italic without gaining a second semantic or accessible
label (lib/api/render/inline_composer.dart).
That restraint is the typography. Emphasis is a local change of voice inside
running text, not a miniature heading. Its long hostile specimen wraps on the
ordinary measure and baseline grid, and its Unicode clusters use the same
composition path as roman prose (test/presentation/inline_composer_test.dart).
InlineMark.strong inherits the surrounding role and changes only its weight
to 700. It does not borrow italic from emphasis, shift toward the accent, add a
box, or alter size and leading. Search paint remains independent, and the
delimiter-free words remain the selectable and accessible text
(lib/api/render/inline_composer.dart).
The bundled variable reading faces receive that weight through
TextStyle.fontWeight; the long hostile specimen proves the heavier glyphs may
reflow naturally without pushing the paragraph off its baseline grid
(test/presentation/paragraph_setting_test.dart).
Composition is recursive, so a child begins with the complete style inherited
from its parent. Emphasis inside strength retains weight and adds italic;
strength inside emphasis retains italic and adds weight. Triple delimiters use
that same pair of ordinary marks rather than inventing a third visual role
(lib/api/render/inline_composer.dart).
The result remains one selectable and accessible string. No delimiter is
painted or spoken, no nested span supplies a replacement semantics label, and
search background remains an independent layer. A long combined specimen may
wrap differently because its glyphs are wider, but every rendered line returns
to the prose baseline grid (test/presentation/inline_composer_test.dart,
test/presentation/paragraph_setting_test.dart).
Both eligible GFM spellings reach composition as
InlineMark.strikethrough. The composer inherits the complete surrounding
role and adds only TextDecoration.lineThrough: no muted tone, accent, weight,
italic, size or leading change. A deletion is still legible prose, and the
continuous line is already its editorial signal
(lib/api/render/inline_composer.dart).
The words remain ordinary selectable and accessible text. Search background
can paint either spelling without replacing the line-through or supplying a
semantics label, and a long deleted passage may reflow while every line returns
to the prose baseline grid (test/presentation/inline_composer_test.dart,
test/presentation/paragraph_setting_test.dart).
InlineMark.subscript and InlineMark.superscript add the OpenType subs or
sups feature to the complete surrounding style. The bundled reading faces
provide those substitutions, so the result uses glyphs drawn for the role
instead of a scaled and manually shifted widget. Font size, leading, colour,
selection, semantics, nested Markdown roles, and the prose baseline grid stay
unchanged (lib/api/render/inline_composer.dart).
The bundled-font contract is checked from each font's own GSUB table
(test/presentation/bundled_scientific_feature_test.dart). Custom themes may
name only bundled faces at render time, so every accepted serif and mono face
has already been checked for subs and sups.
InlineMark.insertion adds one inherited-ink underline. GitHub documents
ins as underline, while a link retains the stronger interaction pair of an
accent colour and underline. Decorations combine when marks nest, so insertion
inside deletion does not erase either authored meaning. All three marks remain
ordinary selectable and searchable text
(lib/api/render/inline_composer.dart,
test/presentation/inline_composer_test.dart,
test/presentation/paragraph_setting_test.dart).
_text walks a run grapheme by grapheme
(lib/api/render/inline_composer.dart), delegating the rules to
Typographic Punctuation in the presentation
ring: a quote is opened or closed based on what precedes it, two or three
hyphens become an en or em dash, three dots become an ellipsis. A lone hyphen
stays a hyphen — well-known is not well–known.
Because CodeRun never reaches _set, git log --oneline "HEAD"... is
composed exactly as written
(test/presentation/inline_composer_test.dart).
The same rules also expose TypographicProjection in
lib/presentation/theme/typographic_punctuation.dart. It keeps the displayed
string beside sparse contraction boundaries, so a windowed consumer can map a
selection after an en dash or ellipsis back to the exact authored source in
O(log substitutions), without allocating one offset per character
(test/presentation/typographic_punctuation_test.dart). A bounded projection
also accepts the displayed character immediately to its left and reports the
character before any later boundary. Adjacent viewport ranges therefore make
the same opening-quote decision as one continuous composition without scanning
their prefix. This is a presentation projection only; copied and searched text
remains the domain's source.
| In | Type | From |
|---|---|---|
theme |
ReadingTheme |
The pane |
document |
DocumentId? |
The reading whose directory owns relative sources |
imageLoader |
DocumentImageLoader? |
The application capability for document-local bytes |
onTapLink |
void Function(String href)? |
The pane's link handler |
compose(runs, {style, previous}) |
List<Inline>, optional base TextStyle and preceding character |
Each block, as it builds |
Out: List<InlineSpan>. Tapping a link calls onTapLink(href) with the href
exactly as the author wrote it; resolving it against the current document is
ReaderController.resolveLink's job
(Reader Controller).
None today. A contributor that wanted to render another custom inline — for
example a wiki link — would attach in _run
(lib/api/render/inline_composer.dart); see the
plugin architecture.
InlineComposer is const and stateless
(lib/api/render/inline_composer.dart).
DocumentView builds one for each render and discards it with that render
(lib/api/render/document_view.dart).
Span construction obeys Flutter's text invariants. The switch over runs is
exhaustive over a sealed hierarchy, so a new run type cannot be added without
deciding how it is composed. A link with no handler simply gets no recogniser
and an image without a usable loader keeps its alternative as text
(lib/api/render/inline_composer.dart). Behaviour is covered by
test/presentation/inline_composer_test.dart.
Two things are deliberately outside this component. Hanging punctuation is
applied after composition by Paragraph. Primes remain open:
5'2" is set as quotes today, where a typographer would want hatch marks. It
is noted in the backlog.