UI Scrollable Areas
This page explains how the UI layer implements scrollable content, managed child containers, and visibility-aware traversal.
The scroll framework has one central rule:
Scrolling state is shared, content ownership is not.
The common scroll base owns offsets, ranges, and scroll bar state, while the concrete surface decides whether content is custom-painted or managed as a child surface.
Relevant Source Files
You will find the implementation primarily in:
src/erbsland/cterm/ui/AbstractSurfaceContainer.hppsrc/erbsland/cterm/ui/Surface.hppsrc/erbsland/cterm/ui/Surface.cppsrc/erbsland/cterm/ui/SurfaceContainer.hppsrc/erbsland/cterm/ui/SurfaceContainer.cppsrc/erbsland/cterm/ui/SurfaceManager.hppsrc/erbsland/cterm/ui/SurfaceManager.cppsrc/erbsland/cterm/ui/LayoutData.hppsrc/erbsland/cterm/ui/impl/ScrollMetrics.hppsrc/erbsland/cterm/ui/impl/ScrollMetrics.cppsrc/erbsland/cterm/ui/surface/AbstractScrollArea.hppsrc/erbsland/cterm/ui/surface/AbstractScrollArea.cppsrc/erbsland/cterm/ui/layout/Viewport.hppsrc/erbsland/cterm/ui/layout/Viewport.cppsrc/erbsland/cterm/ui/layout/SingleContentLayout.hppsrc/erbsland/cterm/ui/layout/SingleContentLayout.cppsrc/erbsland/cterm/ui/layout/ScrollArea.hppsrc/erbsland/cterm/ui/layout/ScrollArea.cppsrc/erbsland/cterm/ui/surface/ScrollingBufferView.hppsrc/erbsland/cterm/ui/surface/ScrollingBufferView.cpptest/unittest/src/ui/layout/UiScrollAreaTest.cpptest/unittest/src/ui/surface/UiScrollingBufferViewTest.cpp
Layer Model
The implementation is deliberately split into three layers:
impl::ScrollMetricsStateless helper functions for resolving content size, clamping offsets, calculating aligned origins, deriving scroll bar ranges, and implementing
scrollIntoView().surface::AbstractScrollAreaShared base for custom-painted scrollable surfaces. It owns
scrollOffset, viewport metrics, scroll bar modes, the two scroll bars, and the scroll corner. Subclasses provide content size and paint the visible area.layout::SingleContentLayout/layout::Viewport/layout::ScrollAreaComposition-based layout surfaces.
SingleContentLayoutkeeps one optional content surface synchronized with generic container changes.Viewportpositions that content surface.ScrollAreaowns one viewport plus the scroll bar surfaces and forwards the public scroll API to the viewport.
Visualized:
Managed Child Containers
Surface::surfaces() returns AbstractSurfaceContainer. The real
storage is SurfaceContainer, which owns parent links, reparenting,
cycle checks, ordering, layout invalidation, and focus cleanup.
The policy layer is SurfaceManager. A manager is attached to a
SurfaceContainer and observes the normal container operations. It can
limit the child count, reject an invalid child, provide default
LayoutData, or keep layout-specific cached state synchronized.
Importantly, the manager does not replace the container. Generic
algorithms still go through surfaces() and get the same parent-link
behavior as hand-written layout APIs.
Layout inherits SurfaceManager with permissive defaults and
attaches itself to its child storage. Layout subclasses override only the
policy hooks they need:
SingleContentLayoutlimits the count to one and keepscontentSurface()synchronized.Sectionscreates default section metadata and stores explicitSectionOptionsas layout data.Buttonsaccepts only button surfaces and synchronizes its action dispatch list.AbstractScrollAreaprotects implementation-owned scroll bar children.
For Viewport, generic mutation is intentionally valid when it
preserves the one-content invariant:
viewport->surfaces().add(content);
viewport->surfaces().remove(content);
Adding a second child throws. Removing the only child leaves an empty
viewport with stable layout behavior. ScrollArea is different: its
direct child surfaces are implementation-owned, so public structural
mutation of those children is rejected. User content belongs to the
internal viewport and is assigned through setContentSurface().
Visibility Traversal
Visibility is a local flag on SurfaceFlags and is accessed through
surface->flags().
Hidden surfaces:
remain in their parent container
keep their last rectangle
are ignored by default layout traversal
are ignored by
Stacklayout item constructionare ignored by dirty collection and paint traversal
cannot receive or retain focus
still keep scheduled actions armed and executable
The scheduler rule is intentional. A hidden surface may still update internal state, reschedule work, or show itself later.
The display and focus code use effective visibility.
flags().isVisibleInTree() checks the surface and all ancestors, while
layout code usually only needs to test direct children with
flags().isVisible().
Scroll Metrics
impl::ScrollMetrics centralizes the math that must stay consistent
between AbstractScrollArea, Viewport, and
ScrollingBufferView.
The key calculations are:
resolveScrollContentSize()Resolves content geometry against a candidate viewport. A grow policy expands to the viewport size, bounded by minimum and maximum. A non-grow policy keeps the preferred size within its bounds. This is what allows constrained content to remain smaller or larger than the viewport.
maximumScrollOffset()andclampedScrollOffset()Derive the valid offset range. The viewport extent is treated as at least one cell when computing the maximum offset, so an empty viewport does not create negative scroll ranges.
alignedContentOrigin()Anchors undersized content by alignment and scrolls oversized content by subtracting
scrollOffsetfrom the regular origin.scrollRegion()andvisibleRegion()Convert content size, viewport size, and offset into
IndexRangevalues for the scroll bars.scrollOffsetForVisibleRect()Implements minimal movement for
scrollIntoView(). If the target rectangle is already visible, the offset is unchanged. If it is outside the viewport, the offset moves just enough to reveal it. If the target is larger than the viewport on an axis, its leading edge wins.
Viewport Layout
Viewport owns at most one content surface through
SingleContentLayout.
During layout:
If there is no content surface, or the content is hidden, content size and offset are reset to zero.
Content size is resolved from the content geometry and viewport size.
The stored scroll offset is clamped to the new metrics.
The content origin is calculated from alignment and offset.
The content surface receives a rectangle at that origin with the resolved content size.
The content surface receives its own layout pass.
This means content can naturally be:
smaller than the viewport and aligned inside it
exactly viewport-sized because it grows
larger than the viewport because its geometry requires it
setScrollOffset() and scrollIntoView() calculate current content
metrics from the current rectangle before clamping. This allows scroll
requests to be made after assigning a viewport rectangle but before the
first layout pass.
Scroll Area Layout
ScrollArea inherits AbstractScrollArea and adds one viewport as
an internal child. The base class resolves the viewport rectangle and
scroll bar visibility. The derived layout then assigns the viewport
rectangle, forwards the current scroll offset, and layouts the viewport.
The direct children of a scroll area are protected implementation
surfaces; the public content surface is owned by the viewport.
The internal children are persistent:
ScrollArea
Viewport
content surface
HorizontalScrollBar
VerticalScrollBar
ScrollCorner
Scroll bar mode is configured per orientation:
HiddenThe bar is hidden and consumes no space.
AutomaticThe bar appears only if content overflows on that axis.
VisibleThe bar appears whenever the scroll area has enough area for that orientation.
Automatic visibility needs a stable pass. A vertical bar consumes one
column and can force horizontal overflow. A horizontal bar consumes one
row and can force vertical overflow. resolveScrollBarVisibility() runs
up to three passes, which is enough for both axes to stabilize.
When a bar is hidden, it remains in child storage but its visibility flag is false. Hidden bars are skipped by layout, painting, and focus routing, and the previous rectangle is left untouched.
Custom-Painted Scroll Areas
AbstractScrollArea is for scrollable content that is not represented
by one child surface. ScrollingBufferView is the simplest example:
the source buffer is not a child surface, so the subclass reports the
buffer size from contentSizeForViewport() and draws a BufferView
inside onPaintArea().
Subclass responsibilities are:
return the content size for a candidate viewport
paint only inside the
targetRectpassed toonPaintArea()interpret
scrollOffsetas content coordinatescall
initializeScrollAreaChildren()in the factory aftershared_from_this()is safe
Maintainer Rules
When changing the scroll framework, keep these invariants intact:
Generic child mutation goes through
surfaces()and should work whenever it preserves the layout’s invariants.Use
SurfaceManagerhooks for layout policy; do not reintroduce proxy containers for ordinary child-count or metadata constraints.Internal framework children are added through
childStorage()and protected by manager policy when public mutation would break the structure.Parent pointers are owned by
Surfaceand maintained bySurfaceContainer.Hidden surfaces keep their rectangles.
Layouts skip hidden direct children instead of clearing their rectangles.
Display traversal and focus routing must use effective visibility.
Scroll offset names stay consistent; do not reintroduce
viewOffsetin UI surfaces.Scroll math shared by more than one scroll component belongs in
impl::ScrollMetrics.