Skip to content
Open
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
8 changes: 6 additions & 2 deletions ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,14 @@ export function pack(
o = packAxis(view, o, l.height ?? { type: "fit" });

let p = l.padding ?? {};
let bw = op.border;
let padLeft = (p.left ?? 0) + sideWidth(bw?.left);
let padRight = (p.right ?? 0) + sideWidth(bw?.right);
let padTop = (p.top ?? 0) + sideWidth(bw?.top);
let padBottom = (p.bottom ?? 0) + sideWidth(bw?.bottom);
view.setUint32(
o,
(p.left ?? 0) | ((p.right ?? 0) << 8) | ((p.top ?? 0) << 16) |
((p.bottom ?? 0) << 24),
padLeft | (padRight << 8) | (padTop << 16) | (padBottom << 24),
true,
);
o += 4;
Expand Down
36 changes: 25 additions & 11 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -797,12 +797,27 @@ excluding joined corner cells. Per-side attributes affect only the styling of
corner cells; corner glyph shape selection (including rounded corners via
`cornerRadius`) is unchanged.

**Border width and layout interaction.** In the underlying layout engine (Clay),
border configuration does not affect layout computation. This is Clay's intended
behavior. Borders are drawn as visual overlays within the element's bounding
box. A bordered element with zero padding will have its borders drawn over its
content. Callers must add padding equal to or greater than the border width to
prevent overlap.
**Border width and layout interaction.** The renderer automatically reserves
space for each enabled border side at pack time. For each side, the effective
padding passed to the layout engine is `userPadding + borderWidth`. Border
glyphs are drawn at the same positions as before; the change is purely in how
much layout space Clay allocates for the element.

Semantics of the additive rule:

- **No explicit padding.** The border width itself becomes the effective
padding, so content is placed immediately inside the border.
- **Explicit user padding.** Adds breathing room _beyond_ the border edge.
`padding: 1` with `border: 1` places content 2 cells from the element edge — 1
for the border glyph, 1 for the padding inset.
- **Prior workaround pattern (`padding == borderWidth`).** These callers now
receive double-reservation (effective = 2 × borderWidth). This is a breaking
change: remove the workaround padding to restore the original visual.

This is a breaking change for callers who compensated for the old border-layout
bug by setting `padding >= borderWidth`. Those callers should remove the
compensating padding; border presence now implies the necessary layout
reservation.

### 12.3 Render return type

Expand Down Expand Up @@ -1037,11 +1052,10 @@ resolution.
3. **Is `pack()` public API?** `pack()` is currently exported but is an internal
implementation detail, not public API. `validate()` is public API.

4. **How should border widths interact with layout?** The current behavior
(borders do not affect layout) is inherited from the underlying layout
engine. The project has questioned whether this is the right design. This
specification describes the current behavior in Section 12.2 without
committing to it.
4. **How should border widths interact with layout?** RESOLVED. Border widths
are now accounted for in layout additively (`padding + borderWidth`) per side
at pack time (TypeScript layer). See Section 12.2 for the full semantics.
This is a breaking change: prior workaround padding must be removed.

5. **What are the specific transfer encoding details?** The encoding structure
is described in Section 12.1 as current implementation surface. Locking down
Expand Down
172 changes: 171 additions & 1 deletion test/border.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { close, fixed, open, type OpenElement, rgba } from "../ops.ts";
import {
close,
fixed,
grow,
open,
type OpenElement,
rgba,
text,
} from "../ops.ts";
import { createTerm } from "../term.ts";
import { describe, expect, it } from "./suite.ts";
import { print } from "./print.ts";

const decode = (b: Uint8Array) => new TextDecoder().decode(b);

Expand Down Expand Up @@ -477,3 +486,164 @@ describe("instances", () => {
expect(again).not.toContain(FG.cyan);
});
});

const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n");

