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
5 changes: 4 additions & 1 deletion packages/hqtui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export { detectCapabilities, type Capabilities, type ColorDepth, type Capability
// Rendering core
export { FrameBuffer, Attr, type Style, type Attributes } from "./buffer.ts";
export { Encoder, encodeFull, type EncodeResult } from "./diff.ts";
export { Surface, createSurface, BORDERS, type BorderStyle, type BoxOptions, type Align } from "./surface.ts";
export {
Surface, createSurface, BORDERS, resolveSides,
type BorderStyle, type BoxOptions, type Align, type Side, type Sides,
} from "./surface.ts";
export { ansi, stripAnsi, moveTo, setTitle } from "./ansi.ts";

// Color
Expand Down
97 changes: 83 additions & 14 deletions packages/hqtui/src/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,51 @@ export function borderBits(codepoint: number): number | null {
return BITS_BY_CODEPOINT.get(codepoint) ?? null;
}

export type Side = "top" | "right" | "bottom" | "left";

/**
* Which edges of a box to draw. "all" is every side, which is what a box was
* before this existed; "none" draws no rule but still insets nothing, the same
* as a border style of "none".
*/
export type Sides = "all" | "none" | readonly Side[];

/** The four sides as flags, in the order the drawing code wants them. */
export interface DrawnSides {
top: boolean;
right: boolean;
bottom: boolean;
left: boolean;
}

export function resolveSides(sides: Sides | undefined): DrawnSides {
if (sides === undefined || sides === "all") {
return { top: true, right: true, bottom: true, left: true };
}
if (sides === "none") return { top: false, right: false, bottom: false, left: false };
return {
top: sides.includes("top"),
right: sides.includes("right"),
bottom: sides.includes("bottom"),
left: sides.includes("left"),
};
}

/**
* The glyph for a cell where two edges meet, given which of them are drawn.
*
* A corner is only a corner when both its sides are there. A top-only rule
* runs straight through the cell a corner would occupy, which is why this
* falls back to the plain rule rather than leaving a gap.
*/
export function junction(style: Exclude<BorderStyle, "none">, bits: number): string | null {
if (bits === 0) return null;
const glyph = borderGlyph(style, bits);
if (glyph !== null) return glyph;
const chars = BORDERS[style];
return bits & (EDGE_LEFT | EDGE_RIGHT) ? chars.h : chars.v;
}

export type Align = "left" | "center" | "right";

export interface TextOptions extends Style {
Expand All @@ -88,6 +133,11 @@ export interface TextOptions extends Style {

export interface BoxOptions extends Style {
border?: BorderStyle;
/**
* Which edges to draw. Defaults to all four. The interior follows the sides
* actually drawn, so a top-only box costs one row rather than two.
*/
sides?: Sides;
borderColor?: Color;
title?: string;
titleAlign?: Align;
Expand Down Expand Up @@ -312,8 +362,11 @@ export class Surface {
this.fill({ bg });
}

if (style === "none" || this.width < 2 || this.height < 1) {
return this.inset(style === "none" ? 0 : 1);
const sides = resolveSides(options.sides);
const any = sides.top || sides.right || sides.bottom || sides.left;

if (style === "none" || !any || this.width < 2 || this.height < 1) {
return this.inset(style === "none" || !any ? 0 : 1);
}

const b = BORDERS[style];
Expand All @@ -334,15 +387,25 @@ export class Surface {
for (let i = 0; i < length; i++) put(x, y + i, ch);
};

put(0, 0, b.tl);
put(w - 1, 0, b.tr);
putH(1, 0, w - 2, b.h);
// A corner belongs to the two sides that meet there, so it exists only if
// both of them are drawn; where one is, the rule runs straight through.
const corner = (a: boolean, aBit: number, c: boolean, cBit: number): string | null =>
junction(style, (a ? aBit : 0) | (c ? cBit : 0));

const tl = corner(sides.top, EDGE_RIGHT, sides.left, EDGE_DOWN);
const tr = corner(sides.top, EDGE_LEFT, sides.right, EDGE_DOWN);
if (sides.top) putH(1, 0, w - 2, b.h);
if (tl !== null) put(0, 0, tl);
if (tr !== null) put(w - 1, 0, tr);

if (h > 1) {
put(0, h - 1, b.bl);
put(w - 1, h - 1, b.br);
putH(1, h - 1, w - 2, b.h);
putV(0, 1, h - 2, b.v);
putV(w - 1, 1, h - 2, b.v);
const bl = corner(sides.bottom, EDGE_RIGHT, sides.left, EDGE_UP);
const br = corner(sides.bottom, EDGE_LEFT, sides.right, EDGE_UP);
if (sides.bottom) putH(1, h - 1, w - 2, b.h);
if (bl !== null) put(0, h - 1, bl);
if (br !== null) put(w - 1, h - 1, br);
if (sides.left) putV(0, 1, h - 2, b.v);
if (sides.right) putV(w - 1, 1, h - 2, b.v);
}

// Measured before the title is drawn: both share the top border row, and
Expand All @@ -351,7 +414,7 @@ export class Surface {
const subtitle = options.subtitle ? ` ${options.subtitle} ` : "";
const subtitleWidth = subtitle && stringWidth(subtitle) + 4 < w ? stringWidth(subtitle) : 0;

if (options.title) {
if (options.title && sides.top) {
const titleColor = options.titleColor ?? this.theme.title;
const label = ` ${options.title} `;
// The title lives in [2, limit). Reserving the width is not enough on its
Expand All @@ -373,22 +436,28 @@ export class Surface {
this.text(tx, 0, shown, { fg: titleColor, bg, attrs: 1 /* bold */ });
}

if (subtitleWidth > 0) {
if (subtitleWidth > 0 && sides.top) {
this.text(w - 2 - subtitleWidth, 0, subtitle, {
fg: options.subtitleColor ?? this.theme.muted,
bg,
});
}

if (options.footer && h > 2) {
if (options.footer && sides.bottom && h > 2) {
const foot = ` ${options.footer} `;
const fw = stringWidth(foot);
if (fw + 4 < w) {
this.text(2, h - 1, foot, { fg: options.footerColor ?? this.theme.muted, bg });
}
}

return this.sub(1, 1, Math.max(0, w - 2), Math.max(0, h - 2));
// The interior follows the sides actually drawn, so a top-only box costs
// one row rather than two.
const left = sides.left ? 1 : 0;
const top = sides.top ? 1 : 0;
const shrinkX = left + (sides.right ? 1 : 0);
const shrinkY = top + (sides.bottom ? 1 : 0);
return this.sub(left, top, Math.max(0, w - shrinkX), Math.max(0, h - shrinkY));
}

/** Absolute rect of this surface, for hit-testing mouse events. */
Expand Down
9 changes: 8 additions & 1 deletion packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Surface, BorderStyle, Align, BoxOptions } from "./surface.ts";
import type { Surface, BorderStyle, Align, BoxOptions, Sides } from "./surface.ts";
import type { Style } from "./buffer.ts";
import type { Color } from "./color.ts";
import type { Theme } from "./theme.ts";
Expand Down Expand Up @@ -94,6 +94,12 @@ export interface PanelOptions extends ContainerOptions {
footer?: string;
border?: BorderStyle;
borderColor?: Color;
/**
* Which edges of the panel to draw. Defaults to all four. Use it for chrome
* that is not a box: a header rule, a sidebar rail, a footer that should not
* look boxed in.
*/
sides?: Sides;
/** Draws the focused border color and joins the Tab order. */
focusable?: boolean;
focused?: boolean;
Expand Down Expand Up @@ -271,6 +277,7 @@ export class Container {
subtitleColor: options.subtitleColor,
footer: options.footer,
border: options.border ?? "rounded",
...(options.sides === undefined ? {} : { sides: options.sides }),
borderColor: options.borderColor ?? (focused ? this.theme.borderFocused : this.theme.border),
bg: options.background,
collapse: this.ctx.collapseBorders,
Expand Down
180 changes: 88 additions & 92 deletions packages/hqtui/test/justify.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { test } from "node:test";
import assert from "node:assert/strict";
import { distribute, stack, type Justify } from "../src/index.ts";

const rect = (width: number) => ({ x: 0, y: 0, width, height: 1 });
Expand All @@ -12,100 +13,95 @@ function placesExactly(slack: number, count: number, justify: Justify): boolean
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);
}
const MODES: Justify[] = ["start", "end", "center", "space-between", "space-around", "space-evenly"];

test("justify: start is what every layout did before, so nothing moves", () => {
assert.deepEqual(xs([4, 4, 4], "start"), [0, 4, 8]);
assert.deepEqual(distribute(8, 3, "start"), { lead: 0, seams: [0, 0] });
});

test("justify: end pushes everything against the far edge", () => {
// 20 wide, 12 used, 8 spare: the first child starts at 8.
assert.deepEqual(xs([4, 4, 4], "end"), [8, 12, 16]);
});

test("justify: center splits the slack, and the odd cell falls after the content", () => {
assert.deepEqual(xs([4, 4, 4], "center"), [4, 8, 12]);
// 20 - 9 = 11 spare, so 5 before and 6 after, which is what reads as centred.
assert.deepEqual(xs([3, 3, 3], "center"), [5, 8, 11]);
});

test("justify: space-between puts it all between, never at the ends", () => {
const at = xs([4, 4, 4], "space-between");
assert.deepEqual(at, [0, 8, 16]);
// The last child ends exactly on the far edge.
assert.equal((at[2] as number) + 4, 20);
});

test("justify: space-between with one child leaves it where it started", () => {
// There is nothing to sit between.
assert.deepEqual(xs([4], "space-between"), [0]);
assert.deepEqual(distribute(16, 1, "space-between"), { lead: 0, seams: [] });
});

test("justify: space-evenly makes every gap the same, ends included", () => {
// 20 - 12 = 8 across 4 gaps: 2 each.
assert.deepEqual(xs([4, 4, 4], "space-evenly"), [2, 8, 14]);
});

test("justify: 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);
assert.equal(at[0], 2);
assert.equal((at[1] as number) - ((at[0] as number) + 4), 3);
});

test("justify: 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.
assert.deepEqual(xs([4, 4, 4], "space-between", 20, 1), [0, 8, 16]);
assert.deepEqual(xs([4, 4, 4], "start", 20, 1), [0, 5, 10]);
});

test("justify: no slack means every mode agrees with start", () => {
for (const justify of MODES) {
// Exactly full: there is nothing to distribute.
assert.deepEqual(xs([5, 5, 5, 5], justify), [0, 5, 10, 15]);
}
});

test("justify: a flexible child leaves no slack, so it is inert beside one", () => {
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.
assert.deepEqual(at, [0, 4]);
});

test("justify: nothing is invented or lost, for any slack, count or mode", () => {
for (const justify of MODES) {
for (let count = 1; count <= 6; count++) {
for (let slack = 0; slack <= 17; slack++) {
assert.equal(placesExactly(slack, count, justify), true, `${justify} ${count} ${slack}`);
}
}
});

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("justify: children never overlap and never leave the rect", () => {
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) {
assert.ok(r.x >= edge, `${justify} ${total}: ${r.x} < ${edge}`);
edge = r.x + r.width;
}
assert.ok(edge <= total, `${justify} ${total}: ${edge} > ${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]);
});
test("justify: 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);
assert.deepEqual(ys, [12, 16]);
});
Loading