Skip to content

Commit 12cb0a4

Browse files
authored
Merge pull request #96 from bombshell-dev/per-side-border-69
✨ per-side border support
2 parents 83d0475 + ea06359 commit 12cb0a4

8 files changed

Lines changed: 770 additions & 52 deletions

File tree

examples/inline-regions/index.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ const GREEN = rgba(80, 250, 123);
3939
const GREEN_BG = rgba(20, 70, 38);
4040
const GRAY = rgba(100, 100, 100);
4141
const CYAN = rgba(139, 233, 253);
42-
const DARK_BG = rgba(30, 30, 40);
4342

4443
const RED = rgba(255, 0, 0);
4544
const ORANGE = rgba(255, 153, 0);
@@ -85,7 +84,7 @@ await main(function* () {
8584
);
8685

8786
let first = term.render(
88-
box("Press any key to compile modules.", CYAN, GRAY, DARK_BG),
87+
box("Press any key to compile modules.", CYAN, GRAY),
8988
{ row },
9089
);
9190
write(new Uint8Array(first.output));
@@ -102,7 +101,6 @@ await main(function* () {
102101
`${icon} ${label} ${time}`,
103102
done ? GREEN : CYAN,
104103
done ? GREEN : GRAY,
105-
DARK_BG,
106104
),
107105
{ row },
108106
);
@@ -350,7 +348,7 @@ function waitKey(): void {
350348
}
351349
}
352350

