Skip to content

Commit ba2d1bd

Browse files
committed
feat!: make term updates return void
1 parent e7f5b2b commit ba2d1bd

16 files changed

Lines changed: 179 additions & 129 deletions

File tree

‎.changeset/quiet-updates.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@bomb.sh/tty": minor
3+
---
4+
5+
Make `term.update()` return `void`. Call `term.render()` to reflect dimension and
6+
capability changes on screen; multiple updates contribute to the next render.
7+
Disabling pointer shape support preserves the last emitted shape without emitting
8+
a reset. To reset the pointer, render without a pointer while support is enabled.

‎docs/review-patterns.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ before opening a PR. PR numbers point at the original discussion.
3434
overloads that only tests use (#133).
3535
- Names carry constraints: `id` when it must be unique, not `name` (#5).
3636
- Static facts are fixed at `createTerm()`; dynamic changes arrive as events via
37-
`term.update()`, which returns the bytes to write. Don't defer work to the
38-
next `render()` (#106).
37+
`term.update()`. Apply those state changes synchronously; renderer output is
38+
produced by `render()`.
3939
- Front-load configuration at creation so each paint stays one
4040
`write(result.output)` (#101).
4141

‎examples/2048/index.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -392,9 +392,7 @@ await main(function* () {
392392
if (next.width !== width || next.height !== height) {
393393
width = next.width;
394394
height = next.height;
395-
Deno.stdout.writeSync(
396-
term.update([{ type: "resize", width, height }]),
397-
);
395+
term.update([{ type: "resize", width, height }]);
398396
Deno.stdout.writeSync(CLEAR_SCREEN);
399397
draw();
400398
}
@@ -405,9 +403,7 @@ await main(function* () {
405403
let e = ev as InputEvent | PointerEvent;
406404

407405
if (e.type === "capability") {
408-
Deno.stdout.writeSync(term.update([e]));
409-
yield* each.next();
410-
continue;
406+
term.update([e]);
411407
}
412408

413409
if (e.type === "keydown") {

‎examples/cursor/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ await main(function* () {
115115
break;
116116
}
117117
if (event.type === "capability") {
118-
writeStdout(term.update([event]));
118+
term.update([event]);
119119
}
120120
if (event.type === "pointerenter") {
121121
ctx.entered.add(event.id);

‎examples/keyboard/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ await main(function* () {
123123
break;
124124
}
125125
if (event.type === "capability") {
126-
writeStdout(term.update([event]));
126+
term.update([event]);
127127
}
128128
if (event.type === "pointerenter") {
129129
context.entered.add(event.id);

‎examples/text-input/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ await main(function* () {
105105
caret++;
106106
}
107107
} else if (event.type === "capability") {
108-
writeStdout(term.update([event]));
108+
term.update([event]);
109109
} else if ("x" in event) {
110110
pointer = {
111111
x: event.x,

‎pointer-shape.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,3 @@ const INDEX: ReadonlyMap<unknown, number> = new Map(
4848
export function pointerShapeIndex(shape: unknown): number | undefined {
4949
return INDEX.get(shape);
5050
}
51-
52-
export const POINTER_SHAPE_RESET: Uint8Array = new TextEncoder().encode(
53-
"\x1b]22;default\x1b\\",
54-
);

‎specs/renderer-spec.md‎

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -426,11 +426,12 @@ values (see [Input Specification](input-spec.md) §5), discriminated by `type`:
426426
the transaction MUST throw otherwise.
427427
- A `CapabilityEvent` (see [Terminfo Specification](terminfo-spec.md) §6.3) — a
428428
capability value delivered by the input parser from a probe response.
429-
- Any other `InputEvent` — a no-op step. It changes no state and contributes no
430-
bytes.
429+
- Any other `InputEvent` — a no-op step. It changes no state.
431430

432-
The Term folds each event in array order. The returned bytes are the
433-
concatenation of each fold's output. An empty array is a no-op.
431+
The Term folds each event in array order. An empty array is a no-op.
432+
433+
Multiple `update()` calls before the next `render()` accumulate state changes in
434+
call order. That render MUST use the state resulting from all those calls.
434435

435436
A resize event whose target dimensions equal the Term's current dimensions MUST
436437
be a no-op for that step.
@@ -448,15 +449,13 @@ be a no-op for that step.
448449
emitted.
449450

450451
**Capability semantics.** A `CapabilityEvent` step folds the event's `key` and
451-
`value` into the Term's private `RuntimeCapabilities`. The foundation update
452-
transaction emits bytes or alters renderer output as a consequence of a
453-
capability event only where a capability section of this specification says so
454-
(§7.10).
452+
`value` into the Term's private `RuntimeCapabilities`. The updated capabilities
453+
are observable through `term.capabilities` when the call returns. Their effects
454+
on subsequent render transactions are defined by the capability sections of this
455+
specification (§7.8–§7.10).
455456

456-
**Return value.** `update()` returns a `Uint8Array` of bytes to write to the
457-
terminal immediately. An empty array is valid when the update changes no
458-
rendered state (TINV-5). The caller writes the bytes to its output stream before
459-
the next render.
457+
**Return value.** `update()` returns `void`. The caller must invoke `render()`
458+
to see the effects (TINV-5).
460459

461460
**Memory.** WASM linear memory can only grow. An update to smaller dimensions
462461
retains the high-water-mark allocation; memory is not reclaimed until the Term
@@ -558,17 +557,17 @@ When the resolved shape differs, the render MUST append `ESC ] 22 ; <shape> ST`
558557
to `output` after the frame's cell bytes and record the new shape. Otherwise it
559558
MUST NOT emit OSC 22.
560559

561-
**Restore.** OSC 22 set cannot pop, so the Term restores by setting `default`:
562-
563-
- A render that resolves `default` emits it per **Output** above.
564-
- An update step that changes `pointerShape` from `true` to `false` while the
565-
emitted shape is not `default` MUST return `ESC ] 22 ; default ST` and record
566-
`default`.
567-
- A resize step leaves the emitted shape unchanged.
560+
**Updates.** Capability changes and resize MUST leave the recorded last emitted
561+
shape unchanged. While `pointerShape` is `false`, renders MUST NOT emit a shape
562+
change, including a reset to `default`. If the capability becomes `true` again,
563+
the next render compares its resolved shape with the recorded last emitted shape
564+
as defined by **Output** above.
568565

569-
To restore before exit, the caller renders a frame without `pointer`, or passes
570-
`{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and
571-
writes the result.
566+
**Reset to default.** OSC 22 set cannot pop, so the Term resets the pointer by
567+
setting `default`. While the capability is `true`, a render that resolves
568+
`default` emits it per **Output** above. To reset before exit, the caller
569+
renders a frame without `pointer` while the capability is `true` and writes the
570+
result's `output`.
572571

573572
---
574573

@@ -762,7 +761,7 @@ wherever the directive model expects a color.
762761
### 8.6 Term update
763762

764763
```
765-
term.update(events: readonly InputEvent[]): Uint8Array
764+
term.update(events: readonly InputEvent[]): void
766765
```
767766

768767
Performs an update transaction as defined in §7.7. `update()` is the universal
@@ -775,23 +774,26 @@ Every other `InputEvent` is accepted and ignored, so the full `events` array
775774
from `input.scan()` can be passed without filtering. Events are folded in array
776775
order.
777776

778-
**Return value.** `update()` always returns a `Uint8Array`. Write it to the
779-
terminal immediately when non-empty. Do not wait for the next `render()`. An
780-
empty array means the update changed no rendered state.
777+
**Return value.** `update()` returns `void`. The caller must invoke `render()`
778+
to see the effects.
781779

782780
**Host usage.** Events from `input.scan()` pass through directly. Resizes
783781
observed outside the input stream (e.g. `SIGWINCH`) are passed as a constructed
784782
`ResizeEvent`:
785783

786784
```
787785
const { events } = input.scan(bytes);
788-
const out = term.update(events);
789-
if (out.length) stdout.write(out);
786+
term.update(events);
787+
requestRender();
790788
791789
// on SIGWINCH
792790
term.update([{ type: "resize", width: cols(), height: rows() }]);
791+
requestRender();
793792
```
794793

794+
Here `requestRender()` is the application's scheduling hook. Its render pass
795+
supplies directives to `term.render()` and writes the result's `output`.
796+
795797
A resize to the Term's current dimensions is a no-op for that step.
796798

797799
---

‎specs/terminfo-spec.md‎

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ renderer and input parser each receive a copy at construction.
8989
terminal's responses arrive on the input stream, where the input parser
9090
recognizes them and emits `CapabilityEvent` values alongside keys and mouse
9191
events. The host loop routes these events to `term.update()`, which folds them
92-
into the renderer's `RuntimeCapabilities` and returns any bytes to write
93-
immediately.
92+
into the renderer's `RuntimeCapabilities` synchronously. Changes are reflected
93+
in the next `render()` call.
9494

9595
### 4.2 Data flow
9696

@@ -102,16 +102,19 @@ terminfo file bytes ──▶ terminfo_parse() ─▶ Capabilities (frozen)
102102
createInput() createTerm()
103103
│ │
104104
probe bytes ──▶ terminal ──▶ stdin │
105-
scan() ─▶ CapabilityEvent ─▶ update() ─▶ Uint8Array (write now)
105+
scan() ─▶ CapabilityEvent ─▶ update(): void
106106
│
107107
RuntimeCapabilities
108+
│
109+
render(ops).output ─▶ Uint8Array (host writes)
108110
```
109111

110112
- `detectTerminal()` parses raw bytes into `Capabilities`. It performs no IO.
111113
- The input parser is the only source of `CapabilityEvent` values. It writes
112114
nothing; it emits.
113115
- The renderer holds its own private capability state. `update()` is the only
114116
path that changes it.
117+
- Changes made by `update()` are reflected in the next `render()` call.
115118
- The renderer and the input parser share no memory and hold no reference to
116119
each other. The host loop is the only place they meet.
117120

@@ -154,9 +157,8 @@ missing, oversized, or malformed terminfo file, or abort. Environmental
154157
conditions degrade to the §7.1 baseline. The only rejection is caller error
155158
(§10.1).
156159

157-
**TINV-5. Immediate update output.** `term.update()` returns a `Uint8Array` of
158-
bytes to write now. It MUST NOT defer output to the next `render()` call. An
159-
empty array is a valid return when the update changes no rendered state.
160+
**TINV-5. Update effects.** `term.update()` returns `void`. The caller must
161+
invoke `render()` to see the effects.
160162

161163
**TINV-6. Event-only probe surface.** The input parser MUST NOT write to any
162164
shared state when it recognizes a probe response. It surfaces the result as a
@@ -324,10 +326,11 @@ not merge; each response yields its own event.
324326
**OSC 22.** An OSC 22 reply sets `key: "pointer-shape", value: true`. The shape
325327
name in the reply is discarded in v1; only protocol support is recorded. The
326328
renderer emits pointer shapes only while `pointerShape` is `true` (Renderer
327-
Specification §7.10). Terminals that implement OSC 22 set but not its query
328-
never reply; `detectTerminal()` supplies that evidence itself from the
329-
known-support table (§6.1), and a caller may still override it with
330-
`term.update([{ type: "capability", key: "pointer-shape", value: true }])`.
329+
Specification §7.10). Setting `pointerShape` to `false` suppresses pointer-shape
330+
output; it does not reset the pointer to `default`. Terminals that implement OSC
331+
22 set but not its query never reply; `detectTerminal()` supplies that evidence
332+
itself from the known-support table (§6.1), and a caller may still override it
333+
with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`.
331334

332335
**DA1.** The DA1 reply is recognized internally as the probe fence and MUST NOT
333336
surface as a `CapabilityEvent`.
@@ -476,9 +479,10 @@ and mouse events in arrival order. The host loop routes them to `term.update()`.
476479
### 9.3 Capability change over time
477480
478481
A capability event can arrive after the renderer has already emitted frames.
479-
`term.update()` folds it into the runtime snapshot. Any renderer-side output
480-
invalidation or immediate bytes are defined by the renderer section that
481-
consumes that capability (Renderer Specification §7.8).
482+
`term.update()` folds it into the runtime snapshot synchronously. Subsequent
483+
renders consume that snapshot. Any renderer-side output invalidation is defined
484+
by the renderer section that consumes that capability (Renderer Specification
485+
§7.8).
482486
483487
---
484488
@@ -566,18 +570,19 @@ When omitted, the parser uses built-in xterm key sequences.
566570
```ts
567571
interface Term {
568572
render(ops: Op[], options?: RenderOptions): RenderResult;
569-
update(events: readonly InputEvent[]): Uint8Array;
573+
update(events: readonly InputEvent[]): void;
570574
readonly capabilities: RuntimeCapabilities;
571575
}
572576
```
573577

574578
`update()` accepts an array of `InputEvent` values. Events other than
575579
`ResizeEvent` and `CapabilityEvent` are no-op steps, so the full `events` array
576580
from `scan()` can be passed without filtering. Events are folded in order: each
577-
event produces the next `RuntimeCapabilities` and any bytes defined by the
578-
consuming feature, and the returned bytes are concatenated. The foundation
579-
defines no such bytes. The return value is always a `Uint8Array`; callers write
580-
it to their output stream when non-empty (TINV-5).
581+
resize updates the Term's dimensions and each capability event updates its
582+
`RuntimeCapabilities`. These changes take effect before `update()` returns. The
583+
method returns `void`; the caller must invoke `render()` to see the effects
584+
(TINV-5). Multiple calls contribute to the next render as defined in Renderer
585+
Specification §7.7.
581586

582587
`term.capabilities` is a frozen snapshot of the current `RuntimeCapabilities`.
583588

@@ -594,19 +599,28 @@ const input = await createInput({ terminfo });
594599

595600
process.stdout.write(terminfo.probe);
596601

602+
function draw() {
603+
const { output } = term.render(view());
604+
process.stdout.write(output);
605+
}
606+
597607
process.stdin.on("data", (bytes: Uint8Array) => {
598608
const { events } = input.scan(bytes);
599-
const out = term.update(events);
600-
if (out.length) process.stdout.write(out);
609+
term.update(events);
601610
for (const event of events) dispatch(event);
611+
requestRender(draw);
602612
});
603613

604614
process.on("SIGWINCH", () => {
605-
const out = term.update([{ type: "resize", width: cols(), height: rows() }]);
606-
if (out.length) process.stdout.write(out);
615+
term.update([{ type: "resize", width: cols(), height: rows() }]);
616+
requestRender(draw);
607617
});
608618
```
609619

620+
Here `view()`, `dispatch()`, and `requestRender()` belong to the host
621+
application. `requestRender()` schedules the supplied rendering callback; the
622+
host can coalesce requests into one render after a batch of events.
623+
610624
`createTerm` and `createInput` each take `terminfo` and build their own private
611625
state from it. Passing the same `TerminalInfo` to both passes the same plain
612626
value twice. There is no shared memory and no attachment guard.

‎src/clayterm.c‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -772,15 +772,7 @@ void clayterm_resize(struct Clayterm *ct, int w, int h) {
772772
cells_fill(ct->front, w, h, 0, 0, 0);
773773
}
774774

775-
int set_capabilities(struct Clayterm *ct, uint32_t caps) {
776-
int withdrawn = (ct->caps & RENDER_CAP_POINTER_SHAPE) &&
777-
!(caps & RENDER_CAP_POINTER_SHAPE);
778-
int restore = withdrawn && ct->pointer_shape != 0;
779-
if (restore)
780-
ct->pointer_shape = 0;
781-
ct->caps = caps;
782-
return restore;
783-
}
775+
void set_capabilities(struct Clayterm *ct, uint32_t caps) { ct->caps = caps; }
784776

785777
void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
786778
float deltaTime, float px, float py, int pointer) {

0 commit comments

Comments
 (0)