describe("box model", () => {
it("full border with no padding reserves space: children visible, box is 3 rows", async () => {
let term = await createTerm({ width: 20, height: 10 });
let result = term.render([
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
open("box", {
layout: { width: fixed(14), direction: "ttb" },
border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 },
}),
text("CASE A"),
close(),
close(),
]);

expect(result.info.get("box")?.bounds.height).toBe(3);
expect(decode(result.output)).toContain("CASE A");
});

it("partial borders (top+left) reserve only their sides", async () => {
let term = await createTerm({ width: 20, height: 10 });
let result = term.render([
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
open("box", {
layout: { width: fixed(14), direction: "ttb" },
border: { color: WHITE, top: 1, left: 1 },
}),
text("CASE B"),
close(),
close(),
]);

// top border reserves 1 row, no bottom border so no bottom reservation
expect(result.info.get("box")?.bounds.height).toBe(2);
expect(decode(result.output)).toContain("CASE B");
});

it("padding is additive: border=1 alone gives height 3; border=1 plus padding=1 gives height 5", async () => {
let nopad = await createTerm({ width: 20, height: 10 });
let r1 = nopad.render([
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
open("box", {
layout: { width: fixed(14), direction: "ttb" },
border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 },
}),
text("CONTENT"),
close(),
close(),
]);

let withpad = await createTerm({ width: 20, height: 10 });
let r2 = withpad.render([
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
open("box", {
layout: {
width: fixed(14),
direction: "ttb",
padding: { top: 1, right: 1, bottom: 1, left: 1 },
},
border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 },
}),
text("CONTENT"),
close(),
close(),
]);

// border=1, no padding: effective = 0+1 = 1 per side → height = 1+text+1 = 3
expect(r1.info.get("box")?.bounds.height).toBe(3);
// border=1, padding=1: effective = 1+1 = 2 per side → height = 2+text+2 = 5
expect(r2.info.get("box")?.bounds.height).toBe(5);
});

it("explicit padding > border width adds breathing room inside the border", async () => {
let term = await createTerm({ width: 20, height: 10 });
let result = term.render([
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
open("box", {
layout: {
width: fixed(14),
direction: "ttb",
padding: { top: 2, bottom: 2 },
},
border: { color: WHITE, top: 1, bottom: 1 },
}),
text("CONTENT"),
close(),
close(),
]);

// effective_top = 2+1 = 3, effective_bottom = 2+1 = 3
// height = 3 + 1 text + 3 = 7
expect(result.info.get("box")?.bounds.height).toBe(7);
});

it("nested two-tone bevel lays out without manual padding compensation", async () => {
let term = await createTerm({ width: 20, height: 10 });
let result = term.render([
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
open("outer", {
layout: { width: fixed(16), direction: "ttb" },
border: { color: WHITE, top: 1, left: 1 },
}),
open("inner", {
layout: { width: grow(), direction: "ttb" },
border: { color: WHITE, bottom: 1, right: 1 },
}),
text("NESTED"),
close(),
close(),
close(),
]);

// inner: effective_bottom=1, effective_right=1 → height = 0 + text(1) + 1 = 2
// outer: effective_top=1, effective_left=1 → height = 1 + inner(2) + 0 = 3
expect(result.info.get("outer")?.bounds.height).toBe(3);
expect(decode(result.output)).toContain("NESTED");
});

it("visual: full border renders border glyphs around content", async () => {
let term = await createTerm({ width: 20, height: 10 });
let out = trim(
print(
decode(
term.render([
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
open("box", {
layout: { width: fixed(14), direction: "ttb" },
border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 },
}),
text("CASE A"),
close(),
close(),
]).output,
),
20,
10,
),
);

let lines = out.split("\n");
expect(lines[0]).toBe("┌────────────┐");
expect(lines[1]).toBe("│CASE A │");
expect(lines[2]).toBe("└────────────┘");
});
});
2 changes: 0 additions & 2 deletions test/clip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ describe("clip", () => {
width: fixed(10),
height: fixed(3),
direction: "ttb",
padding: pad,
},
border,
}),
Expand Down Expand Up @@ -150,7 +149,6 @@ describe("clip", () => {
width: fixed(10),
height: fixed(3),
direction: "ttb",
padding: pad,
},
border,
}),
Expand Down
6 changes: 1 addition & 5 deletions test/term.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ describe("term", () => {
│ │
│ │
│ │
│ padded │
│ │
│ padded │
│ │
│ │
╰──────────────────────────────────────╯`.trim());
Expand All @@ -109,7 +109,6 @@ describe("term", () => {
width: grow(),
height: grow(),
direction: "ttb",
padding: { left: 1, top: 1 },
},
border: {
color: rgba(255, 255, 255),
Expand Down Expand Up @@ -212,7 +211,6 @@ describe("term", () => {
width: fixed(12),
height: fixed(5),
direction: "ttb",
padding: { left: 1, top: 1 },
},
border: {
color: rgba(255, 255, 255),
Expand Down Expand Up @@ -297,7 +295,6 @@ describe("term", () => {
width: grow(),
height: grow(),
direction: "ttb",
padding: { left: 1, top: 1 },
},
border: {
color: rgba(255, 255, 255),
Expand Down Expand Up @@ -349,7 +346,6 @@ describe("term", () => {
width: grow(),
height: grow(),
direction: "ttb",
padding: { left: 1, top: 1 },
},
border: {
color: rgba(255, 255, 255),
Expand Down
Loading