Paragraph Layout and Painting
This page explains the internal paragraph rendering pipeline used for wrapped text.
It focuses on ParagraphLayout and
ParagraphPainter.
Together, these components transform a logical paragraph into positioned and painted terminal cells.
Relevant Source Files
You will find the implementation primarily in:
src/erbsland/cterm/impl/ParagraphLayout.hppsrc/erbsland/cterm/impl/ParagraphLayout.cppsrc/erbsland/cterm/impl/ParagraphPainter.hppsrc/erbsland/cterm/impl/ParagraphPainter.cppsrc/erbsland/cterm/Terminal.cppsrc/erbsland/cterm/impl/TextPainter.cpp
Why the Rendering Is Split into Two Classes
The library deliberately separates paragraph rendering into two distinct phases:
ParagraphLayoutconverts source text andParagraphOptionsinto a neutral layout result.ParagraphPainterrenders that result into a concreteWritableBuffer.
This separation addresses several design goals:
The same wrapping logic is reused across different front ends.
Terminal::printParagraph()builds a temporary buffer for direct output, whileTextPainter::drawText()renders into an existing screen buffer.Layout can fail early, before any drawing occurs. If indentation, markers, or width constraints make rendering impossible, the caller receives
Result.valid == falseand can react accordingly.Painting can apply context-specific coloring without affecting layout logic.
TextPainteruses a color resolver for dynamic effects, whileTerminalfollows a simpler default path.The layout result retains semantic information such as
indentWidth,wrapsToNext, and the separately alignedendMark. This allows the painter to implement background rules without recomputing layout decisions.
Where the Pipeline Is Used
There are currently two primary entry points:
Terminal::printParagraph()usesParagraphLayout::NewlineMode::HardLineBreak. Every newline in the source text becomes a line break within a single paragraph.TextPainter::drawText()usesParagraphLayout::NewlineMode::ParagraphBreak. Each newline starts a new paragraph, allowing spacing between paragraphs.
This distinction is subtle but important. It allows the same layout engine to support both immediate terminal output and structured layout inside a defined rectangle—without duplicating logic.
The Layout Result Data Model
ParagraphLayout produces a Result consisting of physical Line objects.
Each Line contains:
text– the visible content, including indentation and optional start markersendMark– an optional right-aligned wrap marker, painted separatelyindentWidth– the width of the indentation area before visible contentwrappedFromPrevious– whether this line continues a previous onewrapsToNext– whether this line continues onto the next one
Separating text from endMark is intentional.
The end mark must remain visually aligned to the right edge,
independent of text alignment within the available space.
How Input Is Split into Paragraphs
build() begins with validation and then processes the input in two steps:
Widths less than or equal to zero immediately result in
valid = false.splitInputIntoParagraphs()determines how newline characters are interpreted.
The splitting rules are:
In
HardLineBreakmode, the entire input is treated as a single paragraph with multiple lines.In
ParagraphBreakmode, each source line becomes its own paragraph.
When paragraph-break mode is active and
ParagraphSpacing::DoubleLine is enabled,
build() inserts an empty physical line between paragraphs.
Preparing a Source Line
Each source line is normalized by prepareSourceLine() into a sequence of WordItem objects.
This step is central to the layout algorithm.
What prepareSourceLine() Preserves
Instead of simply splitting on ASCII spaces, the implementation preserves enough structure to accurately reproduce:
collapsible inter-word spacing
tab stops
spacing colors
the distinction between leading indentation and regular spacing
Each WordItem contains:
word– the textual contentprefixSpacing– the spaces and tabs preceding the word
Spacing is stored as SpacingElement values rather than expanded text.
This allows tabs to be resolved later based on the current column.
Word Separators and Tabs
Parsing depends on alignment:
For left-aligned paragraphs, TAB characters are treated as tab-stop elements.
For centered or right-aligned paragraphs, TAB is only treated as a separator if included in
ParagraphOptions::wordSeparators().
Separators are defined by ParagraphOptions::wordSeparators().
Consecutive separators collapse into at most one rendered space.
Two subtle but important details:
Leading spaces are removed via
removeLeadingSpaces(). A paragraph does not start with arbitrary spacing unless explicitly configured.spaceFrom()converts separators into colored spaces, preserving their visual appearance without rendering the original characters.
Why Empty Source Lines Are Preserved
If prepareSourceLine() produces no words,
layoutSourceLine() emits an empty physical Line.
This ensures that explicit blank lines remain visible in HardLineBreak mode.
Building Physical Lines
layoutSourceLine() transforms prepared input into one or more physical lines.
Progress is tracked using State:
wordIndex– current wordwordOffset– offset within a split wordspacingOffset– remaining prefix spacingtabStopIndex– next tab stop to use
The algorithm proceeds as follows:
tryBuildLastLine()checks whether all remaining content fits.If not, and the wrap limit is reached,
buildTruncatedLine()produces the final line.Otherwise,
buildWrappedLine()creates a continuation line and advances the state.
This structure avoids special handling for the final line—it is simply a normal build with stricter constraints.
The buildLine() Algorithm
buildLine() is the core routine used for all line types.
It performs these steps:
Reserve space for suffix elements such as end marks or ellipsis.
Build the prefix (indentation and optional start mark for wrapped lines).
Process the current word and its prefix spacing.
Expand spacing using
evaluatePrefixSpacing().If the full word fits, append it and continue.
If it does not fit and the line already has content, stop here.
If it does not fit and the line is still empty, attempt splitting via
buildSplitWord().If no content can be placed, return
std::nullopt.
This final case is what results in Result.valid == false.
Indentation and Wrapped-Line Start Marks
Indentation is only applied for left-aligned paragraphs.
calculateIndentWidth() returns:
firstLineIndent()for the first linewrappedLineIndent()for continuation lines0for centered or right-aligned paragraphs
If a continuation line uses a start mark, it is appended after the indentation.
Tabs and Prefix Spacing Resolution
evaluatePrefixSpacing() converts SpacingElement entries into concrete output.
Rules for spaces:
Leading spaces at the beginning of a line are ignored.
Subsequent spaces are rendered normally.
Tabs behave differently:
Each tab consumes one entry from
ParagraphOptions::tabStops().resolveTabStop()supportscTabWrappedLineIndentto reuse indentation as a tab stop.If a tab advances the column, it expands into that many spaces.
If no advancement occurs or no tab stops remain,
tabOverflowBehavior() applies:
AddSpace→ replace with a single spaceLineBreak→ break the line (with safeguards to avoid infinite loops)
Word Splitting
buildSplitWord() is used only when:
a word does not fit, and
the line contains no visible content yet
This ensures splitting is a fallback, not the default.
The algorithm:
reserves space for
wordBreakMark()if configurediterates character by character
ignores zero-width characters
stops before overflow
appends the break mark only if a split occurred
Because it operates on Char values,
it respects display width, including wide and combining characters.
Truncation and Wrap Limits
maximumLineWraps() limits how often a line may wrap.
When the limit is reached,
buildTruncatedLine() rebuilds the line with space reserved for
paragraphEllipsisMark().
Important consequences:
Hard line breaks reset the wrap counter.
The ellipsis consumes real width and can cause layout failure.
Truncation remains width-aware.
How Painting Works
Once a ParagraphLayout::Result is available,
ParagraphPainter maps it into a WritableBuffer.
The painter does not perform layout. It only positions lines and applies visual rules.
Vertical and Horizontal Placement
paint() determines how many lines fit into the target rectangle.
Excess lines are clipped.
Vertical placement:
top →
rect.y1()center → offset by unused height
bottom → align last line to
rect.y2()
Horizontal placement:
The endMark width is excluded first,
ensuring it remains anchored at the right edge.
The remaining text is aligned within the reduced width.
Drawing Characters
drawSegment() renders a string and returns the final color.
For each character:
Skip zero-width characters
Stop if exceeding the right boundary
Check bounds
Resolve colors
Write the character
Advance by display width
Color handling differs:
Without resolver → merge with existing buffer via
withBaseColor()With resolver → apply dynamic color before merging
The latter is used by TextPainter.
Background Extension Modes
Background behavior is controlled by
ParagraphBackgroundMode.
This logic is separate from layout.
Right-Side Fill
The background extends using the color of the last visible character.
With
endMark→ fill up to the markWithout → fill to the right edge depending on mode
Left-Side Fill for Continuation Indents
Left fill uses previousWrapColor.
If a line wraps:
the last visible color is stored
the next line uses it to fill indentation
This relies on indentWidth and wrapsToNext from the layout result.