Skip to content
Draft
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 = Math.max(p.left ?? 0, sideWidth(bw?.left));
let padRight = Math.max(p.right ?? 0, sideWidth(bw?.right));
let padTop = Math.max(p.top ?? 0, sideWidth(bw?.top));
let padBottom = Math.max(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
35 changes: 24 additions & 11 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -797,12 +797,26 @@ 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 `max(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 `max` rule:

- **No explicit padding.** The border width itself becomes the effective
padding, so content is placed inside the border rather than behind it.
- **User padding equal to border width (prior workaround pattern).** The `max`
evaluates to the same value, so these elements render identically — no
double-reservation, no migration required.
- **User padding greater than border width.** The extra padding beyond the
border width provides additional breathing room inside the border. Padding is
effectively measured from the border edge inward.

This is a breaking change for callers who set padding _less than_ the border
width intending the overlap: those elements now have the overlap removed.
Callers who compensated by setting `padding == borderWidth` are unaffected.

### 12.3 Render return type

Expand Down Expand Up @@ -1037,11 +1051,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 via `max(padding, borderWidth)` per side at
pack time (TypeScript layer). See Section 12.2 for the full semantics
including the no-double-reservation guarantee for prior compensators.

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
171 changes: 170 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,163 @@ 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 == border renders identically to no padding (max semantics, no double-reservation)", 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(),
]);

// Both produce the same box height: border reserves 1 per side, no double-reservation
expect(r1.info.get("box")?.bounds.height).toBe(3);
expect(r2.info.get("box")?.bounds.height).toBe(3);
});

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 = max(2, 1) = 2, effective_bottom = max(2, 1) = 2
// height = 2 + 1 text + 2 = 5
expect(result.info.get("box")?.bounds.height).toBe(5);
});

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("└────────────┘");
});
});
Loading