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; }