From 0fee32375453059c60274edc3df0284b66133d75 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 21:22:27 +0000 Subject: [PATCH] Layout: justify, so leftover space has a policy (#58) Slack in a container had exactly one destination: flexible children absorbed it, and anything else was a spacer inserted by hand. That is layout state encoded as a sibling, and it does not survive somebody reordering the children. `justify` says where it goes: start, end, center, space-between, space-around, space-evenly. It defaults to start, which is what every layout did before this existed, and it only ever applies when there IS slack -- a container holding any fr or fill child has none, because that child already absorbed it. So it is inert exactly where it would otherwise fight with the constraints. Two decisions worth stating: - center floors, so an odd cell falls after the content rather than before it. That is what CSS and ratatui do, and what reads as centred. - Rounding each gap on its own loses a cell here and gains one there. Rounding the cumulative offset and taking differences means the parts add up to exactly the slack, in every mode and however awkward the division. A property test covers slack 0..17 across 1..6 children in all six modes. All six ports, cross-checked rather than assumed: 390 distribution cases in TypeScript, Rust, Go, Python, C and Zig, diffed against the reference and byte-for-byte identical. Every existing frame is unchanged. Closes #58 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- packages/hqtui/src/index.ts | 3 +- packages/hqtui/src/layout.ts | 78 ++++++++++++++++- packages/hqtui/src/ui.ts | 11 ++- packages/hqtui/test/justify.test.ts | 111 ++++++++++++++++++++++++ ports/c/include/hqtui.h | 19 ++++ ports/c/src/layout.c | 70 ++++++++++++++- ports/cpp/include/hqtui/widgets.hpp | 11 ++- ports/go/layout.go | 111 ++++++++++++++++++++++++ ports/python/hqtui/layout.py | 63 +++++++++++++- ports/rust/examples/justify_parity.rs | 16 ++++ ports/rust/src/layout.rs | 112 +++++++++++++++++++++++- ports/zig/build.zig | 2 +- ports/zig/examples/justify_parity.zig | 43 +++++++++ ports/zig/src/layout.zig | 120 +++++++++++++++++++++++++- 14 files changed, 749 insertions(+), 21 deletions(-) create mode 100644 packages/hqtui/test/justify.test.ts create mode 100644 ports/rust/examples/justify_parity.rs create mode 100644 ports/zig/examples/justify_parity.zig diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts index 3ca1c9b..17cf2d3 100644 --- a/packages/hqtui/src/index.ts +++ b/packages/hqtui/src/index.ts @@ -44,7 +44,8 @@ export { // Layout export { solve, stack, inset, intersect, contains, isEmpty, fixed, percent, flex, auto, fill, - remaining, minmax, normalizePadding, type Rect, type Size, type Constraint, type Padding, + remaining, minmax, normalizePadding, distribute, + type Rect, type Size, type Constraint, type Padding, type Justify, } from "./layout.ts"; // Input diff --git a/packages/hqtui/src/layout.ts b/packages/hqtui/src/layout.ts index 19608d7..32c7f3c 100644 --- a/packages/hqtui/src/layout.ts +++ b/packages/hqtui/src/layout.ts @@ -155,25 +155,97 @@ export function solve(total: number, items: Constraint[], gap: number | number[] return out; } +/** + * Where leftover space goes. + * + * It only ever applies when there is slack, and a container holding any `fr` + * or `fill` child has none -- that child has already absorbed it. So this is + * inert exactly where it would otherwise fight with the constraints. + */ +export type Justify = + | "start" + | "end" + | "center" + | "space-between" + | "space-around" + | "space-evenly"; + +/** + * How much slack sits before item `i`, as an exact fraction. + * + * Every mode is a different answer to that one question, which is why they can + * share the rounding below rather than each doing their own and each getting a + * different kind of off-by-one. + */ +function before(i: number, slack: number, count: number, justify: Justify): number { + switch (justify) { + case "end": + return slack; + case "center": + // Floor, so an odd cell falls after the content rather than before it. + // That is what CSS and ratatui do, and it is what reads as centred. + return Math.floor(slack / 2); + case "space-between": + // One child has nothing to sit between, so it stays where it started. + return count > 1 ? (i * slack) / (count - 1) : 0; + case "space-evenly": + return ((i + 1) * slack) / (count + 1); + case "space-around": + // Half a share at each end, a whole one between. + return ((i + 0.5) * slack) / count; + default: + return 0; + } +} + +/** + * The offset before the first child, and the extra added at each seam. + * + * Cells are whole, and rounding each gap on its own loses a cell here and + * gains one there. Rounding the *cumulative* offset instead and taking + * differences means the parts always add up to exactly the slack, whatever the + * mode and however awkward the division. + */ +export function distribute( + slack: number, + count: number, + justify: Justify, +): { lead: number; seams: number[] } { + const seams = new Array(Math.max(0, count - 1)).fill(0); + if (slack <= 0 || count === 0 || justify === "start") return { lead: 0, seams }; + + const at = (i: number): number => Math.round(before(i, slack, count, justify)); + for (let i = 0; i + 1 < count; i++) seams[i] = at(i + 1) - at(i); + return { lead: at(0), seams }; +} + /** Lay children out along one axis inside `rect`. */ export function stack( rect: Rect, items: Constraint[], direction: "row" | "column", gap: number | number[] = 0, + justify: Justify = "start", ): Rect[] { const horizontal = direction === "row"; - const sizes = solve(horizontal ? rect.width : rect.height, items, gap); + const axis = horizontal ? rect.width : rect.height; + const sizes = solve(axis, items, gap); const seam = (i: number) => (Array.isArray(gap) ? (gap[i] ?? 0) : gap); + + let gapTotal = 0; + for (let i = 0; i + 1 < sizes.length; i++) gapTotal += seam(i); + const slack = Math.max(0, axis - sizes.reduce((a, b) => a + b, 0) - gapTotal); + const { lead, seams } = distribute(slack, sizes.length, justify); + const out: Rect[] = []; - let offset = horizontal ? rect.x : rect.y; + let offset = (horizontal ? rect.x : rect.y) + lead; sizes.forEach((size, i) => { out.push( horizontal ? { x: offset, y: rect.y, width: size, height: rect.height } : { x: rect.x, y: offset, width: rect.width, height: size }, ); - offset += size + seam(i); + offset += size + seam(i) + (seams[i] ?? 0); }); return out; } diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index ccf31a8..b399607 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -4,7 +4,7 @@ import type { Color } from "./color.ts"; import type { Theme } from "./theme.ts"; import type { Capabilities } from "./capabilities.ts"; import { - type Constraint, type Rect, type Padding, type Size, inset, stack, solve, isEmpty, + type Constraint, type Justify, type Rect, type Padding, type Size, inset, stack, solve, isEmpty, } from "./layout.ts"; import { stringWidth, wrap } from "./unicode.ts"; import { isRich, toSpanLines, wrapRich, type RichText } from "./richtext.ts"; @@ -69,6 +69,12 @@ interface Child { export interface ContainerOptions { gap?: number; + /** + * Where space left over by the children goes. Defaults to "start", which is + * what every layout did before this existed, and is inert unless the + * children actually leave slack. + */ + justify?: Justify; padding?: Padding; /** Size along the parent's main axis. */ size?: Size; @@ -114,6 +120,7 @@ export class Container { readonly direction: "row" | "column"; private children: Child[] = []; private gap: number; + private justify: Justify; private inner: Surface; constructor( @@ -126,6 +133,7 @@ export class Container { this.ctx = ctx; this.direction = direction; this.gap = options.gap ?? 0; + this.justify = options.justify ?? "start"; this.inner = options.padding ? surface.inset(options.padding) : surface; if (options.background !== undefined) surface.fill({ bg: options.background }); } @@ -204,6 +212,7 @@ export class Container { this.children.map((c) => c.constraint), this.direction, this.seams(), + this.justify, ); this.children.forEach((child, i) => { const rect = rects[i]; diff --git a/packages/hqtui/test/justify.test.ts b/packages/hqtui/test/justify.test.ts new file mode 100644 index 0000000..84c95a0 --- /dev/null +++ b/packages/hqtui/test/justify.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { distribute, stack, type Justify } from "../src/index.ts"; + +const rect = (width: number) => ({ x: 0, y: 0, width, height: 1 }); +const xs = (widths: number[], justify: Justify, total = 20, gap = 0) => + stack(rect(total), widths.map((size) => ({ size })), "row", gap, justify).map((r) => r.x); + +/** Every mode has to place exactly the slack it was given: no cell invented, none lost. */ +function placesExactly(slack: number, count: number, justify: Justify): boolean { + const { lead, seams } = distribute(slack, count, justify); + const placed = lead + seams.reduce((a, b) => a + b, 0); + return placed >= 0 && placed <= slack && lead >= 0 && seams.every((s) => s >= 0); +} + +describe("justify", () => { + test("start is what every layout did before, so nothing moves", () => { + expect(xs([4, 4, 4], "start")).toEqual([0, 4, 8]); + expect(distribute(8, 3, "start")).toEqual({ lead: 0, seams: [0, 0] }); + }); + + test("end pushes everything against the far edge", () => { + // 20 wide, 12 used, 8 spare: the first child starts at 8. + expect(xs([4, 4, 4], "end")).toEqual([8, 12, 16]); + }); + + test("center splits the slack, and the odd cell falls after the content", () => { + expect(xs([4, 4, 4], "center")).toEqual([4, 8, 12]); + // 20 - 9 = 11 spare, so 5 before and 6 after: the odd cell falls after the + // content, which is what reads as centred. + expect(xs([3, 3, 3], "center")).toEqual([5, 8, 11]); + }); + + test("space-between puts it all between, never at the ends", () => { + const at = xs([4, 4, 4], "space-between"); + expect(at[0]).toBe(0); + // 8 spare across 2 seams. + expect(at).toEqual([0, 8, 16]); + // The last child ends exactly on the far edge. + expect((at[2] as number) + 4).toBe(20); + }); + + test("space-between with one child leaves it where it started", () => { + // There is nothing to sit between. + expect(xs([4], "space-between")).toEqual([0]); + expect(distribute(16, 1, "space-between")).toEqual({ lead: 0, seams: [] }); + }); + + test("space-evenly makes every gap the same, ends included", () => { + // 20 - 12 = 8 across 4 gaps: 2 each. + expect(xs([4, 4, 4], "space-evenly")).toEqual([2, 8, 14]); + }); + + test("space-around gives each child equal room, so the ends are half gaps", () => { + // 9 spare over 3 children is 3 each: 1.5 at the ends, 3 between. + const at = xs([4, 4, 3], "space-around", 20); + expect(at[0]).toBe(2); + expect((at[1] as number) - ((at[0] as number) + 4)).toBe(3); + }); + + test("an existing gap is kept and the slack is added to it", () => { + // 12 of content and 2 of gap leaves 6, so each seam becomes 1 + 3 and the + // last child still ends exactly on the far edge. + expect(xs([4, 4, 4], "space-between", 20, 1)).toEqual([0, 8, 16]); + expect(xs([4, 4, 4], "start", 20, 1)).toEqual([0, 5, 10]); + }); + + test("no slack means every mode agrees with start", () => { + for (const justify of ["end", "center", "space-between", "space-around", "space-evenly"] as const) { + // Exactly full: there is nothing to distribute. + expect(xs([5, 5, 5, 5], justify)).toEqual([0, 5, 10, 15]); + } + }); + + test("a flexible child leaves no slack, so justify is inert beside it", () => { + const at = stack(rect(20), [{ size: 4 }, { size: "fill" }], "row", 0, "center").map((r) => r.x); + // "fill" already absorbed everything; centring has nothing left to move. + expect(at).toEqual([0, 4]); + }); + + test("nothing is invented or lost, for any slack, count or mode", () => { + const modes: Justify[] = ["start", "end", "center", "space-between", "space-around", "space-evenly"]; + for (const justify of modes) { + for (let count = 1; count <= 6; count++) { + for (let slack = 0; slack <= 17; slack++) { + expect(placesExactly(slack, count, justify)).toBe(true); + } + } + } + }); + + test("children never overlap and never leave the rect", () => { + const modes: Justify[] = ["start", "end", "center", "space-between", "space-around", "space-evenly"]; + for (const justify of modes) { + for (let total = 6; total <= 24; total++) { + const rects = stack(rect(total), [{ size: 3 }, { size: 4 }, { size: 2 }], "row", 1, justify); + let edge = 0; + for (const r of rects) { + expect(r.x).toBeGreaterThanOrEqual(edge); + edge = r.x + r.width; + } + expect(edge).toBeLessThanOrEqual(total); + } + } + }); + + test("columns justify down the same way rows justify across", () => { + const ys = stack({ x: 0, y: 0, width: 10, height: 20 }, [{ size: 4 }, { size: 4 }], "column", 0, "end") + .map((r) => r.y); + expect(ys).toEqual([12, 16]); + }); +}); diff --git a/ports/c/include/hqtui.h b/ports/c/include/hqtui.h index 7c6429b..d565b17 100644 --- a/ports/c/include/hqtui.h +++ b/ports/c/include/hqtui.h @@ -82,6 +82,25 @@ int hq_solve_gaps(int total, const hq_constraint *items, size_t count, int hq_stack_gaps(hq_rect rect, const hq_constraint *items, size_t count, int horizontal, const int *gaps, hq_rect *out); +/* Where space the children leave over goes. It only applies when there is + * slack: a container holding any fr or fill child has none, because that child + * has already absorbed it. HQ_JUSTIFY_START is what every layout did before + * this existed. */ +typedef enum { + HQ_JUSTIFY_START = 0, + HQ_JUSTIFY_END, + HQ_JUSTIFY_CENTER, + HQ_JUSTIFY_SPACE_BETWEEN, + HQ_JUSTIFY_SPACE_AROUND, + HQ_JUSTIFY_SPACE_EVENLY +} hq_justify; + +/* The offset before the first child, and the extra added at each of the + * count-1 seams. `seams` may be NULL when count < 2. Returns 0 on bad input. */ +int hq_distribute(int slack, size_t count, hq_justify justify, int *lead, int *seams); +int hq_stack_justified(hq_rect rect, const hq_constraint *items, size_t count, + int horizontal, const int *gaps, hq_justify justify, hq_rect *out); + /* colors: 0 (no color), 16, 256 or 16777216 (truecolor). * encode reuses output capacity; allocation failure invalidates terminal state. * It never writes to stdout. An unchanged frame produces zero bytes. */ diff --git a/ports/c/src/layout.c b/ports/c/src/layout.c index 481034b..df8bb00 100644 --- a/ports/c/src/layout.c +++ b/ports/c/src/layout.c @@ -84,18 +84,80 @@ int hq_stack(hq_rect r,const hq_constraint *items,size_t n,int horizontal,int ga } int hq_stack_gaps(hq_rect r,const hq_constraint *items,size_t n,int horizontal,const int *gaps,hq_rect *out) { + return hq_stack_justified(r,items,n,horizontal,gaps,HQ_JUSTIFY_START,out); +} + +/* How much slack sits before item i, as an exact fraction. Every mode is a + * different answer to that one question, which is why they share the rounding + * below rather than each growing their own off-by-one. */ +static double hq_before(size_t i,double slack,size_t n,hq_justify j) { + double fi=(double)i, fn=(double)n; + switch(j) { + case HQ_JUSTIFY_END: return slack; + /* Floor, so an odd cell falls after the content rather than before. */ + case HQ_JUSTIFY_CENTER: return floor(slack/2.0); + case HQ_JUSTIFY_SPACE_BETWEEN: return n>1 ? fi*slack/(fn-1.0):0.0; + case HQ_JUSTIFY_SPACE_EVENLY: return (fi+1.0)*slack/(fn+1.0); + case HQ_JUSTIFY_SPACE_AROUND: return (fi+0.5)*slack/fn; + default: return 0.0; + } +} + +/* Cells are whole, and rounding each gap on its own loses one here and gains + * one there. Rounding the cumulative offset and taking differences means the + * parts always add up to exactly the slack. */ +static int hq_at(size_t i,int slack,size_t n,hq_justify j) { + return (int)floor(hq_before(i,(double)slack,n,j)+0.5); +} + +int hq_distribute(int slack,size_t count,hq_justify justify,int *lead,int *seams) { + if(!lead || count>4096 || (count>1 && !seams)) return 0; + for(size_t i=0;i+10 ? d:0; + } + *lead=hq_at(0,slack,count,justify); + return 1; +} + +int hq_stack_justified(hq_rect r,const hq_constraint *items,size_t n,int horizontal,const int *gaps,hq_justify justify,hq_rect *out) { if(n>4096 || (n && (!out || !items)) || (n>1 && !gaps) || r.width<0 || r.height<0) return 0; if(!n) return 1; int local[64]; int *sizes=n<=64 ? local:malloc(n*sizeof(*sizes)); if(!sizes) return 0; - if(!hq_solve_gaps(horizontal ? r.width:r.height,items,n,gaps,sizes)) { if(sizes!=local) free(sizes); return 0; } - int64_t offset=horizontal ? r.x:r.y; + int axis=horizontal ? r.width:r.height; + if(!hq_solve_gaps(axis,items,n,gaps,sizes)) { if(sizes!=local) free(sizes); return 0; } + + int64_t used=0; + for(size_t i=0;i0 ? (int)spare:0; + + int seam_local[64]; + int *extra=n-1<=64 ? seam_local:malloc((n?n-1:1)*sizeof(*extra)); + if(!extra) { if(sizes!=local) free(sizes); return 0; } + int lead=0; + if(!hq_distribute(slack,n,justify,&lead,extra)) { + if(sizes!=local) free(sizes); + if(extra!=seam_local) free(extra); + return 0; + } + + int64_t offset=(horizontal ? r.x:r.y)+lead; for(size_t i=0;iINT_MAX) { if(sizes!=local) free(sizes); return 0; } + if(offsetINT_MAX) { + if(sizes!=local) free(sizes); + if(extra!=seam_local) free(extra); + return 0; + } out[i]=horizontal ? (hq_rect){(int)offset,r.y,sizes[i],r.height} : (hq_rect){r.x,(int)offset,r.width,sizes[i]}; - offset+=(int64_t)sizes[i]+(i+1 nodes_; bool collapse_ = false; + hq_justify justify_ = HQ_JUSTIFY_START; public: std::vector *regions; @@ -574,6 +575,11 @@ class UI { : surface_(s), horizontal_(horizontal), gap_(gap), collapse_(collapse), regions(regions) {} + /// Where space the children leave over goes. Only ever applies when there is + /// slack: a container holding any fr or fill child has none. + void justify(hq_justify value) { justify_ = value; } + hq_justify justify() const { return justify_; } + /// Merge the borders of adjacent panels into shared lines, the way CSS /// collapses table borders. Inherited by nested containers. void collapse_borders(bool value) { collapse_ = value; } @@ -604,8 +610,9 @@ class UI { for (std::size_t i = 0; i + 1 < nodes_.size(); i++) if (nodes_[i].bordered && nodes_[i + 1].bordered) seams[i] = -1; - if (!hq_stack_gaps(surface_.rect(), sizes.data(), sizes.size(), horizontal_, - seams.empty() ? nullptr : seams.data(), rects.data())) + if (!hq_stack_justified(surface_.rect(), sizes.data(), sizes.size(), + horizontal_, seams.empty() ? nullptr : seams.data(), + justify_, rects.data())) throw std::runtime_error("hqtui: invalid layout"); for (std::size_t i = 0; i < nodes_.size(); i++) if (rects[i].width > 0 && rects[i].height > 0) diff --git a/ports/go/layout.go b/ports/go/layout.go index 7e6ef6e..3a32fc7 100644 --- a/ports/go/layout.go +++ b/ports/go/layout.go @@ -330,6 +330,84 @@ func remove(s []int, v int) []int { return s } +// Justify says where leftover space goes. +// +// It only ever applies when there is slack, and a container holding any fr or +// fill child has none -- that child has already absorbed it. So this is inert +// exactly where it would otherwise fight with the constraints. +type Justify int + +const ( + JustifyStart Justify = iota + JustifyEnd + JustifyCenter + JustifySpaceBetween + JustifySpaceAround + JustifySpaceEvenly +) + +// ParseJustify reads the spelling the reference API uses. Anything else is +// start, which is what every layout did before this existed. +func ParseJustify(name string) Justify { + switch name { + case "end": + return JustifyEnd + case "center": + return JustifyCenter + case "space-between": + return JustifySpaceBetween + case "space-around": + return JustifySpaceAround + case "space-evenly": + return JustifySpaceEvenly + } + return JustifyStart +} + +// before is how much slack sits ahead of item i, as an exact fraction. Every +// mode is a different answer to that one question, which is why they share the +// rounding below rather than each growing their own off-by-one. +func before(i int, slack float64, count int, justify Justify) float64 { + fi, fc := float64(i), float64(count) + switch justify { + case JustifyEnd: + return slack + case JustifyCenter: + // Floor, so an odd cell falls after the content rather than before it. + return math.Floor(slack / 2) + case JustifySpaceBetween: + if count > 1 { + return fi * slack / (fc - 1) + } + return 0 + case JustifySpaceEvenly: + return (fi + 1) * slack / (fc + 1) + case JustifySpaceAround: + return (fi + 0.5) * slack / fc + } + return 0 +} + +// Distribute returns the offset before the first child and the extra added at +// each seam. +// +// Cells are whole, and rounding each gap on its own loses one here and gains +// one there. Rounding the cumulative offset and taking differences means the +// parts always add up to exactly the slack. +func Distribute(slack, count int, justify Justify) (int, []int) { + seams := make([]int, max(0, count-1)) + if slack <= 0 || count == 0 || justify == JustifyStart { + return 0, seams + } + at := func(i int) int { + return int(math.Round(before(i, float64(slack), count, justify))) + } + for i := 0; i+1 < count; i++ { + seams[i] = max(0, at(i+1)-at(i)) + } + return at(0), seams +} + // Stack lays children out along one axis inside rect. func Stack(rect Rect, items []Constraint, direction Direction, gap int) []Rect { seams := make([]int, max(0, len(items)-1)) @@ -339,19 +417,49 @@ func Stack(rect Rect, items []Constraint, direction Direction, gap int) []Rect { return StackWithGaps(rect, items, direction, seams) } +// StackJustified is Stack with a policy for whatever the children leave over. +func StackJustified(rect Rect, items []Constraint, direction Direction, gap int, justify Justify) []Rect { + seams := make([]int, max(0, len(items)-1)) + for i := range seams { + seams[i] = gap + } + return StackWithGapsJustified(rect, items, direction, seams, justify) +} + // StackWithGaps is Stack with a gap per seam, which may be negative. func StackWithGaps(rect Rect, items []Constraint, direction Direction, gaps []int) []Rect { + return StackWithGapsJustified(rect, items, direction, gaps, JustifyStart) +} + +// StackWithGapsJustified is the full form: a gap per seam, and a policy for +// whatever is left over. +func StackWithGapsJustified(rect Rect, items []Constraint, direction Direction, gaps []int, justify Justify) []Rect { horizontal := direction == DirRow total := rect.Height if horizontal { total = rect.Width } sizes := SolveWithGaps(total, items, gaps) + + gapTotal := 0 + for i := 0; i+1 < len(sizes); i++ { + if i < len(gaps) { + gapTotal += gaps[i] + } + } + used := gapTotal + for _, size := range sizes { + used += size + } + slack := max(0, total-used) + lead, extra := Distribute(slack, len(sizes), justify) + out := make([]Rect, 0, len(sizes)) offset := rect.Y if horizontal { offset = rect.X } + offset += lead for i, size := range sizes { if horizontal { out = append(out, Rect{X: offset, Y: rect.Y, Width: size, Height: rect.Height}) @@ -362,6 +470,9 @@ func StackWithGaps(rect Rect, items []Constraint, direction Direction, gaps []in if i < len(gaps) { seam = gaps[i] } + if i < len(extra) { + seam += extra[i] + } offset += size + seam } return out diff --git a/ports/python/hqtui/layout.py b/ports/python/hqtui/layout.py index 6515e7f..35d36af 100644 --- a/ports/python/hqtui/layout.py +++ b/ports/python/hqtui/layout.py @@ -240,22 +240,79 @@ def solve( return [max(0, v) for v in out] +#: Where leftover space goes. +#: +#: It only ever applies when there is slack, and a container holding any ``fr`` +#: or ``fill`` child has none -- that child has already absorbed it. So this is +#: inert exactly where it would otherwise fight with the constraints. +JUSTIFY = ("start", "end", "center", "space-between", "space-around", "space-evenly") + + +def _before(i: int, slack: float, count: int, justify: str) -> float: + """How much slack sits before item ``i``, as an exact fraction. + + Every mode is a different answer to that one question, which is why they + share the rounding below rather than each growing their own off-by-one. + """ + if justify == "end": + return slack + if justify == "center": + # Floor, so an odd cell falls after the content rather than before it. + return float(int(slack // 2)) + if justify == "space-between": + return (i * slack / (count - 1)) if count > 1 else 0.0 + if justify == "space-evenly": + return (i + 1) * slack / (count + 1) + if justify == "space-around": + return (i + 0.5) * slack / count + return 0.0 + + +def _round_half_up(value: float) -> int: + """Python rounds halves to even; every other port rounds them up.""" + return int(math.floor(value + 0.5)) + + +def distribute(slack: int, count: int, justify: str = "start") -> tuple[int, list[int]]: + """The offset before the first child, and the extra added at each seam. + + Cells are whole, and rounding each gap on its own loses one here and gains + one there. Rounding the cumulative offset and taking differences means the + parts always add up to exactly the slack. + """ + seams = [0] * max(0, count - 1) + if slack <= 0 or count == 0 or justify == "start": + return 0, seams + at = lambda i: _round_half_up(_before(i, float(slack), count, justify)) # noqa: E731 + for i in range(max(0, count - 1)): + seams[i] = max(0, at(i + 1) - at(i)) + return at(0), seams + + def stack( rect: Rect, items: Sequence[Constraint], direction: "Direction | str" = Direction.COLUMN, gap: "int | Sequence[int]" = 0, + justify: str = "start", ) -> list[Rect]: """Lay children out along one axis inside ``rect``.""" horizontal = direction == Direction.ROW - sizes = solve(rect.width if horizontal else rect.height, items, gap) + axis = rect.width if horizontal else rect.height + sizes = solve(axis, items, gap) gaps = list(gap) if isinstance(gap, (list, tuple)) else [gap] * max(0, len(sizes) - 1) + + gap_total = sum(gaps[i] for i in range(max(0, len(sizes) - 1)) if i < len(gaps)) + slack = max(0, axis - sum(sizes) - gap_total) + lead, extra = distribute(slack, len(sizes), justify) + out: list[Rect] = [] - offset = rect.x if horizontal else rect.y + offset = (rect.x if horizontal else rect.y) + lead for i, size in enumerate(sizes): if horizontal: out.append(Rect(offset, rect.y, size, rect.height)) else: out.append(Rect(rect.x, offset, rect.width, size)) - offset += size + (gaps[i] if i < len(gaps) else 0) + seam = (gaps[i] if i < len(gaps) else 0) + (extra[i] if i < len(extra) else 0) + offset += size + seam return out diff --git a/ports/rust/examples/justify_parity.rs b/ports/rust/examples/justify_parity.rs new file mode 100644 index 0000000..202c02b --- /dev/null +++ b/ports/rust/examples/justify_parity.rs @@ -0,0 +1,16 @@ +use hqtui::layout::{distribute, Justify}; + +fn main() { + let modes = [ + "start", "end", "center", "space-between", "space-around", "space-evenly", + ]; + for m in modes { + for count in 1..=5usize { + for slack in 0..=12usize { + let (lead, seams) = distribute(slack, count, Justify::parse(m)); + let parts: Vec = seams.iter().map(|s| s.to_string()).collect(); + println!("{m} {count} {slack} {lead} {}", parts.join(",")); + } + } + } +} diff --git a/ports/rust/src/layout.rs b/ports/rust/src/layout.rs index 6a65c30..35744b2 100644 --- a/ports/rust/src/layout.rs +++ b/ports/rust/src/layout.rs @@ -386,29 +386,135 @@ pub fn solve_with_gaps(total: usize, items: &[Constraint], gaps: &[isize]) -> Ve } /// Lay children out along one axis inside `rect`. +/// Where leftover space goes. +/// +/// It only ever applies when there is slack, and a container holding any `fr` +/// or `fill` child has none -- that child has already absorbed it. So this is +/// inert exactly where it would otherwise fight with the constraints. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Justify { + #[default] + Start, + End, + Center, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +impl Justify { + /// Parse the spelling the reference API uses. Anything else is `Start`, + /// which is what every layout did before this existed. + pub fn parse(name: &str) -> Justify { + match name { + "end" => Justify::End, + "center" => Justify::Center, + "space-between" => Justify::SpaceBetween, + "space-around" => Justify::SpaceAround, + "space-evenly" => Justify::SpaceEvenly, + _ => Justify::Start, + } + } +} + +/// How much slack sits before item `i`, as an exact fraction. +/// +/// Every mode is a different answer to that one question, which is why they +/// share the rounding below rather than each growing their own off-by-one. +fn before(i: usize, slack: f64, count: usize, justify: Justify) -> f64 { + let i = i as f64; + let count = count as f64; + match justify { + Justify::Start => 0.0, + Justify::End => slack, + // Floor, so an odd cell falls after the content rather than before it. + Justify::Center => (slack / 2.0).floor(), + Justify::SpaceBetween => { + if count > 1.0 { + i * slack / (count - 1.0) + } else { + 0.0 + } + } + Justify::SpaceEvenly => (i + 1.0) * slack / (count + 1.0), + Justify::SpaceAround => (i + 0.5) * slack / count, + } +} + +/// The offset before the first child, and the extra added at each seam. +/// +/// Cells are whole, and rounding each gap on its own loses one here and gains +/// one there. Rounding the cumulative offset and taking differences means the +/// parts always add up to exactly the slack. +pub fn distribute(slack: usize, count: usize, justify: Justify) -> (usize, Vec) { + let mut seams = vec![0usize; count.saturating_sub(1)]; + if slack == 0 || count == 0 || justify == Justify::Start { + return (0, seams); + } + let at = |i: usize| -> usize { before(i, slack as f64, count, justify).round() as usize }; + for i in 0..count.saturating_sub(1) { + seams[i] = at(i + 1).saturating_sub(at(i)); + } + (at(0), seams) +} + pub fn stack(rect: Rect, items: &[Constraint], direction: Direction, gap: usize) -> Vec { let seams = vec![gap as isize; items.len().saturating_sub(1)]; stack_with_gaps(rect, items, direction, &seams) } +/// As `stack`, with leftover space distributed rather than left at the end. +pub fn stack_justified( + rect: Rect, + items: &[Constraint], + direction: Direction, + gap: usize, + justify: Justify, +) -> Vec { + let seams = vec![gap as isize; items.len().saturating_sub(1)]; + stack_with_gaps_justified(rect, items, direction, &seams, justify) +} + /// As `stack`, but with a gap per seam, which may be negative. pub fn stack_with_gaps( rect: Rect, items: &[Constraint], direction: Direction, gaps: &[isize], +) -> Vec { + stack_with_gaps_justified(rect, items, direction, gaps, Justify::Start) +} + +/// The full form: a gap per seam, and a policy for whatever is left over. +pub fn stack_with_gaps_justified( + rect: Rect, + items: &[Constraint], + direction: Direction, + gaps: &[isize], + justify: Justify, ) -> Vec { let horizontal = direction == Direction::Row; - let sizes = solve_with_gaps(if horizontal { rect.width } else { rect.height }, items, gaps); + let axis = if horizontal { rect.width } else { rect.height }; + let sizes = solve_with_gaps(axis, items, gaps); + + let gap_total: isize = (0..sizes.len().saturating_sub(1)) + .map(|i| gaps.get(i).copied().unwrap_or(0)) + .sum(); + let used: isize = sizes.iter().map(|s| *s as isize).sum::() + gap_total; + let slack = (axis as isize - used).max(0) as usize; + let (lead, extra) = distribute(slack, sizes.len(), justify); + let mut out = Vec::with_capacity(sizes.len()); - let mut offset = if horizontal { rect.x } else { rect.y }; + let mut offset = (if horizontal { rect.x } else { rect.y }) + lead as isize; for (i, size) in sizes.into_iter().enumerate() { out.push(if horizontal { Rect { x: offset, y: rect.y, width: size, height: rect.height } } else { Rect { x: rect.x, y: offset, width: rect.width, height: size } }); - offset += size as isize + gaps.get(i).copied().unwrap_or(0); + offset += size as isize + + gaps.get(i).copied().unwrap_or(0) + + extra.get(i).copied().unwrap_or(0) as isize; } out } diff --git a/ports/zig/build.zig b/ports/zig/build.zig index 9fc00b0..687e773 100644 --- a/ports/zig/build.zig +++ b/ports/zig/build.zig @@ -46,7 +46,7 @@ pub fn build(b: *std.Build) void { // Examples are separate executables, so `zig build run-screenshot` works // without a TTY while `run-dashboard` takes one over. - for ([_][]const u8{ "hello", "dashboard", "screenshot", "widgets", "collapse" }) |name| { + for ([_][]const u8{ "hello", "dashboard", "screenshot", "widgets", "collapse", "justify_parity" }) |name| { const command_name = if (std.mem.eql(u8, name, "dashboard")) "dashboard-mini" else name; const exe = b.addExecutable(.{ .name = command_name, diff --git a/ports/zig/examples/justify_parity.zig b/ports/zig/examples/justify_parity.zig new file mode 100644 index 0000000..da54e50 --- /dev/null +++ b/ports/zig/examples/justify_parity.zig @@ -0,0 +1,43 @@ +//! Print the justify distribution for a matrix of inputs, so the ports can be +//! diffed against the TypeScript reference rather than assumed to agree. +const std = @import("std"); +const hqtui = @import("hqtui"); + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const stdout = std.Io.File.stdout(); + + var text: std.ArrayListUnmanaged(u8) = .empty; + defer text.deinit(allocator); + + const names = [_][]const u8{ "start", "end", "center", "space-between", "space-around", "space-evenly" }; + for (names) |name| { + const j = hqtui.layout.Justify.parse(name); + var count: usize = 1; + while (count <= 5) : (count += 1) { + var slack: usize = 0; + while (slack <= 12) : (slack += 1) { + const spread = try hqtui.layout.distribute(allocator, slack, count, j); + defer allocator.free(spread.seams); + + var seams: std.ArrayListUnmanaged(u8) = .empty; + defer seams.deinit(allocator); + for (spread.seams, 0..) |s, i| { + if (i > 0) try seams.appendSlice(allocator, ","); + const one = try std.fmt.allocPrint(allocator, "{d}", .{s}); + defer allocator.free(one); + try seams.appendSlice(allocator, one); + } + + const line = try std.fmt.allocPrint( + allocator, + "{s} {d} {d} {d} {s}\n", + .{ name, count, slack, spread.lead, seams.items }, + ); + defer allocator.free(line); + try text.appendSlice(allocator, line); + } + } + } + try stdout.writeStreamingAll(init.io, text.items); +} diff --git a/ports/zig/src/layout.zig b/ports/zig/src/layout.zig index 232258e..ce62024 100644 --- a/ports/zig/src/layout.zig +++ b/ports/zig/src/layout.zig @@ -328,6 +328,78 @@ pub fn solveWithGaps( } /// Lay children out along one axis inside `rect`. The caller owns the slice. +/// Where leftover space goes. +/// +/// It only ever applies when there is slack, and a container holding any `fr` +/// or `fill` child has none -- that child has already absorbed it. So this is +/// inert exactly where it would otherwise fight with the constraints. +pub const Justify = enum { + start, + end, + center, + space_between, + space_around, + space_evenly, + + /// The spelling the reference API uses. Anything else is `start`, which is + /// what every layout did before this existed. + pub fn parse(name: []const u8) Justify { + if (std.mem.eql(u8, name, "end")) return .end; + if (std.mem.eql(u8, name, "center")) return .center; + if (std.mem.eql(u8, name, "space-between")) return .space_between; + if (std.mem.eql(u8, name, "space-around")) return .space_around; + if (std.mem.eql(u8, name, "space-evenly")) return .space_evenly; + return .start; + } +}; + +/// How much slack sits before item `i`, as an exact fraction. Every mode is a +/// different answer to that one question, which is why they share the rounding +/// below rather than each growing their own off-by-one. +fn before(i: usize, slack: f64, count: usize, justify: Justify) f64 { + const fi: f64 = @floatFromInt(i); + const fc: f64 = @floatFromInt(count); + return switch (justify) { + .start => 0, + .end => slack, + // Floor, so an odd cell falls after the content rather than before it. + .center => @floor(slack / 2), + .space_between => if (count > 1) fi * slack / (fc - 1) else 0, + .space_evenly => (fi + 1) * slack / (fc + 1), + .space_around => (fi + 0.5) * slack / fc, + }; +} + +/// The offset before the first child, and the extra added at each seam. The +/// caller owns the returned slice. +/// +/// Cells are whole, and rounding each gap on its own loses one here and gains +/// one there. Rounding the cumulative offset and taking differences means the +/// parts always add up to exactly the slack. +pub fn distribute( + allocator: std.mem.Allocator, + slack: usize, + count: usize, + justify: Justify, +) !struct { lead: usize, seams: []usize } { + const seams = try allocator.alloc(usize, count -| 1); + @memset(seams, 0); + if (slack == 0 or count == 0 or justify == .start) return .{ .lead = 0, .seams = seams }; + + const at = struct { + fn f(i: usize, sl: usize, c: usize, j: Justify) usize { + const v = before(i, @floatFromInt(sl), c, j); + return @intFromFloat(@floor(v + 0.5)); + } + }.f; + + var i: usize = 0; + while (i + 1 < count) : (i += 1) { + seams[i] = at(i + 1, slack, count, justify) -| at(i, slack, count, justify); + } + return .{ .lead = at(0, slack, count, justify), .seams = seams }; +} + pub fn stack( allocator: std.mem.Allocator, rect: Rect, @@ -341,6 +413,21 @@ pub fn stack( return stackWithGaps(allocator, rect, items, direction, seams); } +/// As `stack`, with a policy for whatever the children leave over. +pub fn stackJustified( + allocator: std.mem.Allocator, + rect: Rect, + items: []const Constraint, + direction: Direction, + gap: usize, + justify: Justify, +) ![]Rect { + const seams = try allocator.alloc(isize, items.len -| 1); + defer allocator.free(seams); + @memset(seams, @intCast(gap)); + return stackWithGapsJustified(allocator, rect, items, direction, seams, justify); +} + /// As `stack`, but with a gap per seam, which may be negative. pub fn stackWithGaps( allocator: std.mem.Allocator, @@ -348,19 +435,46 @@ pub fn stackWithGaps( items: []const Constraint, direction: Direction, gaps: []const isize, +) ![]Rect { + return stackWithGapsJustified(allocator, rect, items, direction, gaps, .start); +} + +/// The full form: a gap per seam, and a policy for whatever is left over. +pub fn stackWithGapsJustified( + allocator: std.mem.Allocator, + rect: Rect, + items: []const Constraint, + direction: Direction, + gaps: []const isize, + justify: Justify, ) ![]Rect { const horizontal = direction == .row; - const sizes = try solveWithGaps(allocator, if (horizontal) rect.width else rect.height, items, gaps); + const axis = if (horizontal) rect.width else rect.height; + const sizes = try solveWithGaps(allocator, axis, items, gaps); defer allocator.free(sizes); + var gap_total: isize = 0; + var g: usize = 0; + while (g + 1 < sizes.len) : (g += 1) { + if (g < gaps.len) gap_total += gaps[g]; + } + var used: isize = gap_total; + for (sizes) |size| used += @intCast(size); + const slack: usize = @intCast(@max(0, @as(isize, @intCast(axis)) - used)); + + const spread = try distribute(allocator, slack, sizes.len, justify); + defer allocator.free(spread.seams); + const out = try allocator.alloc(Rect, sizes.len); - var offset: isize = if (horizontal) rect.x else rect.y; + var offset: isize = (if (horizontal) rect.x else rect.y) + @as(isize, @intCast(spread.lead)); for (sizes, 0..) |size, i| { out[i] = if (horizontal) .{ .x = offset, .y = rect.y, .width = size, .height = rect.height } else .{ .x = rect.x, .y = offset, .width = rect.width, .height = size }; - offset += @as(isize, @intCast(size)) + (if (i < gaps.len) gaps[i] else 0); + const seam = (if (i < gaps.len) gaps[i] else 0) + + @as(isize, @intCast(if (i < spread.seams.len) spread.seams[i] else 0)); + offset += @as(isize, @intCast(size)) + seam; } return out; }