UI Paint System
This page explains how the UI layer turns dirty surfaces into clipped paint calls.
The paint system has one central rule:
Every surface paints only itself.
Child traversal, clipping, repaint ordering, dirty-flag cleanup, and screen-buffer composition are owned by
Display. This keeps individual surfaces simple and lets the display repaint
only the parts of the surface tree that can affect the terminal buffer.
Relevant Source Files
You will find the implementation primarily in:
src/erbsland/cterm/ui/Display.hppsrc/erbsland/cterm/ui/Display.cppsrc/erbsland/cterm/ui/Surface.hppsrc/erbsland/cterm/ui/Surface.cppsrc/erbsland/cterm/ui/SurfaceFlags.hppsrc/erbsland/cterm/ui/SurfaceFlags.cppsrc/erbsland/cterm/ui/PaintContext.hpptest/unittest/src/ui/surface/UiPaintSystemTest.cpp
Surface Ownership
The surface tree describes ownership and layout:
Painting does not recurse through Surface::onPaint().
Display walks the tree and calls each affected onPaint() directly.
Surface::onPaint(buffer, context)
paints this surface only
does not paint child surfaces
does not clear paint dirty flags
Display
finds dirty visible surfaces
computes clipped damage rectangles
paints from the correct opaque base
paints overlapping later siblings
clears paint dirty state before calling onPaint()
Dirty State and Damage
Paint invalidation is surface-local.
Calling surface->flags().setPaintOutdated() marks that surface dirty, but it does not automatically mark all
parents dirty.
This is what allows a ticking status item to repaint without forcing the whole page to run onPaint().
SurfaceFlags stores two pieces of paint state:
a dirty flag
either a local dirty rectangle or an implicit full-surface repaint
Display refreshes repaint-time action state, resolves the theme context, and clears the dirty state immediately
before calling onPaint().
If a surface invalidates itself while it is painting, that new dirty state remains set for a future render cycle.
Layout invalidation propagates to ancestors, because a child geometry change can require parent layout work. Rectangle changes also damage the vacated and new child rectangles in the parent, so both screen areas are repainted.
Finding Paint Requests
Before rendering, Display calculates layout if needed. Then it scans the visible page stack from top to bottom,
stopping behind the first opaque page.
For each visible dirty surface, the display:
Uses the surface coordinate helpers to calculate the surface’s screen rectangle.
Clips it through all ancestor rectangles.
Converts the local dirty rectangle into a screen damage rectangle.
Chooses the opaque base where repainting must start.
The opaque-base rule is the key to avoiding unnecessary parent paints:
If the dirty surface is opaque, its own visible cells fully cover the parent, so repainting can start there. If the dirty surface is transparent, content behind it can show through, so repainting starts at the nearest opaque ancestor and covers the damaged region from back to front.
Fully clipped surfaces are ignored during rendering and keep their dirty state. If a later layout or move makes them visible, they will be considered again.
Clipping and Local Coordinates
Every onPaint() receives a WriteClippedBufferRef.
The wrapped buffer maps local surface coordinates into the display buffer and rejects writes outside the current dirty
rectangle.
Surface provides the coordinate helpers used by the paint algorithm:
localSurfaceRect()Returns the full local rectangle of the surface, with
0,0as the top-left corner.localToScreen()andscreenToLocal()Convert rectangles between local surface coordinates and screen coordinates.
visibleScreenRect()Clips the surface rectangle through its ancestors and stops as soon as the visible area becomes empty.
The paint context uses local coordinates:
surfaceRectThe full local rectangle of the surface. Usually
{0, 0, width, height}.visibleRectThe part of the surface that survives clipping by all ancestors.
dirtyRectThe part of the surface that should be repainted in this paint call.
For a child that starts left of its parent, the local visible area may begin at a positive x coordinate:
parent local x: 0 1 2 3 4
child local x: -2-1 0 1 2 3 4
| clipped |
child surfaceRect = 0,0:5x1
child visibleRect = 2,0:3x1
child dirtyRect = 2,0:3x1
Surface code should draw as if the surface starts at 0,0.
The clipped buffer decides which writes reach the display buffer.
Repaint Order
Within one parent, child order is paint order. Later children visually cover earlier children.
When a repaint starts at an opaque child, Display repaints later overlapping siblings, because they may cover the
dirty child:
The display therefore paints:
the opaque base and its intersecting descendants
later siblings in the same parent
later siblings of each ancestor on the path back to the page
This preserves visual stacking without repainting unrelated earlier siblings or parents behind an opaque base.
Maintainer Rules
When implementing or reviewing a surface, use these rules:
onPaint()paints only the surface’s own content.Do not call child
onPaint()methods.Do not clear paint dirty flags in normal surface code;
Displayclears them before callingonPaint().Use local coordinates. Treat
0,0as the top-left corner of the surface.Use
context.surfaceRect()for layout and alignment decisions.Use
context.visibleRect()when a surface can skip expensive work outside the ancestor-clipped area.Use
context.dirtyRect()when a surface can cheaply redraw only the damaged sub-region.isOpaque()must returntrueonly when the surface paints every visible cell of its rectangle.
For debugging repaint issues, start by asking:
Is the dirty surface visible after ancestor clipping?
Is the dirty surface opaque, or should repaint start at an ancestor?
Does a later sibling overlap the damaged screen rectangle?
Did the surface invalidate itself during
onPaint()and intentionally keep its dirty flag?