DVX_GUI/dvx/widgets/widgetLayout.c

433 lines
14 KiB
C

// widgetLayout.c -- Layout engine (measure + arrange)
//
// Implements a two-pass layout algorithm inspired by CSS flexbox:
// Pass 1 (Measure): bottom-up calculation of each widget's minimum
// size. Leaf widgets report their intrinsic size (text width, etc.)
// and containers sum their children's minimums plus padding/spacing.
// Pass 2 (Arrange): top-down assignment of actual positions and sizes.
// The root gets the full available area, then each container divides
// its space among children based on their minimums and weight values.
//
// Why two passes instead of one:
// Containers can't assign sizes until they know all children's minimums
// (to calculate extra space for weight distribution). And children can't
// know their minimums until their subtrees are measured. So measure must
// be bottom-up before arrange can be top-down.
//
// Why flexbox-like rather than constraint-based:
// Flexbox maps naturally to the VBox/HBox container model -- it's simple
// to understand, implement, and debug. Constraint-based layout (like
// Cassowary) would add significant complexity for little benefit in a
// DOS GUI where most layouts are linear stacks of widgets.
//
// Size hints use a tagged integer encoding (see dvxWidget.h):
// High 2 bits encode the unit type (pixels, characters, percent),
// low 30 bits encode the numeric value. This packs three distinct
// unit types into a single int32_t without needing a separate struct.
// The wgtResolveSize() function decodes the tag and converts to pixels.
#include "widgetInternal.h"
// ============================================================
// widgetCalcMinSizeBox
// ============================================================
//
// Measure pass for box containers (VBox, HBox, RadioGroup, StatusBar,
// Toolbar, Frame, TabPage). Recursively measures all visible children,
// then computes this container's minimum size as:
// main axis: sum of children minimums + gaps + padding
// cross axis: max of children minimums + padding
//
// "Main axis" is vertical for VBox, horizontal for HBox. The horiz
// flag flips the calculation so VBox and HBox share the same code.
void widgetCalcMinSizeBox(WidgetT *w, const BitmapFontT *font) {
bool horiz = widgetIsHorizContainer(w->type);
int32_t pad = wgtResolveSize(w->padding, 0, font->charWidth);
int32_t gap = wgtResolveSize(w->spacing, 0, font->charWidth);
int32_t mainSize = 0;
int32_t crossSize = 0;
int32_t count = 0;
if (pad == 0) {
pad = DEFAULT_PADDING;
}
if (gap == 0) {
gap = DEFAULT_SPACING;
}
// Frame reserves extra vertical space at the top so the frame title
// text can sit centered on the top border line (like Windows group boxes).
// Without this, the first child would overlap the title.
int32_t frameExtraTop = 0;
if (w->type == WidgetFrameE) {
frameExtraTop = font->charHeight / 2;
pad = DEFAULT_PADDING;
}
// Toolbar and StatusBar use minimal padding to pack controls tightly,
// matching the compact feel of classic Windows/Motif toolbars.
if (w->type == WidgetToolbarE || w->type == WidgetStatusBarE) {
pad = TOOLBAR_PAD;
gap = TOOLBAR_GAP;
}
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (!c->visible) {
continue;
}
widgetCalcMinSizeTree(c, font);
if (horiz) {
mainSize += c->calcMinW;
crossSize = DVX_MAX(crossSize, c->calcMinH);
} else {
mainSize += c->calcMinH;
crossSize = DVX_MAX(crossSize, c->calcMinW);
}
count++;
}
// Add spacing between children
if (count > 1) {
mainSize += gap * (count - 1);
}
// Add padding
mainSize += pad * 2;
crossSize += pad * 2;
if (horiz) {
w->calcMinW = mainSize;
w->calcMinH = crossSize + frameExtraTop;
} else {
w->calcMinW = crossSize;
w->calcMinH = mainSize + frameExtraTop;
}
// Frame border
if (w->type == WidgetFrameE) {
int32_t fb = widgetFrameBorderWidth(w);
w->calcMinW += fb * 2;
w->calcMinH += fb * 2;
}
}
// ============================================================
// widgetCalcMinSizeTree
// ============================================================
//
// Top-level measure dispatcher. Routes to the appropriate measure
// function based on widget type:
// - Box containers use the generic widgetCalcMinSizeBox()
// - Widgets with custom layout (TabControl, TreeView, ScrollPane,
// Splitter) provide their own calcMinSize via the vtable
// - Widgets without calcMinSize get 0x0 (they rely on size hints)
//
// After the type-specific measure, explicit minW/minH size hints
// are applied as a floor. This allows "this widget should be at
// least N pixels/chars wide" without changing the widget's natural
// size calculation. Hints only increase the minimum, never shrink it.
void widgetCalcMinSizeTree(WidgetT *w, const BitmapFontT *font) {
if (widgetIsBoxContainer(w->type)) {
widgetCalcMinSizeBox(w, font);
} else if (w->wclass && w->wclass->calcMinSize) {
w->wclass->calcMinSize(w, font);
} else {
w->calcMinW = 0;
w->calcMinH = 0;
}
// Size hints act as a floor: if the app specified wgtPixels(200) as
// minW, the widget will be at least 200px wide even if its intrinsic
// content is smaller. This is resolved at measure time (not arrange)
// so parent containers see the correct minimum when distributing space.
if (w->minW) {
int32_t hintW = wgtResolveSize(w->minW, 0, font->charWidth);
if (hintW > w->calcMinW) {
w->calcMinW = hintW;
}
}
if (w->minH) {
int32_t hintH = wgtResolveSize(w->minH, 0, font->charWidth);
if (hintH > w->calcMinH) {
w->calcMinH = hintH;
}
}
}
// ============================================================
// widgetLayoutBox
// ============================================================
//
// Arrange pass for box containers. This is the core of the flexbox-
// like layout algorithm, working in two sub-passes:
//
// Sub-pass 1: Sum all children's minimum sizes and total weight.
// "Extra space" = available main-axis size - total minimum sizes.
//
// Sub-pass 2: Assign each child's position and size.
// - Each child gets at least its minimum size.
// - Extra space is distributed proportionally by weight (like CSS
// flex-grow). A child with weight=100 gets twice as much extra
// as one with weight=50.
// - If total weight is 0 (all children are fixed-size), extra space
// is distributed according to the container's alignment: start
// (default), center, or end.
// - maxW/maxH constraints cap a child's final size.
// - Cross-axis size is the full available cross dimension (minus
// any maxW/maxH constraint on that axis).
//
// After positioning each child, widgetLayoutChildren() recurses to
// lay out the child's own subtree.
void widgetLayoutBox(WidgetT *w, const BitmapFontT *font) {
bool horiz = widgetIsHorizContainer(w->type);
int32_t pad = wgtResolveSize(w->padding, 0, font->charWidth);
int32_t gap = wgtResolveSize(w->spacing, 0, font->charWidth);
if (pad == 0) {
pad = DEFAULT_PADDING;
}
if (gap == 0) {
gap = DEFAULT_SPACING;
}
// Frame adjustments
int32_t frameExtraTop = 0;
int32_t fb = 0;
if (w->type == WidgetFrameE) {
frameExtraTop = font->charHeight / 2;
fb = widgetFrameBorderWidth(w);
pad = DEFAULT_PADDING;
}
// Toolbar and StatusBar use tighter padding
if (w->type == WidgetToolbarE || w->type == WidgetStatusBarE) {
pad = TOOLBAR_PAD;
gap = TOOLBAR_GAP;
}
int32_t innerX = w->x + pad + fb;
int32_t innerY = w->y + pad + fb + frameExtraTop;
int32_t innerW = w->w - pad * 2 - fb * 2;
int32_t innerH = w->h - pad * 2 - fb * 2 - frameExtraTop;
if (innerW < 0) { innerW = 0; }
if (innerH < 0) { innerH = 0; }
int32_t count = widgetCountVisibleChildren(w);
if (count == 0) {
return;
}
int32_t totalGap = gap * (count - 1);
int32_t availMain = horiz ? (innerW - totalGap) : (innerH - totalGap);
int32_t availCross = horiz ? innerH : innerW;
// Sub-pass 1: accumulate children's minimum sizes and weights.
// totalMin is the space needed if every child gets exactly its minimum.
// totalWeight determines how to split any leftover space.
int32_t totalMin = 0;
int32_t totalWeight = 0;
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (!c->visible) {
continue;
}
int32_t cmin = horiz ? c->calcMinW : c->calcMinH;
totalMin += cmin;
totalWeight += c->weight;
}
int32_t extraSpace = availMain - totalMin;
if (extraSpace < 0) {
extraSpace = 0;
}
// Alignment only applies when no children have weight (all fixed-size).
// When weights exist, the extra space is consumed by weighted children
// and alignment has no effect.
int32_t alignOffset = 0;
if (totalWeight == 0 && extraSpace > 0) {
if (w->align == AlignCenterE) {
alignOffset = extraSpace / 2;
} else if (w->align == AlignEndE) {
alignOffset = extraSpace;
}
}
// Second pass: assign positions and sizes
int32_t pos = (horiz ? innerX : innerY) + alignOffset;
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (!c->visible) {
continue;
}
int32_t cmin = horiz ? c->calcMinW : c->calcMinH;
int32_t mainSize = cmin;
// Distribute extra space by weight
if (totalWeight > 0 && c->weight > 0 && extraSpace > 0) {
mainSize += (extraSpace * c->weight) / totalWeight;
}
// Apply max size constraint
if (horiz && c->maxW) {
int32_t maxPx = wgtResolveSize(c->maxW, innerW, font->charWidth);
if (mainSize > maxPx) {
mainSize = maxPx;
}
} else if (!horiz && c->maxH) {
int32_t maxPx = wgtResolveSize(c->maxH, innerH, font->charWidth);
if (mainSize > maxPx) {
mainSize = maxPx;
}
}
// Assign geometry
if (horiz) {
c->x = pos;
c->y = innerY;
c->w = mainSize;
c->h = availCross;
} else {
c->x = innerX;
c->y = pos;
c->w = availCross;
c->h = mainSize;
}
// Apply preferred/max on cross axis
if (horiz && c->maxH) {
int32_t maxPx = wgtResolveSize(c->maxH, innerH, font->charWidth);
if (c->h > maxPx) {
c->h = maxPx;
}
} else if (!horiz && c->maxW) {
int32_t maxPx = wgtResolveSize(c->maxW, innerW, font->charWidth);
if (c->w > maxPx) {
c->w = maxPx;
}
}
pos += mainSize + gap;
// Recurse into child containers
widgetLayoutChildren(c, font);
}
}
// ============================================================
// widgetLayoutChildren
// ============================================================
//
// Top-level layout dispatcher. Mirrors the measure dispatcher
// in widgetCalcMinSizeTree(): box containers use the generic
// algorithm, widgets with custom layout (TabControl, TreeView,
// ScrollPane, Splitter) use their vtable entry, and leaf widgets
// do nothing (they have no children to lay out).
void widgetLayoutChildren(WidgetT *w, const BitmapFontT *font) {
if (widgetIsBoxContainer(w->type)) {
widgetLayoutBox(w, font);
} else if (w->wclass && w->wclass->layout) {
w->wclass->layout(w, font);
}
}
// ============================================================
// wgtLayout
// ============================================================
//
// Public entry point: runs both passes on the entire widget tree.
// The root widget is positioned at (0,0) and given the full available
// area, then the arrange pass distributes space to its children.
//
// This is called from widgetManageScrollbars() and widgetOnPaint(),
// which may pass a virtual content size larger than the physical
// window if scrolling is needed.
void wgtLayout(WidgetT *root, int32_t availW, int32_t availH, const BitmapFontT *font) {
if (!root) {
return;
}
// Measure pass
widgetCalcMinSizeTree(root, font);
// Layout pass
root->x = 0;
root->y = 0;
root->w = availW;
root->h = availH;
widgetLayoutChildren(root, font);
}
// ============================================================
// wgtResolveSize
// ============================================================
//
// Decodes a tagged size value into an actual pixel count.
//
// The tagged integer format uses the high 2 bits as a type tag:
// 00 = pixels (value is used directly)
// 01 = characters (value * charWidth, for text-relative sizing)
// 10 = percent (value% of parentSize, for responsive layouts)
//
// This encoding allows a single int32_t field to represent any of
// three unit types without needing a separate struct or enum.
// The tradeoff is that pixel values are limited to 30 bits (~1 billion),
// which is far more than any supported display resolution.
//
// A value of 0 means "auto" (use intrinsic/calculated size) -- the
// caller checks for 0 before calling this function.
int32_t wgtResolveSize(int32_t taggedSize, int32_t parentSize, int32_t charWidth) {
if (taggedSize == 0) {
return 0;
}
uint32_t sizeType = (uint32_t)taggedSize & WGT_SIZE_TYPE_MASK;
int32_t value = taggedSize & WGT_SIZE_VAL_MASK;
switch (sizeType) {
case WGT_SIZE_PIXELS:
return value;
case WGT_SIZE_CHARS:
return value * charWidth;
case WGT_SIZE_PERCENT:
return (parentSize * value) / 100;
default:
return value;
}
}