TextNodeRenderer
This page describes how TextNodeRenderer turns a parsed
TextNode tree into either:
a human-readable
StringviarenderString()streamed terminal output via
renderTo(CursorWriter)
The implementation uses a single streaming pipeline.
The tree is walked once, physical RenderBlock values are emitted as
they are discovered, and the selected output target consumes those
blocks immediately.
No normal rendering path builds a complete block vector before output.
The only retained block is the newest pending block inside
BlockStream so closing container scopes can still apply bottom
margins to the last descendant.
Relevant Source Files
The public renderer entry point lives in:
src/erbsland/cterm/text/impl/TextNodeRenderer.hppsrc/erbsland/cterm/text/impl/TextNodeRenderer.cpp
The internal streaming renderer lives in
src/erbsland/cterm/text/impl/text_node_renderer/:
BlockTarget.hppBlockStream.hppCursorBlockTarget.hppCursorBlockTarget.cppInlineTextRenderer.hppInlineTextRenderer.cppListItemLayout.hppListItemLayout.cppListPrefix.hppRenderBlock.hppRenderBlock.cppRenderContext.hppRenderContext.cppStringBuilderWithWhitespaceState.hppStringBuilderWithWhitespaceState.cppStringBlockTarget.hppStyleResolver.hppStyleResolver.cppTreeRenderer.hppTreeRenderer.cpp
High-Level Architecture
At a high level the subsystem looks like this:
TextNodeRenderer is intentionally thin.
It stores the document pointer and the style pointer, creates the
appropriate target, and asks TreeRenderer to render the document.
The renderer has four main collaborators:
Type / file |
Responsibility |
|---|---|
|
Walk the |
|
Resolve style rules for block roles, inline roles, list kinds, levels, and style-token selectors. |
|
Render inline subtrees into styled |
|
Hold one pending block, flush older blocks to the selected target, and keep the pending block patchable. |
The target side is deliberately small:
Type / file |
Responsibility |
|---|---|
|
Abstract sink for streamed render blocks. |
|
Build the plain string representation used by |
|
Write the terminal representation to a |
Streaming Pipeline
The active rendering path is:
TreeRenderer::appendNode() dispatches each node by type:
semantic containers open a scope and render block-emitting children
bullet and numbered lists stream their list items directly
paragraph-like nodes render inline text and emit one paragraph block
headings emit either a paragraph block or a filled-line block
horizontal rules emit a horizontal-rule block
inline nodes render as paragraph-like content only when the current tree position allows that behavior
Direct inline and empty children of document, section, blockquote, and definition-list containers are ignored. This preserves the renderer contract that these containers only expose block-level children. List items are different: inline and text children of a list item are valid item content and become paragraph-like blocks.
Render Context
RenderContext carries inherited state that descendants need:
the inherited text-style overlay
accumulated horizontal margins from ancestor containers
It does not own vertical edge-margin decisions.
Top and bottom margins are scope behavior in TreeRenderer because
they depend on which descendant becomes the first or last emitted block.
RenderContext creates derived contexts for:
semantic containers
horizontal-margin inheritance
list-item bodies
It also resolves the final text style and final block style for a
RenderBlock.
Style Resolution
StyleResolver owns all renderer-facing style lookup.
It stores the Style pointer by
value, so the resolver does not depend on an external reference staying
alive.
It resolves:
base text style
block rules for semantic roles
heading and list levels
bullet versus numbered list rules
horizontal-rule style
node-local style-token selectors
Rules without node-local style tokens are cached. Nodes with style tokens are resolved directly from normalized tokens. Token splitting trims whitespace, removes duplicates, and sorts tokens so selector matching stays deterministic.
Inline Rendering
InlineTextRenderer renders inline node subtrees into styled
String values.
Its responsibilities are:
append literal text, error, and unsupported nodes
preserve explicit line breaks as newline characters
apply inline style overlays for emphasis, strong text, underline, links, inline code, and styled spans
respect the selected whitespace mode
StringBuilderWithWhitespaceState keeps whitespace handling local to
inline rendering.
The tree renderer only asks for a final styled string and does not know
how inline whitespace was normalized.
Render Blocks
RenderBlock is the single value type passed through the block
stream.
It represents one physical block in one of these shapes:
paragraph-like text
filled-line text, used by filled headings
horizontal rule
The block owns its resolved StyleRule.
That rule includes inherited horizontal margins and all block-level
indentation needed by the output target.
List prefixes are stored as optional ListPrefix data on the first
block of a list item.
The prefix is not copied into both the block text and an additional
terminal-text field.
CursorBlockTarget combines the terminal prefix with the block text
only when it writes the paragraph.
RenderBlock::renderString() uses the same prefix information for the
plain-string representation.
RenderBlock also keeps string-only indentation fields.
These preserve the current renderString() behavior without forcing
terminal output to carry duplicate text.
Block Scopes
TreeRenderer keeps a stack of open BlockScope values.
Each scope stores:
vertical margins for the container or list item
optional
ListItemLayoutinformationwhether the scope has already emitted a block
When TreeRenderer emits a block, every open scope sees that block.
For the first emitted block in a scope, the scope contributes its top
margin.
For a list-item scope, the first emitted block also receives the list
prefix layout.
Later blocks in the same list item receive continuation indentation.
When a scope closes, the scope patches the currently pending block in
BlockStream with its bottom margin.
That pending block is the last emitted descendant of the scope.
This is why the stream keeps one block back before flushing it to the
target.
List Rendering
Lists are rendered directly while the tree is walked. There is no temporary vector of blocks for each list item.
For each list item:
TreeRendererresolves the item rule and creates aListItemLayout.A list-item scope opens with that layout.
Child nodes stream normally.
The first emitted child block receives the marker prefix.
Later emitted child blocks receive continuation indentation.
The scope closes and applies the item bottom margin to the last emitted child block.
If a list item starts with a nested list, the renderer first emits an empty paragraph-like block for the item itself. This keeps the marker visible on its own line before the nested list.
If a list item emits no content at all, the renderer emits one empty list-item paragraph before closing the item scope.
Output Targets
renderString()
The plain-string path creates a StringBlockTarget and streams the
tree into it.
StringBlockTarget:
appends one newline between streamed blocks
delegates each block to
RenderBlock::renderString()trims outer newline-like separators from the final result
This remains a semantic string renderer. It does not simulate terminal width, wrapping, or cursor movement.
renderTo(CursorWriter)
The terminal path creates a CursorBlockTarget and streams the tree
into it.
CursorBlockTarget owns physical terminal emission:
vertical margin collapse
conversion from block styles to
ParagraphOptionsparagraph writing
filled-line painting
horizontal-rule painting
The tree renderer never writes directly to the terminal.
Allocation Notes
Allocation discipline in this subsystem comes from the shape of the pipeline:
there is no full
RenderBlocklist for normal outputBlockStreambuffers only the newest blockinline rendering reserves from the node’s estimated inline capacity
no-style selector lookups are cached in
StyleResolverlist prefixes are stored separately and combined only by the target that needs the combined text
list items stream directly instead of first collecting all descendant blocks
The design favors readable ownership over micro-optimization, but the main avoidable allocations from the previous collect-then-emit model are removed from the hot path.
Where to Change What
When extending the renderer, start in the component that owns the relevant behavior.
Adding a new block-emitting TextNode::Type
Start in TreeRenderer::appendNode().
Then choose the matching helper:
semantic container
list node
paragraph-like block
heading
horizontal rule
Changing inherited text style or horizontal margins
Start in RenderContext.
Changing vertical edge-margin behavior
Start in the BlockScope handling in TreeRenderer and the
one-block buffering behavior in BlockStream.
Changing selector matching or node token handling
Start in StyleResolver.
Changing inline rendering behavior
Start in InlineTextRenderer and
StringBuilderWithWhitespaceState.
Changing list prefix placement or continuation layout
Start in:
TreeRenderer::appendListItem()TreeRenderer::emitBlock()ListItemLayout
Changing block string projection
Start in RenderBlock::renderString().
Changing plain-string collection
Start in StringBlockTarget.
Changing terminal-only emission behavior
Start in CursorBlockTarget.
Testing Strategy
The primary semantic coverage lives in:
test/unittest/src/impl/text/render/HtmlRendererTest.cpptest/unittest/src/impl/text/render/TextNodeRendererTest.cpp
These tests exercise the public rendering behavior through
HtmlRenderer and TextNodeRenderer.
Focused internal tests cover the streaming collaborators:
test/unittest/src/impl/text/text-node/InlineTextRendererTest.cpptest/unittest/src/impl/text/text-node/RenderContextTest.cpptest/unittest/src/impl/text/text-node/StyleResolverTest.cpptest/unittest/src/impl/text/text-node/TreeRendererTest.cpp
Use both levels:
end-to-end tests for user-visible rendering behavior
focused component tests for subtle internal invariants
Summary
The renderer now follows this high-level ownership model:
TextNodeRendererselects the targetTreeRendererwalks the tree and streams blocksStyleResolverowns style lookupInlineTextRendererowns inline text constructionRenderContextcarries inherited text style and horizontal marginsBlockStreamkeeps one pending block for scope finalizationStringBlockTargetandCursorBlockTargetown the output format
The important structural rule is that tree walking, style lookup, inline text construction, scope handling, and output emission each have one clear owner. That keeps the renderer easier to read, easier to extend, and cheaper to run than a separate collection phase followed by a second output phase.