Paragraph Layout and Painting
This page explains the wrapped-paragraph pipeline in
src/erbsland/cterm/impl/paragraph.
The implementation is split into three main parts:
impl::paragraph::Layoutbuilds a reusable width-aware layout.impl::paragraph::Painterpaints that layout into aWritableBuffer.impl::paragraph::Printerstreams the same layout through aCursorWriter.
RendererBase provides the shared placement and background-fill rules for the
two renderers.
How to read the visuals on this page
The visuals on this page are intentionally schematic. Plain-text diagrams show control flow and internal data structures. ANSI blocks show the final visible cells, with gray background cells used to expose spacing, indentation, and reserved areas that would otherwise be invisible.
Pipeline at a Glance
The current rendering path looks like this:
Relevant Source Files
You will find the implementation primarily in:
src/erbsland/cterm/impl/paragraph/Layout.hppsrc/erbsland/cterm/impl/paragraph/Layout.cppsrc/erbsland/cterm/impl/paragraph/LineBuilder.hppsrc/erbsland/cterm/impl/paragraph/LineBuilder.cppsrc/erbsland/cterm/impl/paragraph/LayoutPreparedSourceLine.hppsrc/erbsland/cterm/impl/paragraph/LayoutLineToken.hppsrc/erbsland/cterm/impl/paragraph/RendererBase.hppsrc/erbsland/cterm/impl/paragraph/Painter.hppsrc/erbsland/cterm/impl/paragraph/Painter.cppsrc/erbsland/cterm/impl/paragraph/Printer.hppsrc/erbsland/cterm/impl/paragraph/Printer.cppsrc/erbsland/cterm/Terminal.cppsrc/erbsland/cterm/CursorBuffer.cppsrc/erbsland/cterm/impl/TextPainter.cpp
One Example Through the Pipeline
The following simplified example is reused across several visuals below. It keeps the moving parts small enough to inspect:
source line
"Signal lanterns sailors home"
width
20 columns
selected options
wrappedLineIndent = 3
lineBreakStartMark = "» "
lineBreakEndMark = " ↩"
At the token and layout level, the data model looks like this:
prepared tokens
[0] Word("Signal")
[1] SeparatorSpace(" ")
[2] Word("lanterns")
[3] SeparatorSpace(" ")
[4] Word("sailors")
[5] SeparatorSpace(" ")
[6] Word("home")
physical lines
line 0:
indentWidth = 0
wrapsToNext = true
fragments = [SourceRange("Signal"), Spaces(1), SourceRange("lanterns")]
line 1:
indentWidth = 3
wrapsToNext = false
fragments = [LineBreakStartMark("» "), SourceRange("sailors"), Spaces(1), SourceRange("home")]
The wrap-end marker is not stored as a fragment inside LayoutLine.
Instead, renderers derive it from wrapsToNext and
ParagraphOptions::lineBreakEndMark() so it can stay anchored to the right
edge of the paragraph area.
After rendering, those two lines occupy the 20-cell grid like this:
01234567890123456789 Signal lanterns ↩ » sailors home
Why the Pipeline Is Split
The library deliberately separates paragraph work into layout plus rendering:
Layoutturns source text andParagraphOptionsinto a neutralLayoutResult.Painterrenders that result into an existing buffer and can use a color resolver for animated or context-sensitive colors.Printerrenders the same result as streamed cursor-writer output.RendererBasekeeps alignment and background-fill behavior identical between both rendering paths.
This split matters because the entry points have different output models while sharing the same wrap rules:
Terminal::printParagraph()andCursorBuffer::printParagraph()useLayoutplusPrinter.TextPainter::drawText()usesLayoutplusPainter.Layout failure is detected before any drawing occurs, so the caller can apply
ParagraphOnErrorconsistently.
Where the Pipeline Is Used
The layout engine supports two newline modes:
LayoutNewlineMode::HardLineBreak: each newline starts a new source line inside one printed paragraph. This is used for terminal and cursor-writer style output.LayoutNewlineMode::ParagraphBreak: each newline starts a new paragraph. This is used when text is rendered inside a rectangle.
That means:
Terminal::printParagraph()andCursorBuffer::printParagraph()treat embedded newlines as hard line breaks and appendParagraphSpacingonly after the whole call finishes.TextPainter::drawText()treats embedded newlines as paragraph boundaries, so paragraph spacing is inserted between the resulting paragraphs.
The Layout Result Data Model
Layout::build() produces a LayoutResult with:
valid–falseif the chosen width and paragraph settings cannot produce any visible layoutsourceText– the original string used by source-range fragmentsoptions– the paragraph options used for the buildlines– the rendered physical lines
Each LayoutLine stores:
indentWidth– the width reserved before the first visible fragmentwrappedFromPrevious– whether this line continues a previous physical linewrapsToNext– whether the renderer must add the configured wrap-end markerfragments– an ordered sequence of:SourceRangefragments that point back into the original textgenerated
SpacesLineBreakStartMarkWordBreakMarkParagraphEllipsis
This is enough for renderers to position text, keep the wrap-end marker aligned to the right edge, and apply background-fill rules without re-running layout.
How Input Is Split into Source Lines
Layout::build() starts with two structural steps:
Widths less than or equal to zero immediately produce
valid = false.splitIntoSourceLines()splits the input on newline characters.
The resulting source-line ranges are then interpreted differently depending on the newline mode:
In
HardLineBreakmode, all source lines belong to one rendered paragraph.In
ParagraphBreakmode, each source line is laid out as its own paragraph.
Two details are easy to miss:
Interior empty source lines are preserved, so explicit blank lines stay visible.
A trailing newline does not create an extra trailing source line because
splitIntoSourceLines()only appends the final range when it still contains characters.
When paragraph-break mode is active and
ParagraphSpacing::DoubleLine
is enabled, build() inserts one empty physical line between paragraphs.
Preparing a Source Line
Each source line is normalized by prepareSourceLine() into a
LayoutPreparedSourceLine. This step keeps enough structure for the later
line builder to handle separators, tabs, colors, and indentation correctly.
The prepared token stream uses three token kinds:
Word– a contiguous source word plus its cached display widthSeparatorSpace– one collapsed separator run that later becomes one rendered spaceTab– a left-aligned tab that will be resolved later from the current column and the configured tab stops
Tokenization depends on alignment:
For left-aligned paragraphs, TAB characters become dedicated
Tabtokens.For centered or right-aligned paragraphs, tabs are only separators if they are contained in
ParagraphOptions::wordSeparators().
LayoutPreparedSourceLine also keeps pending spacing tokens until the next
word is known. That gives the implementation two useful behaviors:
leading separator runs are dropped entirely
leading tabs are preserved for left-aligned paragraphs and resolved later from tab-stop settings
Visual Example of Token Preparation
source line
" title:\talpha,,,beta"
separators include ','
prepared tokens
[0] Word("title:")
[1] Tab
[2] Word("alpha")
[3] SeparatorSpace(",,,")
[4] Word("beta")
The important detail is that the three commas do not survive as three rendered cells. They collapse into one logical separator token that later becomes one generated space with the separator run’s color.
Why Empty Source Lines Are Preserved
If prepareSourceLine() produces no tokens, layoutSourceLine() appends
an empty LayoutLine immediately.
That keeps explicit blank input lines visible in both newline modes. In paragraph-break mode, the configured paragraph spacing is then applied around those empty paragraphs in exactly the same way as for non-empty ones.
Building Physical Lines
layoutSourceLine() turns one prepared source line into one or more physical
lines by creating a LineBuilder.
The builder tracks:
tokenIndex– the next token in the prepared source linewordOffset– the current offset inside a split word tokentabStopIndex– the next configured tab stop to consume
For one source line, the control flow is:
This keeps the last line, wrapped lines, and truncated lines on the same
buildLine() foundation instead of maintaining three unrelated code paths.
The buildLine() Algorithm
buildLine() is the core routine behind:
tryBuildLastLine()buildWrappedLine()buildTruncatedLine()
Its steps are:
Reserve suffix width for either the wrap-end marker or the paragraph ellipsis.
Build the prefix from
indentWidthplus an optional start mark on wrapped continuation lines.Evaluate the next spacing run until the next word token is reached.
If the next whole word fits, append the spacing run and the word.
If the word does not fit but the line already contains visible content, stop and let the caller emit a wrapped or truncated continuation line.
If the word does not fit and the line is still empty, try splitting the word.
If even that cannot place any visible content, return
std::nulloptso the whole layout becomes invalid.
Two details matter here:
The optional wrap-start mark is only added for wrapped continuation lines in left-aligned paragraphs.
The line builder counts automatic wraps per source line. A new source line creates a new
LineBuilder, which is why hard line breaks reset the wrap counter.
Spacing Runs, Tabs, and Separators
Spacing is processed as a run before the next word, not as isolated tokens. That is why multiple separators still collapse cleanly and tabs can force a line break before the next visible word is emitted.
Rules for separator tokens:
leading separator runs are ignored
once visible content exists on the line, a separator run contributes exactly one generated space
Rules for tabs in left-aligned paragraphs:
each tab consumes the next entry from
ParagraphOptions::tabStops()resolveTabStop()resolvescTabWrappedLineIndentto the configured wrapped-line indentif the resolved tab stop is ahead of the current column, the tab expands into that many spaces
If the resolved stop does not advance the current column, or if no further stop
exists, tabOverflowBehavior() decides what happens next:
AddSpaceinserts one space and continuesLineBreakconsumes the tab and continues on the next physical line
The implementation also includes one safeguard for LineBreak:
if the tab appears at the start of a line and would not add any visible content,
the tab is consumed and dropped instead of producing an endless stream of empty
wrapped lines.
Word Splitting
Splitting is handled by LayoutLineToken::split() and is only attempted when
a word does not fit and the current physical line still contains no visible
text.
The split logic:
reserves room for
wordBreakMark()when a wrapped line is being builtwalks the word character by character
counts visible width using each
Char’s display widthskips zero-width characters when calculating the consumed width
stops before overflow
adds the break mark only when there is still source text left for later lines
Because the split works on Char display widths, it stays consistent with the
library’s Unicode width system.
Truncation and Wrap Limits
maximumLineWraps() limits the number of automatic wraps produced while
laying out one source line.
When the limit is reached, buildTruncatedLine() rebuilds the current line
with width reserved for paragraphEllipsisMark().
Important consequences:
hard line breaks and paragraph breaks reset the wrap counter because they start a new source line
the ellipsis consumes real width and can itself make the layout invalid
truncation still respects indentation, start marks, tabs, and Unicode width
Rendering the Layout
Once a LayoutResult exists, rendering is handled by Painter or
Printer. Neither renderer performs line breaking; they only consume the
precomputed layout.
Painting into Buffers
Painter::paint() determines how many layout lines fit into the target
rectangle, applies vertical alignment, and then draws each line into the buffer.
For each visible line it:
computes the final placement with
linePlacement()draws the line fragments starting at
textX + indentWidthdraws the wrap-end marker at
endMarkXwhenwrapsToNextis setapplies left and right background fill according to
ParagraphBackgroundMode
Painter uses an optional color resolver. When present, each character color
is resolved against the current position before it is merged with the buffer’s
existing base color.
Printing through CursorWriter
Printer::print() uses the same placement rules, but because it streams text
instead of mutating an existing buffer, it must materialize indentation and
padding as spaces.
That leads to two practical differences from Painter:
left padding, indentation, gaps before the end mark, and trailing fill are all written explicitly as spaces
spaces not covered by wrapped-text background use the writer’s current background color
Printer resolves each emitted character against the writer’s current base
style before sending it to CursorWriter.
Background Extension Modes
Background fill is controlled by
ParagraphBackgroundMode.
RendererBase splits the logic into three decisions:
whether continuation indents use left fill
whether wrapped lines use right fill
whether the final physical line also uses right fill
Left fill:
uses the last visible color from the previous wrapped line
applies only to the continuation indent of the next line
is enabled by
WrappedLeft,WrappedBoth, andFullBoth
Right fill:
uses the last visible color on the current physical line
fills either up to the wrap-end marker or, for
FullRight/FullBoth, to the right edge of the paragraph area
Two rendering paths expose the same modes differently:
Painterpreserves the surrounding buffer content wherever the selected mode does not paint.Printermust emit spaces, so non-filled padding uses the current cursor-writer background instead.
Visual Example of WrappedBoth
The following block shows both fill directions at once:
right fill continues the wrapped line’s background up to the end mark
left fill carries that background into the continuation indent
01234567890123456789 Signal lanterns ↩ » sailors home
FullRight and FullBoth keep going one step further: their final line
also fills to the right edge of the paragraph area even when it no longer
wraps.