Modules/Widgets/docs/ARCHITECTURE.md
Шурупов Илья Викторович 81949323d9 Rename the code name to Sketch2D
Stroke2D becomes Sketch2D across folders, files, namespaces, classes,
targets, scripts, the Android package (online.ilushabasic.sketch2d)
and the update URL. A stroke2d-sketches folder is adopted like the
older leo-sketches one.
2026-10-17 19:47:01 +03:00

14 KiB
Raw Permalink Blame History

Widgets

A small retained-mode UI library: minimal, flexible, headless-testable.

  • Widgets — the library. Depends on Math only (plus the InputCodes / Rect / Color headers). No window, no GPU.
  • WidgetsApp — the adapter that runs a Ui inside a Graphics window: CanvasPainter, InputTranslator, WidgetApplication.
  • WidgetsGallery / WidgetsExample — a gallery of every widget, used both as a demo application and as an end-to-end test subject.
  • TestWidgets — 178 headless tests.

Five concepts

Concept Responsibility
Widget A node: children, sizing request, assigned rect, state, hooks
Layout Optional strategy on a container: measure children, then arrange them
Event A value delivered to widgets in their local coordinates; bubbles to ancestors until accepted
Popup A widget shown above the tree, anchored to a rect, with placement and dismissal rules
Ui The context: owns layout, input routing, popups, ticking, tooltips, stats

Frame

ui.dispatch(event)   input: hit test, hover diff, capture, click synthesis, bubbling, popup dismissal
ui.advance(deltaMs)  ticks: widget animations, geometry springs, tooltip dwell
ui.updateLayout()    only if something requested layout: measure (cached) + arrange (skips clean subtrees)
ui.paint(painter)    tree, then popups, each subtree translated and clipped
ui.needsFrame()      true while layout/paint is pending, something animates, or a tooltip is pending

Layout always runs before an event is routed and after it is handled, so handlers and hit testing never see stale geometry.

Writing a widget

A custom widget overrides only what it needs:

class Swatch : public ui::Widget {
public:
	explicit Swatch(RGBA color) : mColor(color) {}

protected:
	ui::Extent measureContent(ui::Axis, halnf) const override { return ui::Extent::flexible(24); }
	void paint(ui::Painter& painter) override { painter.fillRect(localRect(), mColor, 4); }
	void onEvent(ui::Event& event) override {
		if (event.type() == ui::Event::Type::Click) event.accept();
	}

private:
	RGBA mColor;
};

Hooks: measureContent, onEvent, onTick, paint, paintOverlay, hitTest, acceptsKeyboardFocus, onHoverChanged, onAttached, onDetached. Requests: requestLayout, requestPaint, startTicking, stopTicking. Every built-in widget is written against exactly this API.

Sizing

Each axis has one AxisSizing:

Mode Meaning
fit() Preferred size comes from content (text, icon, children)
fixed(length) Exactly this length
fill(weight) Content size, then grows with weight share of leftover space

Each can be bounded with .atLeast(min) and .atMost(max). Resolution produces an Extent { minimum, preferred, maximum, grow } that layouts work with.

measureContent declares how compressible the content is:

Extent Use
Extent::natural(length) Never smaller than its content: text, buttons, toggles, menu items
Extent::shrinkable(preferred, minimum) Can give up space down to minimum: slider tracks, separators, generic boxes
Extent::rigid(length) Exactly this length

When a container overflows, space is taken back only from items with slack above their minimum. A ScrollView reports a zero minimum along its scroll axis, so a long list scrolls instead of squashing the controls around it.

Algorithms

Measure (bottom-up, cached per axis and cross length): a leaf reports measureContent; a container asks its Layout; the widget's AxisSizing then resolves the result. The cache is invalidated up the ancestor chain by requestLayout.

