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.hpp

  • src/erbsland/cterm/ui/Display.cpp

  • src/erbsland/cterm/ui/Surface.hpp

  • src/erbsland/cterm/ui/Surface.cpp

  • src/erbsland/cterm/ui/SurfaceFlags.hpp

  • src/erbsland/cterm/ui/SurfaceFlags.cpp

  • src/erbsland/cterm/ui/PaintContext.hpp

  • test/unittest/src/ui/surface/UiPaintSystemTest.cpp

Surface Ownership

The surface tree describes ownership and layout:

--- config: theme: default fontFamily: Lato, proxima-nova, "Helvetica Neue", Arial, sans-serif --- flowchart TD page["Page (opaque)"] root["root layout or panel"] left["child A"] right["child B"] overlay["overlay child"] page --> root root --> left root --> right right --> overlay

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.

--- config: theme: default fontFamily: Lato, proxima-nova, "Helvetica Neue", Arial, sans-serif --- sequenceDiagram participant S as Surface participant D as Display S->>S: flags().setPaintOutdated() D->>S: clear paint dirty state D->>S: onPaint() S->>S: flags().setPaintOutdated() Note over S: dirty for next render

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:

  1. Uses the surface coordinate helpers to calculate the surface’s screen rectangle.

  2. Clips it through all ancestor rectangles.

  3. Converts the local dirty rectangle into a screen damage rectangle.

  4. Chooses the opaque base where repainting must start.

The opaque-base rule is the key to avoiding unnecessary parent paints:

--- config: theme: default fontFamily: Lato, proxima-nova, "Helvetica Neue", Arial, sans-serif --- flowchart TD dirtyOpaque["dirty opaque child"] dirtyTransparent["dirty transparent child"] parent["parent surface"] page["nearest opaque ancestor or page"] dirtyOpaque --> opaqueResult["start repaint at the child"] dirtyTransparent --> parent parent --> page page --> transparentResult["start repaint at opaque ancestor"]

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,0 as the top-left corner.

localToScreen() and screenToLocal()

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:

surfaceRect

The full local rectangle of the surface. Usually {0, 0, width, height}.

visibleRect

The part of the surface that survives clipping by all ancestors.

dirtyRect

The 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:

--- config: theme: default fontFamily: Lato, proxima-nova, "Helvetica Neue", Arial, sans-serif --- flowchart LR base["dirty child A"] sibling["later sibling B"] ancestorSibling["later ancestor sibling C"] base --> baseSubtree["paint A subtree"] baseSubtree --> sibling sibling --> ancestorSibling

The display therefore paints:

  1. the opaque base and its intersecting descendants

  2. later siblings in the same parent

  3. 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; Display clears them before calling onPaint().

  • Use local coordinates. Treat 0,0 as 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 return true only 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?