From efccdbc1752940e636934a38e0292db4d05049e3 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 16:00:58 -0400 Subject: [PATCH 1/6] feat(#203): flow frames that wrap their items onto rows a flow is a frame laid out along x whose items wrap: one that would pass the right edge of a row already holding an item starts the next row a row gap below. a row is as tall as its tallest item and its items size and align across against the height it measured last frame. an item wider than a whole row takes it alone at the row's width. the natural width is the items in one row, so a flow fitted to its content with a largest size wraps there. --- src/layout.mach | 209 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/src/layout.mach b/src/layout.mach index b6acedb..1a903d2 100644 --- a/src/layout.mach +++ b/src/layout.mach @@ -27,6 +27,15 @@ # # a frame also measures what it holds: need_w and need_h are the natural size # of its content, which a stack fitted to its content takes the next frame. +# +# a flow is a frame laid out along x that wraps: an item that would pass the +# right edge of a row already holding one starts the next row, a row gap below +# the bottom of the row before. a row is as tall as its tallest item, and its +# items size and align across against that height, as the flow's owner read it +# for the row from last frame (row_len). an item wider than a whole row takes +# the row alone, at the row's width, and cuts its own content to it. fill and +# frac along x take the rest of the row or a fraction of it. a flow whose width +# is open never wraps, so its natural width is its items in one row. use std.types.bool.bool; use std.types.bool.false; @@ -97,6 +106,25 @@ pub rec Stack { item_h: Size; } +# the options a flow opens with +# --- +# align: where its items sit across their row's height +# gap: pixels between items in a row +# row_gap: pixels between rows +# w: how the flow's own width is sized in its parent, auto for fill +# h: how the flow's own height is sized in its parent, auto for fit +# item_w: the width rule its items take unless they say otherwise, auto for each widget's own +# item_h: the height rule its items take unless they say otherwise, auto for each widget's own +pub rec Flow { + align: Align; + gap: f32; + row_gap: f32; + w: Size; + h: Size; + item_w: Size; + item_h: Size; +} + # one level of layout, in the local pixels of the space it was opened in # --- # axis: the direction items advance in @@ -132,6 +160,14 @@ pub rec Stack { # rule_w: the frame's own width rule in its parent # rule_h: the frame's own height rule in its parent # hidden: 1 when the frame lays out only to measure, drawing and claiming nothing +# flow: 1 when items wrap onto rows (see rows) +# row_gap: pixels between a flow's rows +# row: the flow's current row, from 0 +# row_n: items placed in the current row +# row_len: height the current row measured last frame, 0 when not known +# next_len: height the next row measured last frame, 0 when not known +# done_h: natural height of the row the last wrap closed +# synced: rows whose measures the frame's owner has read, row + 1 once current pub rec Frame { axis: Axis; align: Align; @@ -166,6 +202,14 @@ pub rec Frame { rule_w: Size; rule_h: Size; hidden: u8; + flow: u8; + row_gap: f32; + row: usize; + row_n: usize; + row_len: f32; + next_len: f32; + done_h: f32; + synced: usize; } # a placed item: its rect and what it adds to its frame's measure @@ -179,6 +223,7 @@ pub rec Frame { # weight: fill weight along the frame's axis, 0 when the item does not fill # fit_w: 1 when the width is the natural width because nothing else sized it # fit_h: 1 when the height is the natural height because nothing else sized it +# wrap: 1 when the item starts a flow's next row pub rec Item { x: f32; y: f32; @@ -189,6 +234,7 @@ pub rec Item { weight: f32; fit_w: u8; fit_h: u8; + wrap: u8; } # the rule deferring to the frame and the widget @@ -260,6 +306,42 @@ pub fun column(x: f32, y: f32, w: f32, h: f32, gap: f32) Frame { ret f; } +# a flow's options: start-aligned, gap pixels between items and row_gap +# between rows, filling its parent's width and fitting its content's height +# --- +# gap: pixels between items in a row +# row_gap: pixels between rows +pub fun flow(gap: f32, row_gap: f32) Flow { + var s: Flow; + s.gap = gap; + s.row_gap = row_gap; + ret s; +} + +# a frame flowing items into rows w wide from (x, y), growing down with them +# +# the width is open when w is not positive: the items share one row. +# --- +# x: left in local pixels +# y: top in local pixels +# w: width in pixels, not positive for one row that never wraps +# gap: pixels between items in a row +# row_gap: pixels between rows +pub fun rows(x: f32, y: f32, w: f32, gap: f32, row_gap: f32) Frame { + var f: Frame = column(x, y, w, 0.0::f32, gap); + if (w <= 0.0::f32) { f.open = f.open | OPEN_W; } + f.axis = Axis.horizontal{}; + f.flow = 1; + f.row_gap = row_gap; + f.line_y0 = y; + f.line_y1 = y; + ret f; +} + +# how far past a row's edge an item may reach and still fit, a sixty-fourth of +# a pixel, so float rounding of the pen never wraps an item that fits exactly +val ROW_SLACK: f32 = 0.015625; + fun maxf(a: f32, b: f32) f32 { if (a > b) { ret a; } ret b; @@ -356,6 +438,10 @@ pub fun resolve(f: *Frame, sw: Size, sh: Size, nat_w: f32, nat_h: f32) Item { val wide: bool = (f.open & OPEN_W) == 0; val tall: bool = (f.open & OPEN_H) == 0; + if (f.flow != 0) { + in_row(f, ?it, rw, rh, nat_w, nat_h); + ret it; + } if (across(f)) { it.x = f.x; it.w = along(f, rw, nat_w, f.w, x1 - f.x, wide, ?it.fit_w); @@ -382,11 +468,69 @@ pub fun resolve(f: *Frame, sw: Size, sh: Size, nat_w: f32, nat_h: f32) Item { ret it; } +# the rect of the next item in a flow: at the pen, or at the start of the next +# row when it would pass the edge of a row already holding an item +fun in_row(f: *Frame, it: *Item, rw: Size, rh: Size, nat_w: f32, nat_h: f32) { + val x1: f32 = f.x0 + f.w; + val wide: bool = (f.open & OPEN_W) == 0; + var top: f32 = f.y; + var len: f32 = f.row_len; + it.x = f.x; + it.w = along(f, rw, nat_w, f.w, x1 - f.x, wide, ?it.fit_w); + if (wide && f.row_n > 0 && it.x + it.w > x1 + ROW_SLACK) { + it.wrap = 1; + it.x = f.x0; + top = f.line_y1 + f.row_gap; + len = f.next_len; + it.w = along(f, rw, nat_w, f.w, f.w, wide, ?it.fit_w); + } + if (wide && it.w > f.w) { it.w = f.w; } + it.h = over(f, rh, nat_h, len, ?it.fit_h); + it.y = top + offset(f, len, it.h); +} + +# place an item in a flow's row, starting the next row when it wraps +fun commit_row(f: *Frame, it: Item) { + if (it.wrap != 0) { + f.done_h = f.line_h; + f.need_h = f.need_h + f.row_gap; + f.y = f.line_y1 + f.row_gap; + f.line_y0 = f.y; + f.line_y1 = f.y; + f.line_w = 0.0::f32; + f.line_h = 0.0::f32; + f.row = f.row + 1; + f.row_n = 0; + f.row_len = f.next_len; + f.next_len = 0.0::f32; + } + if (f.bare == 0) { + if (f.items > 0) { f.need_w = f.need_w + f.gap; } + if (f.row_n > 0) { f.line_w = f.line_w + f.gap; } + } + f.bare = 0; + f.need_w = f.need_w + it.nat_w; + f.line_w = f.line_w + it.nat_w; + if (it.nat_h > f.line_h) { + f.need_h = f.need_h + it.nat_h - f.line_h; + f.line_h = it.nat_h; + } + f.line_x1 = it.x + it.w; + f.line_y1 = maxf(f.line_y1, it.y + it.h); + f.x = it.x + it.w + f.gap; + f.row_n = f.row_n + 1; + f.items = f.items + 1; +} + # place an item resolve gave, moving the pen past it and adding it to the measure # --- # f: frame # it: the item pub fun commit(f: *Frame, it: Item) { + if (f.flow != 0) { + commit_row(f, it); + ret; + } val spaced: bool = f.items > 0 && f.bare == 0; f.bare = 0; if (across(f)) { @@ -584,3 +728,68 @@ test layout__same_line_and_skip { if (c.y != 40.0::f32 || c.need_h != 40.0::f32) { ret 5; } ret 0; } + +test layout__flow_wraps_rows_and_gives_a_wide_item_the_row { + # 100 wide, 4 between items, 6 between rows + var f: Frame = rows(0.0::f32, 0.0::f32, 100.0::f32, 4.0::f32, 6.0::f32); + val a: Item = resolve(?f, fit(), fit(), 40.0::f32, 10.0::f32); + commit(?f, a); + # 44 + 56 meets the edge exactly and stays + val b: Item = resolve(?f, fit(), fit(), 56.0::f32, 16.0::f32); + if (b.wrap != 0 || b.x != 44.0::f32 || b.y != 0.0::f32) { ret 1; } + commit(?f, b); + # the next wraps below the row's tallest item + val c: Item = resolve(?f, fit(), fit(), 10.0::f32, 8.0::f32); + if (c.wrap != 1 || c.x != 0.0::f32 || c.y != 22.0::f32) { ret 2; } + commit(?f, c); + if (f.row != 1 || f.done_h != 16.0::f32) { ret 3; } + # wider than a row: it wraps and takes the row, cut to its width + val d: Item = resolve(?f, fit(), fit(), 150.0::f32, 8.0::f32); + if (d.wrap != 1 || d.x != 0.0::f32 || d.y != 36.0::f32 || d.w != 100.0::f32) { ret 4; } + commit(?f, d); + # and the item after it starts the row below + val e: Item = resolve(?f, fill(1.0::f32), fit(), 10.0::f32, 8.0::f32); + if (e.wrap != 1 || e.y != 50.0::f32 || e.w != 100.0::f32) { ret 5; } + commit(?f, e); + + # the natural width is the items in one row, the height the rows stacked + if (f.need_w != 40.0::f32 + 56.0::f32 + 10.0::f32 + 150.0::f32 + 10.0::f32 + 16.0::f32) { ret 6; } + if (f.need_h != 16.0::f32 + 8.0::f32 * 3.0::f32 + 18.0::f32) { ret 7; } + + # a first item wider than the row has nothing to wrap from + var g: Frame = rows(0.0::f32, 0.0::f32, 100.0::f32, 4.0::f32, 6.0::f32); + val h: Item = resolve(?g, fit(), fit(), 150.0::f32, 8.0::f32); + if (h.wrap != 0 || h.w != 100.0::f32) { ret 8; } + + # an open width never wraps or cuts + var o: Frame = rows(0.0::f32, 0.0::f32, 0.0::f32, 4.0::f32, 6.0::f32); + commit(?o, resolve(?o, fit(), fit(), 150.0::f32, 8.0::f32)); + val k: Item = resolve(?o, fit(), fit(), 150.0::f32, 8.0::f32); + if (k.wrap != 0 || k.x != 154.0::f32 || k.w != 150.0::f32) { ret 9; } + ret 0; +} + +test layout__flow_aligns_items_in_their_row { + # each row as tall as last frame measured it + var f: Frame = rows(0.0::f32, 0.0::f32, 100.0::f32, 4.0::f32, 6.0::f32); + f.align = Align.center{}; + f.row_len = 20.0::f32; + f.next_len = 12.0::f32; + val a: Item = resolve(?f, fit(), fit(), 60.0::f32, 8.0::f32); + if (a.y != 6.0::f32) { ret 1; } + commit(?f, a); + commit(?f, resolve(?f, fit(), fit(), 30.0::f32, 20.0::f32)); + # the wrapped item centers in the next row's height + val b: Item = resolve(?f, fit(), fit(), 60.0::f32, 8.0::f32); + if (b.wrap != 1 || b.y != 28.0::f32) { ret 2; } + commit(?f, b); + if (f.row_len != 12.0::f32 || f.next_len != 0.0::f32) { ret 3; } + + # stretch and fill across take the row's height + f.align = Align.stretch{}; + if (resolve(?f, fit(), fit(), 10.0::f32, 8.0::f32).h != 12.0::f32) { ret 4; } + if (resolve(?f, fit(), fill(1.0::f32), 10.0::f32, 8.0::f32).h != 12.0::f32) { ret 5; } + f.align = Align.end{}; + if (resolve(?f, fit(), fit(), 10.0::f32, 8.0::f32).y != 26.0::f32 + 4.0::f32) { ret 6; } + ret 0; +} From d39a99dc49728af9a82bc39b0c91e9d76c055608 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 16:00:58 -0400 Subject: [PATCH 2/6] feat(#203): begin_flow and end_flow open a flow in the layout widget.flow gives the options at the layout style's gaps, margin.r between items and margin.b between rows. a flow is sized in its parent as a stack is and shares its open and close path. each row's natural height is kept in the state store under the flow's id and the row's index, so items align in rows whose height comes from later items. a flow that is not start-aligned, or that its parent aligns by its size, spends its first frame measuring. --- src/widget.mach | 140 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 131 insertions(+), 9 deletions(-) diff --git a/src/widget.mach b/src/widget.mach index d1840d9..663930d 100644 --- a/src/widget.mach +++ b/src/widget.mach @@ -5,7 +5,7 @@ # layout is the context's stack of frames (see blit.layout). every container # pushes the frame its content is laid out in and pops it at its end, and # begin_stack opens a horizontal or vertical stack as an item of the current -# frame. a widget asks the top frame for its rect with its own size rules and +# frame, and begin_flow a flow, whose items wrap onto rows. a widget asks the top frame for its rect with its own size rules and # the size its content needs, which size_next and a stack's item rules # override. same_line puts an item beside the last one. the helpers place rows # by hand: space and advance move the pen, cell_x0 and cell_x1 split the @@ -232,7 +232,9 @@ fun top(ctx: *context.Context) *layout.Frame { if (context.layout_depth(ctx) == 0) { context.push_layout(ctx, layout.column(0.0::f32, 0.0::f32, ctx.sw, ctx.sh, gap(ctx))); } - ret context.top_layout(ctx); + val f: *layout.Frame = context.top_layout(ctx); + if (f.flow != 0 && f.synced != f.row + 1) { sync_rows(ctx, f); } + ret f; } # the default width of a widget that spans the space it is given @@ -370,6 +372,12 @@ fun guessing(f: *layout.Frame, s: layout.Stack, it: layout.Item) bool { over = it.fit_h; } if (main == 0 || over != 0) { ret true; } + ret by_size(f, it); +} + +# whether f aligns an item opening at it by a size the item has not measured: +# centered or end-aligned across f's axis while fitted to its content there +fun by_size(f: *layout.Frame, it: layout.Item) bool { var cross: u8 = it.fit_w; if (layout.across(f)) { cross = it.fit_h; } ret cross != 0 && f.inline == 0 && ((sel f.align.center) || (sel f.align.end)); @@ -413,13 +421,19 @@ pub fun begin_stack(ctx: *context.Context, key: str, s: layout.Stack) { c.item_h = s.item_h; c.prev_fixed = m.fixed; c.prev_weight = m.weight; - c.id = id; - c.slot = it; - c.rule_w = rw; - c.rule_h = rh; - if (m.seen == 0 && guessing(f, s, it)) { c.hidden = 1; } - context.push_layout(ctx, c); - if (c.hidden != 0) { + enter(ctx, ?c, id, it, rw, rh, m.seen == 0 && guessing(f, s, it)); +} + +# push a stack's or a flow's frame c, opened as item it of the current frame +# under the rules rw and rh, laying it out only to measure when hide is set +fun enter(ctx: *context.Context, c: *layout.Frame, id: u64, it: layout.Item, rw: layout.Size, rh: layout.Size, hide: bool) { + c.id = id; + c.slot = it; + c.rule_w = rw; + c.rule_h = rh; + if (hide) { c.hidden = 1; } + context.push_layout(ctx, @c); + if (hide) { context.push_measure(ctx); context.wake_at(ctx, ctx.in.time); } @@ -432,6 +446,12 @@ pub fun begin_stack(ctx: *context.Context, key: str, s: layout.Stack) { # --- # ctx: context pub fun end_stack(ctx: *context.Context) { + leave(ctx); +} + +# pop a stack's or a flow's frame, keep what it measured and place it in its +# parent +fun leave(ctx: *context.Context) { val c: layout.Frame = @context.top_layout(ctx); context.pop_layout(ctx); if (c.hidden != 0) { context.pop_measure(ctx); } @@ -452,6 +472,108 @@ pub fun end_stack(ctx: *context.Context) { layout.commit(top(ctx), it); } +# a flow's options, at the layout style's gaps: margin.r between items in a +# row and margin.b between rows, start-aligned, filling its parent's width +# and fitting its content's height +# --- +# ctx: context +# ret: options to adjust and pass to begin_flow +pub fun flow(ctx: *context.Context) layout.Flow { + ret layout.flow(across(ctx), gap(ctx)); +} + +# the natural height a flow's row measured, kept for the next frame +# --- +# h: the tallest natural height of the row's items +rec Row { + h: f32; +} + +# the id a flow keeps row n's measure under +fun row_id(f: *layout.Frame, n: usize) u64 { + ret bid.child_int(f.id, n::u64); +} + +# the height row n of a flow measured last frame, 0 when it has none +fun row_len(ctx: *context.Context, f: *layout.Frame, n: usize) f32 { + val r: *Row = state.find[Row](ctx, row_id(f, n)); + if (r == nil) { ret 0.0::f32; } + ret r.h; +} + +# keep the height of a flow's row n for the next frame +fun keep_row(ctx: *context.Context, f: *layout.Frame, n: usize, h: f32) { + state.get[Row](ctx, row_id(f, n)).h = h; +} + +# bring a flow's row measures up to its current row: keep the row a wrap +# closed, and read the height the row after this one measured last frame +fun sync_rows(ctx: *context.Context, f: *layout.Frame) { + if (f.id != 0) { + if (f.row > 0) { keep_row(ctx, f, f.row - 1, f.done_h); } + f.next_len = row_len(ctx, f, f.row + 1); + } + f.synced = f.row + 1; +} + +# open a flow as the next item of the current frame +# +# the flow's items go left to right, s.gap apart, and an item that would pass +# the flow's right edge starts a new row, s.row_gap below the last (see +# blit.layout). a row is as tall as its tallest item, and its items sit across +# that height as s.align says, read from what the row measured last frame, +# kept in the state store under the flow's id. an item wider than a whole row +# takes the row alone at the row's width, and a widget cuts its label to it as +# its kind's overflow says, as a control placed by hand does with +# blit.text.fit_box or fit_pair. the flow is sized in its parent as a stack +# is (see begin_stack). a width fitted to the content is its items in one +# row, so a flow sized by layout.limit(layout.fit(), 0, max) wraps at max and +# is as narrow as its items when they fit in less. a flow that is not +# start-aligned, or that its parent aligns by its size, lays out its first +# frame only to measure, as a stack does. flows and stacks nest in each other. +# pair it with end_flow. +# --- +# ctx: context +# key: the flow's key in the current id scope +# s: its options (see flow) +pub fun begin_flow(ctx: *context.Context, key: str, s: layout.Flow) { + val id: u64 = context.id_of(ctx, key); + val m: *Measure = state.get[Measure](ctx, id); + val f: *layout.Frame = top(ctx); + val rw: layout.Size = layout.pick(f.next_w, s.w, layout.pick(f.item_w, wide(), wide())); + val rh: layout.Size = layout.pick(f.next_h, s.h, layout.pick(f.item_h, layout.fit(), layout.fit())); + val it: layout.Item = layout.resolve(f, rw, rh, m.w, m.h); + f.next_w = layout.auto(); + f.next_h = layout.auto(); + + # a width fitted to the content wraps at its largest size, if it has one + var w: f32 = it.w; + if (it.fit_w != 0) { w = rw.max; } + var c: layout.Frame = layout.rows(it.x, it.y, w, s.gap, s.row_gap); + c.h = it.h; + if (it.fit_h == 0) { c.open = c.open & layout.OPEN_W; } + c.align = s.align; + c.item_w = s.item_w; + c.item_h = s.item_h; + c.id = id; + c.row_len = row_len(ctx, ?c, 0); + val hide: bool = m.seen == 0 && (!(sel s.align.start) || by_size(f, it)); + enter(ctx, ?c, id, it, rw, rh, hide); +} + +# close the flow begin_flow opened and place it in its parent +# +# a side fitted to the content takes the size measured this frame: a height +# its rows stacked, a width its items in one row up to the width's largest +# size. +# --- +# ctx: context +pub fun end_flow(ctx: *context.Context) { + val f: *layout.Frame = top(ctx); + if (f.id != 0 && f.items > 0) { keep_row(ctx, f, f.row, f.line_h); } + leave(ctx); +} + # move the pen past an item len pixels long along the current frame's axis, # and the gap after it # From 1723938b1dabb7191813cbd90968e32d10133a5d Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 16:00:58 -0400 Subject: [PATCH 3/6] test(#203): a flow wraps alike in a docked window, a window and an overlay --- src/dock.mach | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/dock.mach b/src/dock.mach index bf938b4..b3d825d 100644 --- a/src/dock.mach +++ b/src/dock.mach @@ -62,6 +62,7 @@ use std.types.option.opt; use std.types.result.res; use std.types.size.usize; use std.types.string.str; +use std.types.string.str_len; use allo: std.allocator; use std.allocator.page; @@ -76,6 +77,7 @@ use bid: blit.id; use blit.input; use blit.interact; use blit.layout; +use blit.overlay; use blit.state; use blit.tabs; use blit.theme; @@ -2341,3 +2343,93 @@ test scroll__wheel_stops_at_the_band_a_popup_opens { if (sel freed.err) { ret 1; } ret 0; } + +# what the flow probe lays out: a docked window, a floating one and an +# overlay, each holding the same flow, and the rects its items took in each +#[testing] +rec Flows { + wins: [2]widget.Window; + r: [3][3]context.Area; +} + +# a label wider than any row of the probe's flows +#[testing] +val LONG: str = "a label far too long to fit in any row of this flow"; + +# a flow at most 100 wide, centered in its rows: two items sharing a row, a +# button wider than a whole row, and an item after it +#[testing] +fun flow_of(ctx: *context.Context, r: *context.Area) { + var s: layout.Flow = widget.flow(ctx); + s.align = layout.Align.center{}; + widget.size_next(ctx, layout.limit(layout.fit(), 0.0::f32, 100.0::f32), layout.auto()); + widget.begin_flow(ctx, "flow", s); + r[0] = widget.place(ctx, layout.fit(), layout.fit(), 60.0::f32, 10.0::f32); + r[1] = widget.place(ctx, layout.fit(), layout.fit(), 30.0::f32, 20.0::f32); + widget.size_next(ctx, layout.fit(), layout.auto()); + widget.button(ctx, LONG); + r[2] = widget.place(ctx, layout.fit(), layout.fit(), 20.0::f32, 10.0::f32); + widget.end_flow(ctx); +} + +#[testing] +fun flows_ui(ctx: *context.Context, user: ptr) { + val fl: *Flows = user::*Flows; + val sid: u64 = context.id_of(ctx, "main"); + if (empty(ctx, sid)) { add(ctx, root(ctx, sid), context.id_of(ctx, "A")); } + space(ctx, "main", context.Area{x0: 0.0::f32, y0: 0.0::f32, x1: 200.0::f32, y1: 300.0::f32}); + var i: usize = 0; + for (i < 2) { + val w: widget.WindowArea = widget.begin_window(ctx, fl.wins[i]); + if (w.body != 0) { flow_of(ctx, ?fl.r[i][0]); } + widget.end_window(ctx, w); + i = i + 1; + } + var o: overlay.Overlay = overlay.on_surface(overlay.BOTTOM_RIGHT); + val sh: overlay.Shown = overlay.begin(ctx, "hud", o); + flow_of(ctx, ?fl.r[2][0]); + overlay.end(ctx, sh); +} + +test flow__wraps_alike_in_a_docked_window_a_window_and_an_overlay { + var al: allo.Allocator; + val made: err[allo.Error] = page.make(?al); + if (sel made.err) { ret 1; } + var fl: Flows; + fl.wins[0] = widget.window("A", 0.0::f32, 0.0::f32, 160.0::f32, 160.0::f32); + fl.wins[1] = widget.window("B", 220.0::f32, 20.0::f32, 160.0::f32, 160.0::f32); + var d: driver.Driver; + val e: err[allo.Error] = driver.init(?d, ?al, 600.0::f32, 400.0::f32, flows_ui, (?fl)::ptr); + if (sel e.err) { ret 1; } + driver.step(?d); + driver.settle(?d); + if (node_of(?d.ctx, win(?d, "A")) == 0 || node_of(?d.ctx, win(?d, "B")) != 0) { ret 2; } + + val g: f32 = context.px(?d.ctx, context.look_of(?d.ctx, theme.LAYOUT).margin.r); + val rg: f32 = context.px(?d.ctx, context.look_of(?d.ctx, theme.LAYOUT).margin.b); + val paths: [3]str = [3]str{"A", "B", "hud"}; + var i: usize = 0; + for (i < 3) { + val r: *context.Area = ?fl.r[i][0]; + val x: f32 = r[0].x0; + val y: f32 = r[1].y0; + # the first row is as tall as its taller item, the shorter centered in it + if (r[0].y0 != y + 5.0::f32 || r[1].x0 != x + 60.0::f32 + g) { ret 3; } + if (r[2].x0 != x) { ret 4; } + # the button took the second row alone, cut to the flow's 100 pixels, + # and the last item the third row + val b: u64 = bid.of_label(driver.find(?d, paths[i]), LONG); + val br: opt[context.Area] = driver.rect(?d, b); + if (sel br.none) { ret 5; } + val bh: f32 = br.some.y1 - br.some.y0; + if (br.some.x1 - br.some.x0 != 100.0::f32) { ret 6; } + if (r[2].y0 != y + 20.0::f32 + rg + bh + rg) { ret 7; } + val shown: str = driver.text_of(?d, b); + if (str_len(shown) == 0 || str_len(shown) >= str_len(LONG)) { ret 8; } + i = i + 1; + } + + val freed: err[allo.Error] = driver.free(?d); + if (sel freed.err) { ret 1; } + ret 0; +} From b616c400307338fe9369d0906c030b31dba1ad58 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 16:00:58 -0400 Subject: [PATCH 4/6] docs(#203): flows in the readme's layout section --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 95a595d..9cc07a7 100644 --- a/README.md +++ b/README.md @@ -923,7 +923,7 @@ works inside surfaces, docks and windows alike. - **A stack of frames.** Layout is a stack of frames on the context (`blit.layout.Frame`): a content box, a pen, the axis items advance along, the gap between them and how they align across it. Every container (panel, - window, popup, dock, scroll region, columns, stack) pushes its frame at its + window, popup, dock, scroll region, columns, stack, flow) pushes its frame at its begin and pops it at its end, so a panel opened inside a window leaves the window's layout where it was. Outside any container, widgets lay out down a column over the screen. @@ -944,6 +944,27 @@ works inside surfaces, docks and windows alike. blit.widget.text_field(?ctx, "find", ?find, "find"); blit.widget.end_stack(?ctx); ``` +- **Flows.** `begin_flow(?ctx, key, s)`/`end_flow` open a flow as the next + item of the current frame: its items go left to right, and one that would + pass the right edge starts the next row. A row is as tall as its tallest + item, and its items sit in that height as `align` says, read from what the + row measured last frame. An item wider than a whole row takes the row alone + at its width and cuts its label to it, as a control placed by hand does + with `blit.text.fit_box` or `fit_pair`. `blit.widget.flow(?ctx)` gives the + options at the layout style's gaps (`margin.r` between items, `margin.b` + between rows): adjust `gap`, `row_gap`, `align`, the flow's own `w` and `h` + and `item_w`/`item_h`, as for a stack. A flow is sized in its parent as a + stack is, and its width fitted to its content is its items in one row, so + `limit(fit(), 0, max)` wraps at `max`. Flows and stacks nest in each other, + and in windows, docked bodies and overlays. + ```mach + blit.widget.size_next(?ctx, blit.layout.limit(blit.layout.fit(), 0.0::f32, max_w), blit.layout.auto()); + blit.widget.begin_flow(?ctx, "readout", blit.widget.flow(?ctx)); + # one item per label and value pair, cut to the row when wider than it + val r: blit.context.Area = blit.widget.place(?ctx, blit.layout.fit(), blit.layout.fit(), pair_w, line_h); + blit.text.fit_pair(?ctx, blit.theme.LABEL, "gen", 3, faint, gen, ink, pair_gap, r.y0, r); + blit.widget.end_flow(?ctx); + ``` - **Sizing.** Each side of an item is `blit.layout.fixed(px)`, `fit()` (what its content needs), `fill(weight)` (a share of the space the other items leave) or `frac(f)` (a fraction of the space left), and `limit(s, min, max)` clamps From 302c70bd7f76a25e916c38321f40e92d92171357 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 16:00:58 -0400 Subject: [PATCH 5/6] feat(#203): a flow entry in the gallery's layout section --- demo/gallery/src/sections/layout.mach | 44 ++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/demo/gallery/src/sections/layout.mach b/demo/gallery/src/sections/layout.mach index 75c6e0b..36e09d9 100644 --- a/demo/gallery/src/sections/layout.mach +++ b/demo/gallery/src/sections/layout.mach @@ -1,13 +1,16 @@ -# layout: stacks with sizing rules, same-line rows, columns and controls -# placed by hand +# layout: stacks with sizing rules, flows, same-line rows, columns and +# controls placed by hand # # layout is a stack of frames. a stack lays its items along an axis, each # side sized fixed, fit to its content, filling a share of what is left or a -# fraction of it. same_line puts the next item beside the last, columns lay -# whole widgets side by side, and place hands back a rect for anything drawn +# fraction of it. a flow lays its items left to right and wraps them onto +# rows. same_line puts the next item beside the last, columns lay whole +# widgets side by side, and place hands back a rect for anything drawn # outside blit's widgets. use std.types.size.usize; +use std.types.string.str; +use std.types.string.str_len; use blit; @@ -60,6 +63,38 @@ pub fun show(ctx: *blit.context.Context, user: ptr) { blit.widget.size_next(ctx, blit.layout.limit(blit.layout.frac(0.5::f32), blit.context.px(ctx, 120.0::f32), 0.0::f32), blit.layout.auto()); blit.widget.button(ctx, "half, at least 120"); + # a flow: tags wrap onto rows as the width runs out + blit.controls.separator_label(ctx, "A flow"); + var tags: blit.layout.Flow = blit.widget.flow(ctx); + tags.item_w = blit.layout.fit(); + blit.widget.begin_flow(ctx, "tags", tags); + val names: [9]str = [9]str{"layout", "stacks", "flows", "rows", "gaps", "alignment", "wrapping", "sizing", "truncation"}; + var k: usize = 0; + for (k < 9) { + blit.widget.button(ctx, names[k]); + k = k + 1; + } + blit.widget.end_flow(ctx); + + # label and value pairs, centered in their rows, each cut to the row + # when it is wider than one + var pairs: blit.layout.Flow = blit.widget.flow(ctx); + pairs.align = blit.layout.Align.center{}; + blit.widget.begin_flow(ctx, "pairs", pairs); + val t: *blit.theme.Theme = blit.context.theme_of(ctx); + val sp: f32 = blit.context.px(ctx, 6.0::f32); + val lh: f32 = blit.context.line_height(ctx); + val keys: [4]str = [4]str{"generation", "population", "rule", "note"}; + val vals: [4]str = [4]str{"1,024", "53,211", "B3/S23", "a value long enough to be cut when the row is narrow"}; + var p: usize = 0; + for (p < 4) { + val w: f32 = blit.context.text_width(ctx, keys[p]) + sp + blit.context.text_width(ctx, vals[p]); + val r: blit.context.Area = blit.widget.place(ctx, blit.layout.fit(), blit.layout.fit(), w, lh); + blit.text.fit_pair(ctx, blit.theme.LABEL, keys[p], str_len(keys[p]), t.text_faint, vals[p], t.text, sp, r.y0, r); + p = p + 1; + } + blit.widget.end_flow(ctx); + blit.controls.separator_label(ctx, "Same line"); blit.widget.text(ctx, "label"); blit.widget.same_line(ctx); @@ -76,7 +111,6 @@ pub fun show(ctx: *blit.context.Context, user: ptr) { # a control drawn by hand takes its place in the layout like a widget blit.controls.separator_label(ctx, "Placed by hand"); - val t: *blit.theme.Theme = blit.context.theme_of(ctx); val h: f32 = blit.context.px(ctx, 24.0::f32); val r: blit.context.Area = blit.widget.place(ctx, blit.layout.fill(1.0::f32), blit.layout.fixed(h), 0.0::f32, h); val w: f32 = (r.x1 - r.x0) / 8.0::f32; From 063d56063abf70e198abd2bb2ae18fd794ae2f88 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 16:01:46 -0400 Subject: [PATCH 6/6] docs(#203): rewrap the widget header's layout paragraph --- src/widget.mach | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/widget.mach b/src/widget.mach index 663930d..2d0d86a 100644 --- a/src/widget.mach +++ b/src/widget.mach @@ -5,9 +5,9 @@ # layout is the context's stack of frames (see blit.layout). every container # pushes the frame its content is laid out in and pops it at its end, and # begin_stack opens a horizontal or vertical stack as an item of the current -# frame, and begin_flow a flow, whose items wrap onto rows. a widget asks the top frame for its rect with its own size rules and -# the size its content needs, which size_next and a stack's item rules -# override. same_line puts an item beside the last one. the helpers place rows +# frame, and begin_flow a flow, whose items wrap onto rows. a widget asks the +# top frame for its rect with its own size rules and the size its content +# needs, which size_next and a stack's or a flow's item rules override. same_line puts an item beside the last one. the helpers place rows # by hand: space and advance move the pen, cell_x0 and cell_x1 split the # column into equal cells for widgets that take a rect (button_at), and # begin_columns lays whole widgets side by side. the gaps and padding come