Distribute (distributeSpace): start each item at its preferred length, then repeatedly give out free space by grow weight (or take it back proportionally to each item's slack above its minimum), clamping to [minimum, maximum]. Free space is recomputed from scratch every round, so the loop converges in at most n + 1 rounds without epsilon tricks.

Snap (placeSnapped): edges are rounded, not lengths, so adjacent children always tile without gaps.

Arrange (top-down): a parent assigns each child a rect. A child whose rect is unchanged and which has no pending layout is skipped entirely.

Popup placement (placePopup): place on the requested side, flip to the opposite side if it doesn't fit, then slide inside the viewport.

Hover: diff the old and new root-to-target paths and notify only the widgets that entered or left.

Layouts

Layout Use
RowLayout / ColumnLayout (BoxLayout) Flex-like stacking with gap, padding, main/cross alignment
StackLayout Every child fills the whole area (pages, overlays)
FreeLayout Children at requestPosition() with their preferred size (floating panels, desks)
GridLayout Equal columns, rows as tall as their tallest cell

SplitView and ScrollView own private layouts with typed slots instead of "child number N means X".

Docking

DockArea is a CLion-style tool-window frame around a center widget, built from DockPanels:

  • Stripes. A thin rail on the left and right lists every panel. Left and bottom panels share the left rail (bottom panels at its lower end); right panels use the right rail. Clicking a stripe button toggles its panel.
  • One panel per side. Opening a panel closes the other one docked on the same side, as in CLion.
  • Header. Title, a "⋮" options button and a "−" hide button, as in CLion. The options menu has View Mode (Dock Pinned, Dock Unpinned, Float), Move To (Left, Right, Bottom) and Hide.
  • View modes:
    • Docked / pinned: the panel takes a zone and pushes the center aside.
    • Undocked / unpinned: the panel overlays the center and hides as soon as the user activates something outside it (pressing the center, another panel, a toolbar). Its stripe button can still toggle it.
    • Float: a movable window inside the dock area, resizable from its corner grip, independent of side zones.
  • Resize. Drag a docked panel's inner edge; length is clamped so the center always stays visible.
  • Drag to dock. Dragging a header beyond a small threshold detaches the panel, which follows the pointer as a raised window. While any panel is dragged, docked or floating, a hot target square is shown for every side. Moving the pointer onto one grows an animated preview of the zone the panel will take; releasing there docks it on that side and moves its stripe button. Releasing anywhere else floats the panel at the drop position; nothing docks without a hot target.

"Activates something outside" comes from Ui::activeWidget(), the widget last pressed. The active path follows popup owners, so using a panel's options menu counts as activity inside the panel. Every ancestor reports containsActiveWidget() and gets onActiveWithinChanged(); that is how unpinned panels auto-hide without polling.

Popups

One mechanism serves menus, submenus, dropdowns, popup buttons and tooltips:

ui::PopupRequest request;
request.content = &menu;           // any widget; attached while open
request.owner = &button;           // pressing the owner doesn't dismiss; owner removal closes it
request.anchor = button.windowRect();
request.options.placement = ui::Placement::Below;
ui->openPopup(request);

The stack tracks chains: a popup opened from inside another popup is its child. Opening from the main tree replaces unrelated popups. Pressing outside closes popups above the pressed one. Escape closes the topmost. modal blocks input below. interactive = false (tooltips) makes it ignore the pointer.

Input

  • Pointer events go to the deepest visible widget under the pointer (popups first), then bubble to ancestors until one calls accept().
  • A widget that calls ui()->capturePointer(*this) receives moves and the release even outside its bounds. Only the capturing widget can release.
  • Click is synthesized when press and release land on the same widget.
  • Keyboard events go to the focused widget (set when a focusable widget is pressed), else the topmost popup, else the root.
  • Disabled widgets receive nothing; their ancestors still do.
  • PointerDown and Click carry clickCount(): presses of the same button within 400 ms and 4 px count up, so double-click is clickCount() == 2. The clock comes from Ui::advance, which keeps it deterministic in tests.
  • setCursor(shape) gives a widget a pointer cursor; the hovered widget or its nearest ancestor with a cursor wins and is sent to the window host.

Window

The window has no system decorations. Ui::setWindowHost connects the tree to the window (WidgetApplication is the host in apps; tests use FakeWindowHost). Without a host the window controls stay hidden.

  • MenuBar ends with WindowControls: monochrome minimize, maximize/restore and close buttons (close hovers red, the one non-gray color).
  • Pressing empty menu-bar space starts a compositor window move, double-click toggles maximize, and secondary press opens the system window menu.
  • The frame itself (rounded corners, outline, shadow, edge resizing) belongs to Graphics: WindowFrame owns the geometry and edge hit-testing, the Wayland backend reserves a shadow margin, sets the window geometry and input region, and the canvas cuts the corners and paints the shadow last. Maximized and tiled windows drop the margin and corners. Without compositor alpha support the frame falls back to an opaque square window that still resizes from its edges.

Animation

Geometry is animated by default, as in the original library. Every widget has two rects:

  • targetRect(): where layout wants the widget.
  • rect(): where the widget is now. Painting, hit testing, windowRect() and the arrangement of its children all use this one.

When layout assigns a new target, a spring (Motion::fluid(), the same stiffness 0.0041 and damping 0.25 per millisecond as the original SpringRect) moves rect() towards it. Each step re-arranges the widget's children inside the current rect, so a panel that is opening pushes the main view and lays out its own content as it grows; nothing is faked at paint time. Children of a moving widget follow it directly instead of running their own springs, so motion never compounds. The curve has no overshoot: 90% of the travel takes about 135 ms, and it settles to within a quarter pixel in about 300 ms.

Appearing is animated too:

  • Popups grow out of the edge of their anchor.
  • Dock panels slide out of their side.
  • A widget that becomes visible grows from its center.

setGeometryAnimated(false) opts a widget out; scroll view content and dock panels being resized by the pointer use it so they track input exactly. finishGeometryAnimation() jumps to the target.

Math/SpringAnimations.hpp steps springs in substeps of at most 1 ms with an explicit delta, so the curve is the same at any frame rate and tests are deterministic. Controls animate hover, press and toggle visuals with AnimatedFloat / AnimatedColor and the Motion::quick() / Motion::smooth() presets.

Debugging

  • ui.dumpTree() prints every widget's name, rect and state.
  • ui.stats() counts layout passes and measured, arranged, skipped, delivered and painted widgets.
  • WidgetApplication toggles the widget debugger with D. It takes its own column on the right; the Ui viewport shrinks to the area left of it, and pointer input over the panel goes to ImGui only, so the panel never covers or steals input from the UI.
  • setDebugName names widgets in dumps and the inspector.

Testing

tests/support provides everything needed to test any widget tree headlessly:

  • UiDriver owns a Ui and drives it like a user would: moveTo, press, release, click, drag, wheel, key, advance, settle, hoverFor, paint.
  • RecordingPainter records paint commands with their window rects and clips, so tests can assert what text is visible and that clip/translation stacks are balanced.
  • MonospaceTextMetrics makes text measurement deterministic.
  • Box / EventProbe are minimal widgets for exact layout and event checks.

Suites: algorithms (sizing, distribution, snapping, placement, springs), layouts, input routing, popups and tooltips, every widget, menus, window chrome, and the gallery application end to end. Sketch2D/tests reuses the same support code to test the Sketch2D UI with a fake renderer.

What changed from the old library

Old New
Four copies of a rect (mArea spring target/current, mAreaCache, layout area) copied back and forth each frame A target rect from layout and a sprung current rect that children are laid out in
Size policy and min/max stored on the child's own layout object; every leaf allocated a layout AxisSizing on the widget; only containers have a layout
Child picks its own rect from parent, children and itself; dependency graph, topological sort, dead policy table Measure up, arrange down; no graph
UpdateManager mixed hover, event delivery, layout scoping, animation and redraw; process() polled every frame Separate InputRouter, TickScheduler, layout dirty flags, paint request; widgets only run on events, ticks and paint
Popups re-parented under a shared layer plus a focus lock to fake modality PopupStack with owners, chains and dismissal rules
Four friend managers and many dynamic_casts A narrow public API; one internal access class for the engine
Layouts keyed on child count (ToolBarLayout, FixedSizeBarsLayout, ScrollableLayout) Generic Row/Column/Stack/Free/Grid plus typed slots
std::to_string debug reasons built on every setArea No hot-path strings; dumpTree and stats on demand