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.
14 KiB
Widgets
A small retained-mode UI library: minimal, flexible, headless-testable.
Widgets— the library. Depends onMathonly (plus theInputCodes/Rect/Colorheaders). No window, no GPU.WidgetsApp— the adapter that runs aUiinside aGraphicswindow: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. Clickis 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.
PointerDownandClickcarryclickCount(): presses of the same button within 400 ms and 4 px count up, so double-click isclickCount() == 2. The clock comes fromUi::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.
MenuBarends withWindowControls: 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:WindowFrameowns 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.WidgetApplicationtoggles 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.setDebugNamenames widgets in dumps and the inspector.
Testing
tests/support provides everything needed to test any widget tree headlessly:
UiDriverowns aUiand drives it like a user would:moveTo,press,release,click,drag,wheel,key,advance,settle,hoverFor,paint.RecordingPainterrecords paint commands with their window rects and clips, so tests can assert what text is visible and that clip/translation stacks are balanced.MonospaceTextMetricsmakes text measurement deterministic.Box/EventProbeare 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 |