UI/Layout
Flow-style containers and child properties for arranging elements. Clear rules, practical examples, and accurate behaviour.
What this page covers #
Koya layout is flow- and grid-oriented (not CSS). This page explains exactly how slots are computed, how children are measured, and which properties own placement versus visual transforms.
By task #
- Centre text or content in a fixed slot: see Recipes
- Understand row/column wrapping and growth: see Flow layout
- Place items in explicit grid cells/spans: see Grid layout
- Understand why animation did not move layout slots: see Behaviour notes and gotchas
Overview #
Containers position direct children using row, column, or grid rules. Children contribute margins and optional sizes. Visual transforms (position/scale/rotation/animation) apply after layout and do not change the layout calculation.
Import #
import * as Compositor from 'Koya/Compositor';
import * as UI from 'Helix/UserInterface';Mental model #
- A parent with
layoutpositions its direct children into slots. - Each child can optionally specify a layout size (
item.size) and a margin. If no size is given, the parent measures the child's renderable bounds. - After a slot is assigned, the child's renderable can be aligned inside that slot with
contentAlign. This is separate from the parent's alignment options. - Text has its own glyph alignment (
renderable.justify) which is separate again. - Animations and renderable transforms are visual only and do not change slot sizes or flow decisions.
A quick map of "who controls what":
- Parent container:
layout.justifyContent(main axis),layout.alignItems(cross axis) - Child override:
item.alignSelf(cross axis only) - Inside the child's slot:
contentAlignon the child element - Inside text geometry:
renderable.justifyfor text
Container types #
row: left-to-right flow with optional wrapping into new rows.column: top-to-bottom flow with optional wrapping into new columns.grid: a simple grid by rows or columns, with spans and per-axis alignment.none: not a flow/grid container. Children inherit the container's inner bounds (see Layout type: none).
Container properties #
Set on layout when creating the container (or later via UI.setLayout).
type'row' | 'column' | 'grid' | 'none' - Layout mode for positioning children.wrapboolean optional - Row: wrap to next row; column: wrap to next column.wrapLimitnumber optional - Max items per line/column when wrapping.gapnumber optional - Uniform spacing between items along the main axis; also used as row/column gap fallback in grid.padding{ t, r, b, l } optional - Inner inset.justifyContent'start' | 'center' | 'end' | 'space-between' optional - Distribution along main axis.alignItems'start' | 'center' | 'end' optional - Alignment along cross axis.gridColumnsnumber optional - Grid only. If zero, inferred from average child width.gridRowsnumber optional - Grid only. If zero, inferred to fit all items.columnGapnumber optional - Grid only. Overridegapfor column spacing.rowGapnumber optional - Grid only. Overridegapfor row spacing.gridFlowByRowboolean optional - Grid only.true= fill by rows,false= by columns.gridAlignItems'start' | 'center' | 'end' optional - Grid only. Horizontal alignment inside each grid cell.gridJustifyItems'start' | 'center' | 'end' | 'space-between' optional - Grid only. Vertical distribution inside each grid cell.
The container's available area is its bounds minus padding. Root elements get a canvas-sized bounds before layout.
Cross-axis alignment uses the container's computed size and children's metrics; if you want a larger slot than the content, provide item.size on the child.
Child (item) properties #
Set per child under item when creating the child (or later via setters).
margin{ t?, r?, b?, l? } optional - Child margin.size{ x?: number | 'auto', y?: number | 'auto' } optional - Omit or pass'auto'to use the parent's measurement of the child's renderable bounds for that axis.position{ x, y } optional - Absolute placement when parentlayout.type === 'none'. Places top-left of the child slot at this offset.aabb{ min: {x,y}, max: {x,y} } optional - Absolute rectangle inside the parent's inner area; overridespositionandsize. Only when parent is'none'.ordernumber optional - Lower orders come first; ties are stable.flexGrownumber optional - Share of remaining main-axis space. Only applies when there is positive leftover space.alignSelf'start' | 'center' | 'end' optional - Cross-axis override for this child.gridColumnnumber optional - Grid only. 1-based column placement.gridRownumber optional - Grid only. 1-based row placement.gridColumnSpannumber optional - Grid only. Number of columns to span.gridRowSpannumber optional - Grid only. Number of rows to span.
Size special cases in flow: - Cross-axis: when the measured cross size is zero, the child fills the container's inner cross size (minus margins). Row: height=0 → height becomes inner height. Column: width=0 → width becomes inner width. - Main-axis: 'auto' (or omitted) uses the measured size as the base; if flexGrow > 0, it participates in distributing remaining space. - You can also set size later via UI.setLayoutSize(win, id, { x?, y? }). - flexShrink exists in the API but is not applied in the current layout algorithm.
Sizing and measurement rules #
- If
item.sizeis provided (> 0), that value is used for the child's slot size along that axis. - If not provided or
auto, the parent measures the child's renderable local bounds for that axis. - Flow cross-axis auto-fill: when the measured cross size is zero, the child fills the container's inner cross size (minus margins).
- Flow main-axis growth: remaining main-axis space is shared only among children with
flexGrow > 0. minWidth/minHeight/maxWidth/maxHeightare applied to the measured/preferred size if provided viaitem.- Margins reduce the usable slot area and affect positioning.
Flow layout (row/column) — how it actually works #
- Inner area = container.bounds minus padding.
- Build an ordered child list: sort by
item.order(stable by id for ties). - For each child, determine its base size: use
item.size(w/h > 0) if provided; otherwise measure renderable bounds. Apply min/max constraints if present. - Break into lines (rows for
row, columns forcolumn): start a new line ifwrapis true and the next item would overflow the main limit, orwrapLimitwould be exceeded. - For each line, compute layout: remaining space =
max(0, mainLimit - line.mainSize). ApplyjustifyContent(start,center,end, orspace-between). If remaining > 0 and total grow > 0, distribute viaflexGrow: each item getsshare = (item.grow / totalGrow) * remaining. - Place each item, honoring per-item margins and
alignSelfoverride for cross-axis alignment. The cross-axis usesalignItemsifalignSelfis not set. - Set each child's
boundsto the resolved slot rectangle (this is the areacontentAligncan use inside the child). - Advance to the next line using the line's cross-size plus
gap.
What this means in practice:
flexGrowonly distributes positive leftover space. It does not currently shrink items when overflowed.space-betweendistributes leftover space between items after growth is considered.- To visually centre a small child in a larger region, either give the child a larger slot via
item.sizeand setcontentAlign: { x: 'center', y: 'center' }, or for text setrenderable.justify: 'center'.
Grid layout — how it actually works #
- Inner area = container.bounds minus padding.
- Determine columns/rows: if
gridColumns <= 0, infer a column count using the average preferred width of children. IfgridRows <= 0, infer rows to fit all items. - Compute
cellWandcellHfrom inner area minus gaps, divided by cols/rows (clamped to non-negative). - For each child (in order): choose explicit cell (
gridColumn/gridRowif provided, 1-based) or auto-place using an advancing cursor (by rows ifgridFlowByRow). ApplygridColumnSpan/gridRowSpanto compute the slot rectangle. - Align inside the cell: horizontal via
gridAlignItems, vertical viagridJustifyItems. Margins are applied inside the resulting slot. - Set each child's
boundsaccordingly, then recurse.
Layout type: none #
If layout.type === 'none' (or omitted): the container does not perform flow/grid positioning. Instead, each child's bounds is set to the container's inner area (minus the child's margins). This keeps the child visually "full-bleed" beneath the container unless you:
- Move the child's renderable with transforms, or - Use contentAlign to position the renderable within that full-area slot, or - Give the child its own layout (making it a nested container).
This is intentionally different from CSS absolute positioning. Think of none as "no flow; children inherit the available rectangle."
Content alignment inside a slot #
On each child element:
contentAlign: { x?: 'start'|'center'|'end', y?: 'start'|'center'|'end' } | 'fill'— positions the renderable inside the child's slot (bounds).contentPositioning: 'raw'|'contain'— controls whether content is clamped inside the slot (contain) or allowed to overflow (raw).
'fill' mode: makes the renderable fill the element's OUTER bounds. Padding still applies to children, but is ignored for the renderable. Only renderables that implement inherit-bounds will resize geometry (currently box). Others ignore 'fill'. contentPositioning and {x,y} alignment are ignored when 'fill' is used.
Scripting API #
UI.setLayout(windowId, id, props)UI.setMargin(windowId, id, { t, r, b, l })'auto' switches the axis back to measured/auto semantics.UI.setLayoutSize(windowId, id, { x?, y? })UI.setLayoutOrder(windowId, id, order)flexGrow.UI.setGrow(windowId, id, number)UI.setContentAlign(windowId, id, { x?, y? })UI.setContentPositioning(windowId, id, 'raw' | 'contain')All mutators mark layout as dirty; layout is recomputed before each render.
Recipes #
Centre text inside a fixed-size slot:
{
renderable: { type: 'text', string: 'Hello', size: 20, font: '/rom/fonts/SourceSans3-Regular.ttf' },
item: { size: { x: 200, y: 80 } },
contentAlign: { x: 'center', y: 'center' }
}Right-align items in a row, with even spacing between them:
{
layout: {
type: 'row', gap: 8,
justifyContent: 'space-between',
alignItems: 'center'
}
}Grow a middle spacer between two fixed items:
child: [
{ renderable: { type: 'text', string: 'Left', size: 18, font: '/rom/fonts/SourceSans3-Regular.ttf' } },
{ renderable: { type: 'panel' }, item: { flexGrow: 1 } },
{ renderable: { type: 'text', string: 'Right', size: 18, font: '/rom/fonts/SourceSans3-Regular.ttf' } }
]Grid with two columns and centred items:
{
layout: {
type: 'grid', gridColumns: 2,
columnGap: 8, rowGap: 6,
gridAlignItems: 'center',
gridJustifyItems: 'center'
}
}Working example #
Creates an overlay window with a padded row container that spaces three children: two text labels pinned to each end and a flexible spacer in the middle. This is a common toolbar/status-bar pattern.
import * as Compositor from 'Koya/Compositor';
import * as UI from 'Helix/UserInterface';
const win = await Compositor.createWindow({
role: 'overlay',
anchor: 'top-left',
size: { x: 360, y: 200 },
offset: { x: 100, y: 100 }
});
const bar = await UI.createElement(win, {
renderable: { type: 'panel' },
layout: {
type: 'row', gap: 8,
padding: { t: 6, r: 10, b: 6, l: 10 },
justifyContent: 'space-between',
alignItems: 'center'
},
child: [
{ renderable: { type: 'text', string: 'Left', size: 18, font: '/rom/fonts/SourceSans3-Regular.ttf' } },
{ renderable: { type: 'panel' }, item: { flexGrow: 1 } },
{ renderable: { type: 'text', string: 'Right', size: 18, font: '/rom/fonts/SourceSans3-Regular.ttf' } }
]
});
await UI.attachRoot(win, bar);Behaviour notes and gotchas #
- Layout is independent from visual transforms and animations.
- No margin collapsing.
flexGrowdistributes only positive leftover space. It does not currently shrink items when overflowed.space-betweendistributes leftover space after computing base line size; items withflexGrowcan consume some/all of that space first.- Order is stable: equal
ordervalues keep source order.
Final tips #
- If the result is not what you expect, inspect which layer you intend to control: container alignment, child override, slot alignment, or text glyph alignment.
- Prefer explicit
item.sizewhen you want reliable centring or spacing independent of content measurement. - For text, always set
font(e.g./rom/fonts/SourceSans3-Regular.ttf) and asizefor predictable results.
For overflow content, enable native scrolling with scroll: true or axis/options on the viewport. Native scrolling clips children and translates their layout bounds, so hit testing follows their visible positions. Scroll limits use immediate child bounds, margins, and padding; give a wrapper an explicit overflow size. See Native scrolling for raw APIs or ScrollView for the standard viewport/content control.