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

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

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

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

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

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

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

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

  • src/erbsland/cterm/ui/impl/ScrollMetrics.hpp

  • src/erbsland/cterm/ui/impl/ScrollMetrics.cpp

  • src/erbsland/cterm/ui/surface/AbstractScrollArea.hpp

  • src/erbsland/cterm/ui/surface/AbstractScrollArea.cpp

  • src/erbsland/cterm/ui/layout/Viewport.hpp

  • src/erbsland/cterm/ui/layout/Viewport.cpp

  • src/erbsland/cterm/ui/layout/SingleContentLayout.hpp

  • src/erbsland/cterm/ui/layout/SingleContentLayout.cpp

  • src/erbsland/cterm/ui/layout/ScrollArea.hpp

  • src/erbsland/cterm/ui/layout/ScrollArea.cpp

  • src/erbsland/cterm/ui/surface/ScrollingBufferView.hpp

  • src/erbsland/cterm/ui/surface/ScrollingBufferView.cpp

  • test/unittest/src/ui/layout/UiScrollAreaTest.cpp

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

Layer Model

The implementation is deliberately split into three layers:

impl::ScrollMetrics

Stateless helper functions for resolving content size, clamping offsets, calculating aligned origins, deriving scroll bar ranges, and implementing scrollIntoView().

surface::AbstractScrollArea

Shared 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::ScrollArea

Composition-based layout surfaces. SingleContentLayout keeps one optional content surface synchronized with generic container changes. Viewport positions that content surface. ScrollArea owns one viewport plus the scroll bar surfaces and forwards the public scroll API to the viewport.

Visualized:

--- config: theme: default fontFamily: Lato, proxima-nova, "Helvetica Neue", Arial, sans-serif --- flowchart TD metrics["impl::ScrollMetrics"] abstract["surface::AbstractScrollArea"] buffer["surface::ScrollingBufferView"] viewport["layout::Viewport"] area["layout::ScrollArea"] content["content surface"] bars["horizontal/vertical bars + corner"] metrics --> abstract metrics --> viewport abstract --> buffer abstract --> area area --> viewport area --> bars viewport --> content

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:

  • SingleContentLayout limits the count to one and keeps contentSurface() synchronized.

  • Sections creates default section metadata and stores explicit SectionOptions as layout data.

  • Buttons accepts only button surfaces and synchronizes its action dispatch list.

  • AbstractScrollArea protects 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 Stack layout item construction

  • are 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() and clampedScrollOffset()

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 scrollOffset from the regular origin.

scrollRegion() and visibleRegion()

Convert content size, viewport size, and offset into IndexRange values 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:

  1. If there is no content surface, or the content is hidden, content size and offset are reset to zero.

  2. Content size is resolved from the content geometry and viewport size.

  3. The stored scroll offset is clamped to the new metrics.

  4. The content origin is calculated from alignment and offset.

  5. The content surface receives a rectangle at that origin with the resolved content size.

  6. 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:

Hidden

The bar is hidden and consumes no space.

Automatic

The bar appears only if content overflows on that axis.

Visible

The 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 targetRect passed to onPaintArea()

  • interpret scrollOffset as content coordinates

  • call initializeScrollAreaChildren() in the factory after shared_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 SurfaceManager hooks 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 Surface and maintained by SurfaceContainer.

  • 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 viewOffset in UI surfaces.

  • Scroll math shared by more than one scroll component belongs in impl::ScrollMetrics.