[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
4.5 kB
147 lines
1import { Type } from "@sinclair/typebox";
2import { TypeCompiler } from "@sinclair/typebox/compiler";
3import type { Op } from "./ops.ts";
4import type { RenderOptions, RenderResult, Term } from "./term.ts";
5
6/* ── Range helpers (match bit-packing in pack()) ──────────────────── */
7
8const u8 = Type.Integer({ minimum: 0, maximum: 255 });
9const u16 = Type.Integer({ minimum: 0, maximum: 65535 });
10
11/* RGBA color packed as (a << 24 | r << 16 | g << 8 | b). When alpha >= 128,
12 * bit 31 is set and JavaScript interprets the value as a negative int32.
13 * Accept both signed and unsigned representations of the same bit pattern. */
14const rgba = Type.Integer({ minimum: -0x80000000, maximum: 0xFFFFFFFF });
15
16/* ── Sizing axis (discriminated union) ────────────────────────────── */
17
18const Fit = Type.Object({
19 type: Type.Literal("fit"),
20 min: Type.Optional(Type.Number()),
21 max: Type.Optional(Type.Number()),
22});
23
24const Grow = Type.Object({
25 type: Type.Literal("grow"),
26 min: Type.Optional(Type.Number()),
27 max: Type.Optional(Type.Number()),
28});
29
30const Percent = Type.Object({
31 type: Type.Literal("percent"),
32 value: Type.Number(),
33});
34
35const Fixed = Type.Object({
36 type: Type.Literal("fixed"),
37 value: Type.Number(),
38});
39
40const SizingAxis = Type.Union([Fit, Grow, Percent, Fixed]);
41
42/* ── Sub-objects ──────────────────────────────────────────────────── */
43
44const Padding = Type.Object({
45 left: Type.Optional(u8),
46 right: Type.Optional(u8),
47 top: Type.Optional(u8),
48 bottom: Type.Optional(u8),
49});
50
51const Layout = Type.Object({
52 width: Type.Optional(SizingAxis),
53 height: Type.Optional(SizingAxis),
54 padding: Type.Optional(Padding),
55 gap: Type.Optional(u16),
56 direction: Type.Optional(
57 Type.Union([Type.Literal("ltr"), Type.Literal("ttb")]),
58 ),
59 alignX: Type.Optional(u8),
60 alignY: Type.Optional(u8),
61});
62
63const CornerRadius = Type.Object({
64 tl: Type.Optional(u8),
65 tr: Type.Optional(u8),
66 bl: Type.Optional(u8),
67 br: Type.Optional(u8),
68});
69
70const Border = Type.Object({
71 color: rgba,
72 left: Type.Optional(u8),
73 right: Type.Optional(u8),
74 top: Type.Optional(u8),
75 bottom: Type.Optional(u8),
76});
77
78const Clip = Type.Object({
79 horizontal: Type.Optional(Type.Boolean()),
80 vertical: Type.Optional(Type.Boolean()),
81});
82
83const Floating = Type.Object({
84 x: Type.Optional(Type.Number()),
85 y: Type.Optional(Type.Number()),
86 parent: Type.Optional(Type.Integer({ minimum: 0 })),
87 attachTo: Type.Optional(u8),
88 attachPoints: Type.Optional(u8),
89 zIndex: Type.Optional(u16),
90});
91
92/* ── Op types (discriminated on `id`) ─────────────────────────────── */
93
94const CloseElement = Type.Object({ id: Type.Literal(0x04) });
95
96const OpenElement = Type.Object({
97 id: Type.Literal(0x02),
98 name: Type.String(),
99 layout: Type.Optional(Layout),
100 bg: Type.Optional(rgba),
101 cornerRadius: Type.Optional(CornerRadius),
102 border: Type.Optional(Border),
103 clip: Type.Optional(Clip),
104 floating: Type.Optional(Floating),
105});
106
107const TextOp = Type.Object({
108 id: Type.Literal(0x03),
109 content: Type.String(),
110 color: Type.Optional(rgba),
111 fontSize: Type.Optional(u8),
112 fontId: Type.Optional(u8),
113 wrap: Type.Optional(u8),
114 attrs: Type.Optional(u8),
115});
116
117const Ops = Type.Array(Type.Union([OpenElement, TextOp, CloseElement]));
118
119/* ── Compiled validator ───────────────────────────────────────────── */
120
121const compiled = TypeCompiler.Compile(Ops);
122
123export function validate(ops: unknown): ops is Op[] {
124 return compiled.Check(ops);
125}
126
127export function assert(ops: unknown): asserts ops is Op[] {
128 if (!compiled.Check(ops)) {
129 let errors = [...compiled.Errors(ops)];
130 let msg = errors
131 .slice(0, 5)
132 .map((e) => `${e.path}: ${e.message}`)
133 .join("\n");
134 throw new TypeError(`Invalid ops:\n${msg}`);
135 }
136}
137
138/* ── Term wrapper ─────────────────────────────────────────────────── */
139
140export function validated(term: Term): Term {
141 return {
142 render(ops: Op[], options?: RenderOptions): RenderResult {
143 assert(ops);
144 return term.render(ops, options);
145 },
146 };
147}