1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
|
//! The core component contract for the TUI.
//!
//! Dispatch is a vtable of function pointers over `*anyopaque` (decided in
//! plan §4.5; a tagged union was rejected so out-of-tree extensions can define
//! their own components later without editing a central enum).
//!
//! The render engine (later sub-phase) holds a list of `Component`s, asks each
//! to `render(width)` into lines, and uses `firstLineChanged` to do a
//! differential repaint. This file defines only the interface plus a small
//! reusable cache/dirty mixin; it implements no concrete components and no
//! engine.
const std = @import("std");
/// Invisible cursor marker.
///
/// A focused `Focusable` component embeds this marker in its rendered output
/// at the virtual cursor location. It is an APC string (`ESC _ ... ESC \`),
/// which terminals ignore (zero visible width), so it survives in the line
/// buffer until the engine scans for it. In P3 the engine will locate this
/// marker, strip it from the emitted bytes, and position the hardware cursor
/// there; for P1 the marker simply ships and is treated as zero-width.
///
/// The payload `panto-cursor` disambiguates it from any other APC a child
/// component might legitimately emit.
pub const CURSOR_MARKER = "\x1b_panto-cursor\x1b\\";
/// The component vtable. Concrete components store their own state behind
/// `ptr` and provide function pointers that recover the concrete type via
/// `@ptrCast`/`@alignCast`.
///
/// Line / width contract (plan §3.1): every line returned by `render` MUST
/// have visible width <= the `width` argument. The engine validates this and
/// treats overflow as a hard error, so components are responsible for
/// truncating. The returned `[]const []const u8` and its lines are owned by
/// the component (typically backed by its render cache); they must remain
/// valid until the next `render`/`invalidate` call on that component.
pub const Component = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
/// Render the component at `width` columns, returning one slice per
/// visible line. Each line's visible width must be <= `width`.
/// `alloc` is the engine's per-frame/render allocator; whether the
/// component caches into its own storage or allocates fresh each call
/// is up to it, but the returned slices must outlive the call until
/// the next render/invalidate.
render: *const fn (ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8,
/// Lowest component-local line index whose rendered output differs
/// since the last successful render, or null if nothing changed.
///
/// This MUST be derived from the component's render cache (see
/// `RenderCache`), not a separately hand-managed integer that can
/// drift from reality.
firstLineChanged: *const fn (ptr: *anyopaque) ?usize,
/// Drop cached render state, forcing a full re-render and re-dirtying
/// the component.
invalidate: *const fn (ptr: *anyopaque) void,
/// Optional: feed raw input bytes (already routed to this component by
/// the engine) for the component to interpret. Null when the component
/// does not accept input.
handleInput: ?*const fn (ptr: *anyopaque, data: []const u8) void = null,
/// Capability: when true the engine should deliver key *release*
/// events to this component (most components only want press/repeat).
wantsKeyRelease: bool = false,
};
pub fn render(self: Component, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
return self.vtable.render(self.ptr, width, alloc);
}
pub fn firstLineChanged(self: Component) ?usize {
return self.vtable.firstLineChanged(self.ptr);
}
pub fn invalidate(self: Component) void {
self.vtable.invalidate(self.ptr);
}
pub fn handleInput(self: Component, data: []const u8) void {
if (self.vtable.handleInput) |f| f(self.ptr, data);
}
pub fn wantsKeyRelease(self: Component) bool {
return self.vtable.wantsKeyRelease;
}
};
/// The focus contract. A component that can hold focus exposes `focused`,
/// which the engine flips on focus changes. When focused, the component emits
/// `CURSOR_MARKER` at its virtual cursor position inside its `render` output.
///
/// This is a thin embeddable struct: a focusable component contains one and
/// the engine sets `.focused` via the component's own setter (the vtable does
/// not yet carry focus methods — focus routing wiring is part of the engine
/// sub-phase; what ships in P1 is the flag + the marker constant + the
/// documented contract).
pub const Focusable = struct {
focused: bool = false,
pub fn setFocused(self: *Focusable, value: bool) void {
self.focused = value;
}
};
/// Reusable cache + dirty bookkeeping for components.
///
/// firstLineChanged lifecycle (mirrors pi's `invalidate()` model):
/// - State mutation calls `markDirty()` (or `invalidate()`), which clears
/// the cache. While dirty, `firstLineChanged()` reports the recorded
/// dirty line (0 by default — "everything from the top changed").
/// - A *successful* render calls `store(lines)`, which:
/// 1. diffs the new lines against the previously cached lines,
/// 2. records the lowest differing index as the "changed-from" value,
/// 3. replaces the cache with a copy of the new lines,
/// 4. marks the cache clean.
/// - Once the cache is CLEAN, `firstLineChanged()` returns null regardless
/// of the diff `store` recorded. `firstLineChanged()` is the LIVE
/// pre-render dirty signal (the engine reads it once per slot BEFORE
/// `render` to decide whether to re-render and where to cut); a clean
/// component is already up to date on screen and has nothing pending, so
/// it must report null. The recorded diff index is retained separately in
/// `changed_from` as render bookkeeping and is NOT a live signal — exposing
/// it while clean would peg the differential cut to a stale line on every
/// later frame (a first-paint store records `changed_from == 0`, which
/// would otherwise force the cut to line 0 forever).
///
/// Because `firstLineChanged` is computed purely from cache state, it cannot
/// drift from a hand-managed field. Components embed this struct and route
/// their vtable through `cacheRender`/`markDirty`.
///
/// The cache owns its copies of the line bytes (allocated with the allocator
/// passed to `init`); call `deinit` to free them.
pub const RenderCache = struct {
alloc: std.mem.Allocator,
/// Owned copies of the last rendered lines, or null when never rendered or
/// invalidated.
lines: ?[][]u8 = null,
/// Lowest line index that changed at the last `store` (or the pending
/// dirty line while dirty; 0 when dirty with no prior cache). This is
/// render bookkeeping, NOT the live signal: when the cache is clean,
/// `firstLineChanged()` returns null even though this still holds the last
/// diff index. Null only when a clean store found no change.
changed_from: ?usize = null,
dirty: bool = true,
pub fn init(alloc: std.mem.Allocator) RenderCache {
return .{ .alloc = alloc };
}
pub fn deinit(self: *RenderCache) void {
self.freeLines();
}
fn freeLines(self: *RenderCache) void {
if (self.lines) |lines| {
for (lines) |line| self.alloc.free(line);
self.alloc.free(lines);
self.lines = null;
}
}
/// Mark the component dirty (e.g. on any state mutation). Drops the cache
/// so the next render is a full render starting at `from` (default 0).
pub fn markDirty(self: *RenderCache) void {
self.markDirtyFrom(0);
}
/// Mark dirty starting at a specific line. The cache is dropped (we no
/// longer trust any cached line), and `firstLineChanged` will report
/// `from` until the next successful render.
pub fn markDirtyFrom(self: *RenderCache, from: usize) void {
self.dirty = true;
self.changed_from = from;
self.freeLines();
}
/// Mark dirty for an APPEND-style mutation that only affects the tail of
/// the rendered output (e.g. a streaming content delta appended to the
/// end of a buffer).
///
/// Unlike `markDirty`/`markDirtyFrom`, this RETAINS the cached baseline
/// lines so the next `store` can diff against them and recover the TRUE
/// lowest-changed index (which, for an append, is near the tail). While
/// dirty, `firstLineChanged` reports a conservative TAIL HINT — the index
/// of the last cached line (or 0 when there is no baseline yet) — so the
/// engine re-renders and the cut stays near the end rather than rolling to
/// line 0. The post-render diff in `store` then replaces the hint with the
/// exact change point. `firstLineChanged` therefore remains cache-derived:
/// the dirty hint comes from the retained cache's length, and the precise
/// value comes from the diff.
pub fn markDirtyAppend(self: *RenderCache) void {
self.dirty = true;
if (self.lines) |lines| {
// Tail hint: the last existing line is the lowest line an append
// can change. Keep the baseline for the diff in `store`.
self.changed_from = if (lines.len == 0) 0 else lines.len - 1;
} else {
self.changed_from = 0;
}
}
/// Equivalent to `markDirty` — full drop + re-dirty. Provided so a
/// component's `invalidate` vtable entry can forward here verbatim.
pub fn invalidate(self: *RenderCache) void {
self.markDirty();
}
/// Derived dirty signal. Returns the lowest changed line index, or null
/// when the cache is clean and nothing changed at the last store.
pub fn firstLineChanged(self: *const RenderCache) ?usize {
// The engine reads this as the PRE-render dirty signal (once per slot,
// before `render`). A clean cache has no pending change to repaint, so
// it must report null — even though the last `store` recorded a
// `changed_from` diff index. Returning that retained index while clean
// makes an unchanged component spuriously cut the differential repaint
// at its stale diff line on EVERY later frame (notably a first-paint
// store leaves `changed_from == 0`, which would peg the cut to line 0
// forever). The retained `changed_from` is internal render bookkeeping,
// not a live signal; only the dirty state drives a repaint.
if (self.dirty) return self.changed_from orelse 0;
return null;
}
/// Record a successful render. Diffs `new_lines` against the cache,
/// updates `changed_from` to the lowest differing index, copies the new
/// lines into the cache, and marks it clean.
pub fn store(self: *RenderCache, new_lines: []const []const u8) !void {
const diff = self.computeFirstDiff(new_lines);
// Build the new owned copy first; only swap in on success.
var copies = try self.alloc.alloc([]u8, new_lines.len);
var made: usize = 0;
errdefer {
for (copies[0..made]) |c| self.alloc.free(c);
self.alloc.free(copies);
}
for (new_lines, 0..) |line, i| {
copies[i] = try self.alloc.dupe(u8, line);
made = i + 1;
}
self.freeLines();
self.lines = copies;
self.changed_from = diff;
self.dirty = false;
}
/// Lowest index at which `new_lines` differs from the cached lines.
/// Returns null when they are identical. When there is no prior cache (or
/// the line counts differ at index 0), returns 0.
fn computeFirstDiff(self: *const RenderCache, new_lines: []const []const u8) ?usize {
const old = self.lines orelse return 0;
const n = @min(old.len, new_lines.len);
var i: usize = 0;
while (i < n) : (i += 1) {
if (!std.mem.eql(u8, old[i], new_lines[i])) return i;
}
// Common prefix matched; if lengths differ, the first extra/missing
// line index is the change point.
if (old.len != new_lines.len) return n;
return null;
}
};
test "CURSOR_MARKER is an APC string" {
try std.testing.expect(std.mem.startsWith(u8, CURSOR_MARKER, "\x1b_"));
try std.testing.expect(std.mem.endsWith(u8, CURSOR_MARKER, "\x1b\\"));
}
test "RenderCache: starts dirty from 0" {
var c = RenderCache.init(std.testing.allocator);
defer c.deinit();
try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
}
test "RenderCache: store cleans, no-change render returns null" {
var c = RenderCache.init(std.testing.allocator);
defer c.deinit();
const a = [_][]const u8{ "one", "two" };
try c.store(&a);
// store cleans the cache, so the live signal is null (clean => null).
// The recorded diff (first store after empty => 0) lives in changed_from.
try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
try std.testing.expectEqual(@as(?usize, 0), c.changed_from);
// Re-store identical => no change, still clean.
try c.store(&a);
try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
try std.testing.expectEqual(@as(?usize, null), c.changed_from);
}
test "RenderCache: store reports lowest differing line" {
var c = RenderCache.init(std.testing.allocator);
defer c.deinit();
const a = [_][]const u8{ "one", "two", "three" };
try c.store(&a);
const b = [_][]const u8{ "one", "TWO", "three" };
try c.store(&b);
// The diff store computed is recorded in changed_from; the live signal is
// null because the cache is now clean (already rendered on screen).
try std.testing.expectEqual(@as(?usize, 1), c.changed_from);
try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: line-count change reports the boundary" {
var c = RenderCache.init(std.testing.allocator);
defer c.deinit();
const a = [_][]const u8{ "one", "two" };
try c.store(&a);
const b = [_][]const u8{ "one", "two", "three" };
try c.store(&b);
try std.testing.expectEqual(@as(?usize, 2), c.changed_from);
try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: markDirtyAppend keeps baseline and reports a tail hint, diff recovers exact change" {
var c = RenderCache.init(std.testing.allocator);
defer c.deinit();
const a = [_][]const u8{ "l0", "l1", "l2", "l3" };
try c.store(&a);
// No baseline change yet; append-dirty reports the tail hint (last line).
c.markDirtyAppend();
try std.testing.expectEqual(@as(?usize, 3), c.firstLineChanged());
// The baseline is retained for the diff.
try std.testing.expect(c.lines != null);
// A render that only adds a tail line: diff recovers the exact boundary (4),
// NOT 0 — the streaming-tail property. The cache is clean after store, so
// the recorded diff lives in changed_from and the live signal is null.
const b = [_][]const u8{ "l0", "l1", "l2", "l3", "l4" };
try c.store(&b);
try std.testing.expectEqual(@as(?usize, 4), c.changed_from);
try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: markDirtyAppend with no baseline reports 0" {
var c = RenderCache.init(std.testing.allocator);
defer c.deinit();
c.markDirtyAppend();
try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
}
test "RenderCache: markDirty re-dirties from 0" {
var c = RenderCache.init(std.testing.allocator);
defer c.deinit();
const a = [_][]const u8{"x"};
try c.store(&a);
c.markDirty();
try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
}
test "Focusable flips" {
var f: Focusable = .{};
try std.testing.expect(!f.focused);
f.setFocused(true);
try std.testing.expect(f.focused);
}
|