353-
function box(msg: string, fg: number, border: number, bg: number): Op[] {
351+
function box(msg: string, fg: number, border: number, bg?: number): Op[] {
354352
return [
355353
open("root", {
356354
layout: { width: grow(), height: grow(), direction: "ttb" },

ops.ts

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ function packAxis(view: DataView, offset: number, axis: SizingAxis): number {
5353
return o;
5454
}
5555

56+
function sideWidth(side: BorderSide | undefined): number {
57+
return typeof side === "number" ? side : side?.width ?? 0;
58+
}
59+
60+
function sideFg(side: BorderSide | undefined, shared: number): number {
61+
let color = typeof side === "object" && side.color !== undefined
62+
? side.color
63+
: shared;
64+
return color & 0x00FFFFFF;
65+
}
66+
67+
function sideBg(
68+
side: BorderSide | undefined,
69+
shared: number | undefined,
70+
): number {
71+
let bg = typeof side === "object" && side.bg !== undefined ? side.bg : shared;
72+
// ATTR_DEFAULT sentinel (bit 31 set) means "keep the existing cell bg"
73+
return bg === undefined ? 0x80000000 : bg & 0x00FFFFFF;
74+
}
75+
5676
function packString(
5777
view: DataView,
5878
bytes: Uint8Array,
@@ -162,21 +182,25 @@ export function pack(
162182

163183
if (op.border) {
164184
let b = op.border;
165-
view.setUint32(o, b.color, true);
166-
o += 4;
167-
168-
// ATTR_DEFAULT sentinel (bit 31 set) means "use terminal default bg"
169-
let bg = b.bg === undefined ? 0x80000000 : b.bg & 0x00FFFFFF;
170-
view.setUint32(o, bg, true);
171-
o += 4;
172-
173185
view.setUint32(
174186
o,
175-
(b.left ?? 0) | ((b.right ?? 0) << 8) | ((b.top ?? 0) << 16) |
176-
((b.bottom ?? 0) << 24),
187+
sideWidth(b.left) | (sideWidth(b.right) << 8) |
188+
(sideWidth(b.top) << 16) | (sideWidth(b.bottom) << 24),
177189
true,
178190
);
179191
o += 4;
192+
193+
// Must match render_border() in src/clayterm.c.
194+
// Resolve CSS-like side fallbacks here, then write eight required
195+
// attribute words: fg/bg pairs in top, right, bottom, left order.
196+
// C treats the presence and order of these words as a wire-format
197+
// invariant and only consumes the explicit values.
198+
for (let side of [b.top, b.right, b.bottom, b.left]) {
199+
view.setUint32(o, sideFg(side, b.color), true);
200+
o += 4;
201+
view.setUint32(o, sideBg(side, b.bg), true);
202+
o += 4;
203+
}
180204
}
181205

182206
if (op.clip) {
@@ -300,6 +324,14 @@ export interface CloseElement {
300324
directive: typeof OP_CLOSE_ELEMENT;
301325
}
302326

327+
export type BorderSide =
328+
| number
329+
| {
330+
width: number;
331+
color?: number;
332+
bg?: number;
333+
};
334+
303335
export interface OpenElement {
304336
directive: typeof OP_OPEN_ELEMENT;
305337
id: string;
@@ -317,10 +349,10 @@ export interface OpenElement {
317349
border?: {
318350
color: number;
319351
bg?: number;
320-
left?: number;
321-
right?: number;
322-
top?: number;
323-
bottom?: number;
352+
left?: BorderSide;
353+
right?: BorderSide;
354+
top?: BorderSide;
355+
bottom?: BorderSide;
324356
};
325357
clip?: { horizontal?: boolean; vertical?: boolean };
326358
floating?: {
@@ -453,7 +485,7 @@ function packSize(ops: Op[]): number {
453485
if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align
454486
if (op.bg !== undefined) n += 4;
455487
if (op.cornerRadius) n += 4;
456-
if (op.border) n += 12;
488+
if (op.border) n += 36; // widths word + 4 sides × (fg + bg)
457489
if (op.clip) n += 4;
458490
// x, y, expand width/height, parent, attach/pointer, clip/z
459491
if (op.floating) n += 7 * 4;

specs/renderer-spec.md

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -681,8 +681,11 @@ The `open()` constructor currently accepts the following property groups in its
681681
padding (per-side), alignment (`alignX`: `"left"` | `"center"` | `"right"`;
682682
`alignY`: `"top"` | `"center"` | `"bottom"`, defaulting to left/top when
683683
omitted), direction (top-to-bottom or left-to-right), and gap
684-
- **`border`** — per-side border widths, border color, and border background
685-
color
684+
- **`border`** — per-side border configuration. Each side field (`top`, `right`,
685+
`bottom`, `left`) accepts either a scalar width or a structured object
686+
`{ width, color?, bg? }`. The shared `color` field is required and is the
687+
fallback foreground for every side; the optional shared `bg` field is the
688+
fallback border-cell background for every side
686689
- **`cornerRadius`** — per-corner radius values, producing rounded box-drawing
687690
characters
688691
- **`clip`** — Declares the element as a clip region (see §7.5). Currently
@@ -749,12 +752,47 @@ The `text()` constructor accepts: `color`, `bg`, `fontSize`, `letterSpacing`,
749752
These property groups represent the current implementation surface. New groups
750753
and fields have been added incrementally and more may follow.
751754

752-
**Border background.** When `border.bg` is provided, the renderer MUST apply
753-
that background color to all cells occupied by border glyphs (corners,
754-
horizontal edges, and vertical edges). When `border.bg` is omitted, border
755-
rendering MUST NOT override the background already present in each border cell;
756-
element backgrounds established by `open({ bg })` remain in effect, and the
757-
terminal default remains in effect where no element background applies.
755+
**Border sides.** Each border side is declared independently as either a scalar
756+
width (`top: 1`) or a structured object (`top: { width: 1, color?, bg? }`). The
757+
two forms are equivalent when the object form provides only `width`. A side is
758+
enabled when its resolved width is greater than zero; an omitted side or a side
759+
with width `0` MUST NOT be drawn. Scalar side declarations MUST keep their
760+
pre-existing behavior.
761+
762+
**Border side colors (fallback resolution).** Side attributes resolve in a
763+
CSS-like shorthand/longhand fashion before rendering:
764+
765+
- A structured side with `color` MUST render with that foreground color. A
766+
scalar side, or a structured side that omits `color`, MUST fall back to the
767+
shared `border.color`. The shared `color` remains required.
768+
- A structured side with `bg` MUST render border cells of that side with that
769+
background color. A scalar side, or a structured side that omits `bg`, MUST
770+
fall back to the shared `border.bg` when it is provided.
771+
- When neither the side nor the shared border provides `bg`, border rendering
772+
MUST NOT override the background already present in each border cell of that
773+
side; element backgrounds established by `open({ bg })` remain in effect, and
774+
the terminal default remains in effect where no element background applies.
775+
776+
Fallback resolution is performed on the TypeScript side before the frame is
777+
transferred; the WASM renderer consumes explicit per-side attributes and does
778+
not implement the public fallback rules.
779+
780+
**Independent sides and corners.** Each enabled side renders as a straight edge
781+
(`` for horizontal sides, `` for vertical sides). A corner glyph MUST be
782+
rendered only when both adjacent sides for that corner are enabled; when either
783+
adjacent side is absent, the present side continues straight through the
784+
endpoint with no corner glyph. A left-only border is therefore a plain vertical
785+
line, and a top-plus-bottom border is two plain horizontal rules.
786+
787+
**Corner styling approximation.** A terminal cell carries a single glyph,
788+
foreground, and background, so CSS-style diagonally split corners cannot be
789+
represented. When corners are rendered: top corners (``, ``, and their rounded
790+
variants) MUST use the resolved attributes of the `top` side, and bottom corners
791+
(``, ``, and their rounded variants) MUST use the resolved attributes of the
792+
`bottom` side. Left and right side attributes apply to vertical edge cells
793+
excluding joined corner cells. Per-side attributes affect only the styling of
794+
corner cells; corner glyph shape selection (including rounded corners via
795+
`cornerRadius`) is unchanged.
758796

759797
**Border width and layout interaction.** In the underlying layout engine (Clay),
760798
border configuration does not affect layout computation. This is Clay's intended

src/clayterm.c

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -325,44 +325,64 @@ static void render_text(struct Clayterm *ct, int x0, int y0,
325325
static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1,
326326
Clay_RenderCommand *cmd) {
327327
Clay_BorderRenderData *b = &cmd->renderData.border;
328-
uint32_t fg = color(b->color);
329-
/* userData is currently exclusively the packed border-bg word. */
330-
uint32_t bg = (uint32_t)(uintptr_t)cmd->userData;
328+
/* Must match border packing in ops.ts.
329+
* userData points at eight required words in the command buffer: resolved
330+
* fg/bg pairs in top, right, bottom, left order. Fallback resolution
331+
* (shared color/bg vs side overrides) happens on the TypeScript side; this
332+
* renderer consumes explicit values only. The command buffer outlives the
333+
* render pass within reduce(). Missing userData is a wire-format violation.
334+
*/
335+
const uint32_t *s = (const uint32_t *)cmd->userData;
336+
if (s == NULL) {
337+
__builtin_trap();
338+
}
339+
340+
uint32_t top_fg = s[0];
341+
uint32_t top_bg = s[1];
342+
uint32_t right_fg = s[2];
343+
uint32_t right_bg = s[3];
344+
uint32_t bot_fg = s[4];
345+
uint32_t bot_bg = s[5];
346+
uint32_t left_fg = s[6];
347+
uint32_t left_bg = s[7];
331348
int top = b->width.top > 0;
332349
int bot = b->width.bottom > 0;
333350
int left = b->width.left > 0;
334351
int right = b->width.right > 0;
335352

336-
/* corners — rounded when corner radius > 0 */
353+
/* corners — rounded when corner radius > 0. Drawn only when both adjacent
354+
* sides are enabled; a terminal cell holds a single fg/bg, so top corners
355+
* take the top side attributes and bottom corners take the bottom side
356+
* attributes (deterministic approximation of CSS split corners). */
337357
uint32_t tl = b->cornerRadius.topLeft > 0 ? 0x256d : 0x250c;
338358
uint32_t tr = b->cornerRadius.topRight > 0 ? 0x256e : 0x2510;
339359
uint32_t bl = b->cornerRadius.bottomLeft > 0 ? 0x2570 : 0x2514;
340360
uint32_t br = b->cornerRadius.bottomRight > 0 ? 0x256f : 0x2518;
341361

342362
if (top && left)
343-
setcell(ct, x0, y0, tl, fg, bg);
363+
setcell(ct, x0, y0, tl, top_fg, top_bg);
344364
if (top && right)
345-
setcell(ct, x1 - 1, y0, tr, fg, bg);
365+
setcell(ct, x1 - 1, y0, tr, top_fg, top_bg);
346366
if (bot && left)
347-
setcell(ct, x0, y1 - 1, bl, fg, bg);
367+
setcell(ct, x0, y1 - 1, bl, bot_fg, bot_bg);
348368
if (bot && right)
349-
setcell(ct, x1 - 1, y1 - 1, br, fg, bg);
369+
setcell(ct, x1 - 1, y1 - 1, br, bot_fg, bot_bg);
350370

351371
/* horizontal edges */
352372
if (top)
353373
for (int x = x0 + left; x < x1 - right; x++)
354-
setcell(ct, x, y0, 0x2500, fg, bg);
374+
setcell(ct, x, y0, 0x2500, top_fg, top_bg);
355375
if (bot)
356376
for (int x = x0 + left; x < x1 - right; x++)
357-
setcell(ct, x, y1 - 1, 0x2500, fg, bg);
377+
setcell(ct, x, y1 - 1, 0x2500, bot_fg, bot_bg);
358378

359-
/* vertical edges */
379+
/* vertical edges — excluding joined corner cells owned by top/bottom */
360380
if (left)
361381
for (int y = y0 + top; y < y1 - bot; y++)
362-
setcell(ct, x0, y, 0x2502, fg, bg);
382+
setcell(ct, x0, y, 0x2502, left_fg, left_bg);
363383
if (right)
364384
for (int y = y0 + top; y < y1 - bot; y++)
365-
setcell(ct, x1 - 1, y, 0x2502, fg, bg);
385+
setcell(ct, x1 - 1, y, 0x2502, right_fg, right_bg);
366386
}
367387

368388
/* ── Command buffer helpers ───────────────────────────────────────── */
@@ -577,15 +597,18 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) {
577597
}
578598

579599
if (mask & PROP_BORDER) {
580-
decl.border.color = unpack_color(rd(buf, len, &i));
581-
582-
decl.userData = (void *)(uintptr_t)rd(buf, len, &i);
583-
584600
uint32_t bw = rd(buf, len, &i);
585601
decl.border.width.left = bw & 0xff;
586602
decl.border.width.right = (bw >> 8) & 0xff;
587603
decl.border.width.top = (bw >> 16) & 0xff;
588604
decl.border.width.bottom = (bw >> 24) & 0xff;
605+
606+
/* Resolved per-side fg/bg attribute words (top, right, bottom,
607+
* left). Routed to render_border via userData; the command buffer
608+
* remains valid for the whole render pass. */
609+
if (i + 8 <= len)
610+
decl.userData = (void *)&buf[i];
611+
i += 8;
589612
}
590613

591614
if (mask & PROP_CLIP) {

term-native.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ const BoundingBoxStruct = struct<BoundingBox>({
1616

1717
const BOUNDING_BOX = offsets(BoundingBoxStruct);
1818

19+
const WASM_PAGE_BYTES = 65536;
20+
const TEXT_TRANSFER_BUFFER_BYTES = 1024 * 1024;
21+
const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192;
22+
23+
// Conservative fixed wire-format budget per element. This covers the largest
24+
// non-text open/close element encoding we currently support; text, element id,
25+
// and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES.
26+
const MAX_FIXED_ELEMENT_WIRE_BYTES = 116;
27+
1928
export interface Native {
2029
memory: WebAssembly.Memory;
2130
statePtr: number;
@@ -92,10 +101,15 @@ export async function createTermNative(
92101
let heap = ct.__heap_base.value as number;
93102
let size = ct.clayterm_size(w, h);
94103

95-
// grow memory to fit heap + state + ops buffer (1MB headroom for ops)
96-
let needed = heap + size + 1024 * 1024;
97-
let pages = Math.ceil(needed / 65536);
98-
let current = memory.buffer.byteLength / 65536;
104+
// Grow memory once to fit heap + renderer state + fixed transfer buffer.
105+
// The transfer budget is intentionally fixed: text/id/snapshot payload bytes
106+
// get 1MB, and fixed op overhead gets one max-sized element per Clay element.
107+
// Do not grow this dynamically per render; improve the wire format instead.
108+
let transferBytes = TEXT_TRANSFER_BUFFER_BYTES +
109+
CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES;
110+
let needed = heap + size + transferBytes;
111+
let pages = Math.ceil(needed / WASM_PAGE_BYTES);
112+
let current = memory.buffer.byteLength / WASM_PAGE_BYTES;
99113
if (pages > current) {
100114
memory.grow(pages - current);
101115
}

0 commit comments

Comments
 (0)