Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/hqtui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 75 additions & 3 deletions packages/hqtui/src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(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;
}
Expand Down
11 changes: 10 additions & 1 deletion packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -114,6 +120,7 @@ export class Container {
readonly direction: "row" | "column";
private children: Child[] = [];
private gap: number;
private justify: Justify;
private inner: Surface;

constructor(
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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];
Expand Down
111 changes: 111 additions & 0 deletions packages/hqtui/test/justify.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
19 changes: 19 additions & 0 deletions ports/c/include/hqtui.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
70 changes: 66 additions & 4 deletions ports/c/src/layout.c
Original file line number Diff line number Diff line change
Expand Up @@ -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+1<count;++i) seams[i]=0;
if(slack<=0 || count==0 || justify==HQ_JUSTIFY_START) { *lead=0; return 1; }
for(size_t i=0;i+1<count;++i) {
int d=hq_at(i+1,slack,count,justify)-hq_at(i,slack,count,justify);
seams[i]=d>0 ? 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;i<n;++i) used+=sizes[i];
for(size_t i=0;i+1<n;++i) used+=gaps[i];
int64_t spare=(int64_t)axis-used;
int slack=spare>0 ? (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;i<n;++i) {
if(offset<INT_MIN || offset>INT_MAX) { if(sizes!=local) free(sizes); return 0; }
if(offset<INT_MIN || offset>INT_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<n ? gaps[i]:0);
offset+=(int64_t)sizes[i]+(i+1<n ? gaps[i]+extra[i]:0);
}
if(sizes!=local) free(sizes);
if(extra!=seam_local) free(extra);
return 1;
}
Loading
Loading