From 3c5fee2c91dd45bc29a5ab03ee27853143b5e066 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 21:32:22 -0500 Subject: [PATCH 01/29] =?UTF-8?q?=F0=9F=93=9D=20add=20transitions=20design?= =?UTF-8?q?=20specification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design spec for clayterm transitions: frame-snapshot-compatible interpolation of element position, size, and color properties. Defines the deltaTime convention, the animating signal on RenderResult, declarative enter/exit semantics that replace Clay's function-pointer callbacks, and cancellation as a structural consequence of re-describing state. Implementation is gated on bumping the Clay submodule past the upstream transition commit. --- specs/transitions-spec.md | 658 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 658 insertions(+) create mode 100644 specs/transitions-spec.md diff --git a/specs/transitions-spec.md b/specs/transitions-spec.md new file mode 100644 index 0000000..494dd79 --- /dev/null +++ b/specs/transitions-spec.md @@ -0,0 +1,658 @@ +# Clayterm Transitions Specification + +**Version:** 0.1 (draft) **Status:** Design specification for a not-yet-implemented +feature. Normative where it establishes invariants and contract. Descriptive +where surfaces may settle during implementation. + +--- + +## 1. Purpose + +A transition smoothly interpolates an element's visual properties over time. +This specification defines how transitions integrate with Clayterm's frame-snapshot +rendering model: how they are declared, how time is supplied, how enter and +exit behaviors are expressed, and how callers observe in-flight animation so +they can drive the render loop. + +Transitions are a first-class extension of the rendering contract defined in +the [Clayterm Renderer Specification](renderer-spec.md). They do not change +the architectural model, do not introduce a component tree, and do not require +callers to hold cross-frame identity beyond the stable element identifiers they +already use. + +--- + +## 2. Scope + +### In scope (normative) + +- The transition model and its relationship to the frame-snapshot rendering contract +- Time handling and the `deltaTime` convention +- The animating signal returned from `render()` +- The declarative enter and exit model (no callbacks across the WASM boundary) +- Element identity requirements for transitions +- Cancellation semantics (as a consequence of the frame-snapshot model) + +### In scope (non-normative, descriptive) + +- The shape of the `transition` field on the `open()` directive (shorthand and longhand) +- The set of easing functions exposed in the initial surface +- The wire encoding of transition data in the directive buffer +- Interaction with line mode +- Testing strategy + +### Out of scope + +- Custom (JavaScript-authored) easing functions. Reserved for a future extension; + the enum space is designed not to preclude them. +- Proportional reversal (CSS-style dynamic shortening of duration when a + transition is cancelled mid-flight). +- Physics-based animation, spring interpolation, or keyframe sequences. +- Any framework-level concept of "animation groups," "timelines," or choreography + across multiple elements. Orchestration is a caller concern. +- Input parsing (see [Input Specification](input-spec.md)). + +--- + +## 3. Terminology + +**Transition.** A time-based interpolation of one or more of an element's +visual properties between an initial value and a target value. + +**Transition property.** A specific visual attribute of an element that can be +interpolated: position (x, y), size (width, height), background color, overlay +color, border color, border width, or corner radius. + +**Easing.** A function mapping normalized progress in [0, 1] to an eased value +in [0, 1]. Clayterm exposes a fixed set of built-in easings. + +**Transition state.** One of four modes an element can be in with respect to a +given transition: idle, entering (element newly mounted), transitioning +(property target changed on an existing element), or exiting (element removed +from the tree but still being animated out). + +**Enter transition.** The animation played when an element first appears in the +directive tree. Its initial state is derived from the element's target state +by applying caller-supplied deltas (e.g., offset position, transparent color). + +**Exit transition.** The animation played when an element disappears from the +directive tree. Its final state is derived from the element's last-seen state +by applying caller-supplied deltas. The element is still rendered during its +exit even though it is no longer in the directive tree. + +**Delta time (`deltaTime`).** The number of seconds elapsed since the previous +render transaction. Used by the renderer to advance interpolation. + +**Animating signal.** A boolean flag in the render result indicating whether +any transition is currently in progress. Callers use it to decide whether to +schedule another frame. + +--- + +## 4. Architectural Model + +_This section is normative._ + +### 4.1 Relationship to the frame-snapshot model + +Transitions do not alter the frame-snapshot contract defined in INV-3 of the +renderer specification. The directive array still fully describes the desired +state for its frame. Transitions interpolate between the previous frame's +state and the current frame's target state; they do not reintroduce a +persistent component tree on the caller side. + +What transitions add is the requirement that element identifiers remain stable +across frames for any element on which animation is desired. This is not a new +invariant — the existing pointer-event subsystem already relies on stable +identifiers — but it becomes load-bearing for transitions. + +### 4.2 Time ownership + +The `Term` instance is the sole source of frame-to-frame time. On each +`render()` call, the Term reads a monotonic clock and computes the elapsed +seconds since the previous render. That value is passed to the layout engine +to advance any in-flight transitions. + +The caller MAY override the computed delta via an explicit `deltaTime` option +on `render()`. Use cases include deterministic testing, snapshot rendering, +and compute-only renders where the caller is querying bounds without +displaying output. + +The Term MUST NOT use a non-monotonic clock (e.g., `Date.now()`). Wall-clock +time can move backward under NTP adjustments or DST, which would produce +negative deltas and corrupt interpolation. + +### 4.3 Delta clamping + +Clayterm does not clamp `deltaTime`. Long gaps between frames (process +suspension, backgrounded terminal, debugger pause) produce large deltas. The +underlying interpolation is duration-based and naturally clamps at 1.0 of +progress, so a large delta causes in-flight transitions to complete rather +than to overshoot or become unstable. + +This differs from physics-based engines, which clamp deltas to prevent +tunneling. Transitions as specified here are not physics-based, so clamping +is unnecessary. + +### 4.4 Animation-loop signaling + +The render result MUST surface whether any transition is currently active. +Callers use this signal to schedule the next frame. When no transition is +active, callers may stop rendering until the next external event (input, +resize, application state change). + +This requirement exists because terminal applications typically render +on-demand rather than at a fixed refresh rate. Without an explicit animating +signal, a caller has no way to know that a transition it triggered is still +in progress. + +### 4.5 Boundary preservation + +Transitions MUST NOT require function pointers, callbacks, or other +non-serializable values to cross the TS→WASM boundary. Easing and +enter/exit initial-state computation are implemented on the C side using +declarative configuration carried in the directive buffer. + +This preserves INV-2 (single transaction per frame): one binary buffer in, +one result struct out. + +--- + +## 5. Core Invariants + +_This section is normative._ + +**INV-T1. Time is driven by delta, not wall clock.** All transition +interpolation advances by `deltaTime`, a per-frame seconds value. The +renderer does not subscribe to an internal timer or schedule work of its own. + +**INV-T2. Render remains pure under time override.** When the caller supplies +an explicit `deltaTime`, the render result depends only on the directive +array, the previous frame's cell buffer, and the supplied `deltaTime`. This +makes deterministic rendering possible for tests and snapshots. + +**INV-T3. No callbacks across the boundary.** Transition configuration MUST +be fully serializable. No function pointers, closures, or callback registries +cross the TS→WASM boundary during a render transaction. + +**INV-T4. Identity is drawn from element IDs.** Transition state is associated +with elements by their declared `id`. Callers using transitions on an element +MUST assign it a stable, unique `id` across frames. Reusing an `id` for a +different logical element in a later frame is a caller error; behavior is +unspecified. + +**INV-T5. Animating signal is accurate per transaction.** The `animating` +flag returned by `render()` reflects the state of transitions as of the end +of that transaction. If it is `true`, at least one transition has non-zero +remaining progress and calling `render()` again with positive `deltaTime` +will advance it. + +**INV-T6. Cancellation is structural.** There is no imperative `cancel()` +API. Transitions are cancelled by re-describing the previous target in a +later frame; the transition infrastructure re-anchors the interpolation from +the current visible value to the new target. + +--- + +## 6. Rendering Contract Additions + +_This section is normative._ + +### 6.1 `render()` signature + +The `render()` method accepts an optional `deltaTime` field in its options +argument: + +``` +render(ops: Op[], options?: RenderOptions): RenderResult + +interface RenderOptions { + mode?: "line"; + row?: number; + pointer?: { x, y, down }; + deltaTime?: number; // seconds; overrides Term's internal clock +} +``` + +Each `render()` call advances transitions by its `deltaTime`: + +- If `deltaTime` is omitted, Term computes it as the monotonic wall-clock + time elapsed since the previous `render()` call. +- If `deltaTime` is provided, it is used verbatim for that frame. + +On every `render()` call, Term captures the current monotonic timestamp as +the reference point for the next implicit delta. The two modes can be +freely mixed, but mixing within a single session is primarily useful for +tests that step time manually and should otherwise be avoided. + +### 6.2 `RenderResult` addition + +The render result gains one field: + +``` +interface RenderResult { + output: Uint8Array; + events: PointerEvent[]; + info: RenderInfo; + errors: ClayError[]; + animating: boolean; // NEW +} +``` + +`animating` is `true` if and only if at least one element has an in-flight +transition at the end of the transaction. + +### 6.3 The `transition` field on `open()` + +An element may declare a transition by adding a `transition` field to its +open-element directive. The field is optional. Its absence means the element +has no transitions, which is the default. + +The field accepts either shorthand or longhand form (Section 7). + +--- + +## 7. Declarative Transition Surface + +_This section is descriptive. The shapes may be revised during implementation, +but the architectural commitments above do not change._ + +### 7.1 Shorthand form + +All listed properties share one duration and one easing: + +```ts +open("sidebar", { + layout: { width: fixed(20) }, + bg: rgba(30, 30, 30, 255), + transition: { + duration: 0.2, + easing: easeOut(), + properties: ["x", "width", "bg"], + }, +}) +``` + +### 7.2 Longhand form + +Each property declares its own duration and easing independently: + +```ts +open("sidebar", { + transition: [ + { property: "x", duration: 0.3, easing: easeInOut() }, + { property: "width", duration: 0.3, easing: easeInOut() }, + { property: "bg", duration: 0.15, easing: easeOut() }, + ], +}) +``` + +The shorthand form is expanded to longhand during directive packing. The wire +encoding carries only longhand. + +### 7.3 Extended form (enter, exit, interaction handling) + +```ts +open("toast", { + transition: { + properties: [ + { property: "y", duration: 0.25, easing: easeOut() }, + { property: "bg", duration: 0.15, easing: linear() }, + ], + enter: { + independently: false, + from: { y: -2, bg: rgba(0, 0, 0, 0) }, + }, + exit: { + independently: false, + to: { y: -2, bg: rgba(0, 0, 0, 0) }, + paintOrder: "natural", + }, + interactive: false, + }, +}) +``` + +**`enter.from`** declares deltas relative to the element's target state. The +initial state used by the enter transition is `target + from`. A missing +`from` entry for a given property means the enter transition starts at the +target value for that property (no visible animation on that axis). + +**`exit.to`** declares deltas relative to the element's last-seen state. +The final state used by the exit transition is `initial + to`. + +**`enter.independently` / `exit.independently`** (default `false`) control +whether the element's enter/exit plays when its parent is also entering or +exiting in the same frame. The default couples the element to its parent: +child elements do not play their own enter/exit when the parent is itself +entering or exiting (this prevents cascaded animations when an entire +container mounts or unmounts). Setting `independently: true` opts in to +playing the animation unconditionally. + +**`exit.paintOrder`** controls how an exiting element is drawn relative to +its reflowing siblings during the exit animation. One of: + +- `"natural"` (default) — paints in the element's natural DOM order. +- `"underSiblings"` — paints beneath siblings; reflowing neighbors cover the + exiting element. +- `"overSiblings"` — paints on top of siblings; the exiting element remains + visually prominent until its animation completes. + +**`interactive`** (default `false`) — when `false`, pointer interactions +with the element are disabled while a position transition is in progress. +When `true`, pointer interactions remain enabled throughout position +transitions. + +### 7.4 Easing helpers + +Exported from the top-level module: + +```ts +linear() +easeIn() +easeOut() +easeInOut() +cubicBezier(x1: number, y1: number, x2: number, y2: number) +``` + +Each returns an `Easing` value: a tagged byte with optional parameters. The +easing enum space is deliberately larger than the current surface to allow +future additions (including a potential `custom()` form that bridges to a +JavaScript function) without breaking serialized frames. + +### 7.5 Property names + +```ts +type TransitionProperty = + | "x" | "y" | "position" + | "width" | "height" | "size" + | "bg" | "overlay" | "borderColor" + | "cornerRadius" | "borderWidth" + | "all"; +``` + +Group names (`position`, `size`, `all`) expand to the underlying property +set during packing and are equivalent to listing the constituent properties +explicitly in longhand form. + +--- + +## 8. Wire Encoding + +_This section is descriptive._ + +The transition block is a new optional tagged section on `OP_OPEN_ELEMENT`. +Its presence is indicated in the element's property bitmask (existing +mechanism for optional fields). When present, its layout is: + +``` +transition_block { + flags: u8 // bit 0: enter present + // bit 1: exit present + // bit 2: interactive (0 = disabled, 1 = enabled) + entry_count: u8 // number of property_transition entries + entries: property_transition[] // entry_count entries, in stable property order + enter?: transition_side // present iff flags bit 0 + exit?: transition_side // present iff flags bit 1 +} + +property_transition { + property: u16 // single-bit mask from Clay's property enum + duration: f32 // seconds, non-negative + easing: u8 // easing kind + params: f32[0 or 4] // 4 floats iff easing == cubicBezier +} + +transition_side { + flags: u8 // bit 0: independently + // bits 1-2: paintOrder (exit only: 0 natural, 1 under, 2 over) + mask: u16 // which properties have deltas + values: bytes // packed in stable property order; widths per property +} +``` + +Value widths are property-specific: `f32` for position and size, `u32` for +colors, `u8[4]` for border widths, `u8[4]` for corner radii (8-bit +resolution per corner is consistent with the existing cornerRadius +encoding). + +The shorthand form is never present on the wire. TS fans shorthand out to +per-property longhand entries before packing. The C side sees only longhand. + +### 8.1 Validation + +The existing `validate()` utility gains checks: + +- `duration >= 0` for every entry. +- `easing` is one of the defined enum values. +- Property names in entries are valid and appear at most once. +- Property names in `enter.from` / `exit.to` are a subset of the entries + (deltas for a property not being transitioned are ignored or flagged). + +--- + +## 9. Cancellation Semantics + +_This section is normative._ + +A caller cancels an in-flight transition by emitting a new frame whose +directive for that element describes a different target state. The +transition infrastructure re-anchors the interpolation: + +- The new `initial` value becomes the element's currently-visible value. +- `elapsedTime` resets to zero. +- The new `target` is the value declared in the current frame. + +The transition duration is unchanged. A cancelled-and-reversed transition +takes its full configured duration regardless of how far it had progressed +at the time of cancellation. + +There is no `term.cancelTransition(id)` call. The frame-snapshot model +makes cancellation a structural consequence of re-describing the desired +state rather than an imperative operation. + +--- + +## 10. Interaction with Line Mode + +_This section is descriptive; the concrete behavior will be finalized +during implementation._ + +Line mode emits cells as newline-separated rows without absolute cursor +positioning. Position transitions (`x`, `y`) have no meaningful effect in +this mode: the rendering output places each row at the current cursor, +not at absolute coordinates. + +Expected behavior in line mode: + +- Color and size transitions proceed normally. +- Position transitions are silently skipped (treated as if the property is + not being transitioned for that frame). +- Enter/exit transitions that declare `from` or `to` deltas on position + properties have those position deltas dropped; other delta properties + still apply. + +The `animating` signal reports accurately regardless of mode; line-mode +color or size transitions still report as animating. + +--- + +## 11. Testing Strategy + +_This section is descriptive._ + +The `deltaTime` override enables deterministic, snapshot-friendly tests. +A test sequence looks like: + +```ts +term.render(opsA, { deltaTime: 0 }); +term.render(opsB, { deltaTime: 0 }); // target change, no time elapsed +term.render(opsB, { deltaTime: 0.1 }); // 50% through a 0.2s transition +term.render(opsB, { deltaTime: 0.1 }); // 100%, completed +``` + +Test coverage should include, at minimum: + +- Shorthand and longhand produce identical output for equivalent configs. +- Enter transitions with `independently: true` and `false`. +- Exit transitions with each `paintOrder` value. +- Cancellation: target change mid-flight re-anchors initial to current. +- Re-appearance during an exit transition. +- Transition config present one frame and absent the next. +- Multiple concurrent transitions on a single element (longhand). +- Multiple concurrent transitions on multiple elements. +- Line mode rendering: color and size transitions apply, position transitions + are silently skipped. + +--- + +## 12. Implementation Notes + +_This section is descriptive and may change without affecting contract._ + +### 12.1 Clay submodule version + +clayterm currently pins Clay at commit `76ec363`. The transition API was +introduced upstream in commit `ee192f4`, with follow-up bug fixes. Before +implementing transitions, the Clay submodule must be advanced to a post- +`ee192f4` commit. Non-transition Clay changes introduced between the current +pin and the target pin — notably the `Clay_OnHover` signature change and the +element ID scheme split — require an audit of existing clayterm integration. + +Upgrading Clay is a prerequisite and should be treated as its own commit +ahead of transition work. + +### 12.2 Handler architecture + +Each `Term` registers a single C-side transition handler with Clay. +Per-element transition metadata (per-property duration, easing, easing +params, enter deltas, exit deltas) is stored in a side table keyed by +Clay element ID, owned by the Term's context. + +The handler: + +1. Resolves the active Term context. +2. Looks up metadata for the element by its Clay ID. +3. For each property in the active bitmask, computes local progress as + `clamp(elapsedTime / property.duration, 0, 1)`, applies the property's + easing, writes the interpolated value into the output struct. +4. Increments the Term context's `animating_count`. +5. Returns `true` if any property's local progress is below 1.0. + +At the start of each `render()`, the Term resets its `animating_count` to +zero. At the end, the value is copied into the result struct as the +`animating` flag (true if count > 0). + +The `setInitialState` and `setFinalState` callbacks Clay expects are +implemented as fixed C functions that apply the per-element `from` / `to` +deltas from the side table to the target / initial state Clay passes in. + +### 12.3 Per-element storage lifetime + +Metadata is repopulated each frame during directive unpacking. Clay's +handler is invoked synchronously inside `Clay_EndLayout`, so per-frame +metadata remains valid when the handler fires. No metadata needs to persist +across frames on our side; Clay's internal hashmap persists the actual +transition state (elapsed time, current value, state machine phase). + +### 12.4 Multiple Term instances + +`animating_count` and the metadata side table live on the Term's C-side +context, not as module-level state. Multiple Terms created in the same +process remain isolated. + +--- + +## 13. Open Questions + +These items remain undecided and will be resolved during implementation. +They do not affect the contract. + +### 13.1 First-frame delta + +On the very first `render()` after `createTerm()`, there is no previous +frame to compute a delta against. Clay's own behavior on its first +`Clay_EndLayout(deltaTime)` call (with a non-zero delta) is the source of +truth: clayterm will pass through whatever delta it has computed and adopt +whatever Clay does. Verification and documentation occur during +integration. + +### 13.2 Mid-transition target change + +The cancellation semantics in Section 9 require that a target change +mid-flight re-anchors `initial` to the current visible value. Clay's +`TRANSITIONING` state machine is expected to handle this, but it must be +verified. If Clay does not re-anchor, our handler adds the logic by +tracking the last-seen target per element. + +### 13.3 Element re-appearance mid-exit + +If an element is exiting and reappears in the next frame's directives, +the expected behavior is to cancel the exit and interpolate from the +current visible state to the new target. Implementation-dependent on Clay. + +### 13.4 Transition removed mid-flight + +If an element has a transition one frame and the `transition` field is +absent in the next frame, Clay's behavior for in-flight transitions +determines the outcome. Two reasonable options: (a) in-flight transitions +complete using their original config; (b) they freeze at their current +value. Deferred to Clay's observed behavior. Documented once verified. + +### 13.5 Custom easing escape hatch + +The easing enum space is deliberately larger than the initial surface. A +future `custom()` easing that bridges to a JavaScript function is +anticipated but not specified here. Its design must preserve INV-T3 +(no callbacks across the boundary during a render transaction) — likely +via a pre-sampled lookup table supplied in the directive buffer. + +--- + +## 14. Demos + +Two demos accompany the feature: + +1. **`demo/transitions.ts`** — a clayterm-native demo meaningfully + exercising transitions in a terminal context (e.g., a collapsing + sidebar, a list reorder, or a toast notification). Primary purpose: + surface real-world sharp edges in the API. + +2. **A reproduction of Clay's upstream `raylib-transitions` demo** — + the example that accompanied the Clay transition-API commit + (`ee192f4`). Primary purpose: provide a reference implementation + that can be visually compared to upstream, validating that the + clayterm integration faithfully exercises the full transition API + surface. + +--- + +## Appendix A. Relationship to the Renderer Specification + +This specification extends, but does not modify, the renderer specification. +Specifically: + +- **INV-1 (Zero IO).** Transitions introduce reading of a monotonic clock + for `deltaTime` computation. A clock read is not terminal IO and does + not violate this invariant. The renderer still produces bytes only; it + does not read or write terminals. + +- **INV-2 (Single transaction per frame).** Transitions preserve this. + All transition configuration is serialized into the single directive + buffer; no additional boundary crossings occur during rendering. + +- **INV-3 (Frame-snapshot independence).** Transitions preserve this at + the API level. Each directive array still fully describes the desired + state. Element IDs carry more weight (Section 4.1) but callers do not + acquire new cross-frame bookkeeping responsibilities. + +- **INV-4 (ANSI byte output).** Unchanged. + +- **INV-5 (Layout/render/diff ownership).** The renderer additionally + owns transition interpolation. Interpolated values feed into the + existing layout and diff pipeline at the same pipeline stage that + resolved values would. + +The "Deferred/Future Areas" section of the renderer specification should +be updated to remove transitions from its list and to reference this +specification. From f7bf4131ad1b29c82d6de4fd2cbe74805442a646 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 21:50:04 -0500 Subject: [PATCH 02/29] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20bump=20Clay=20submod?= =?UTF-8?q?ule=20to=20latest=20main=20(transitions=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clay | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clay b/clay index 76ec363..0896380 160000 --- a/clay +++ b/clay @@ -1 +1 @@ -Subproject commit 76ec3632d80c145158136fd44db501448e7b17c4 +Subproject commit 08963800a4db8a6980bf0f130f2e33ba88b096c4 From 5b4216fea6cac4d1b1c0989a7b6c24782cefcec4 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 21:51:32 -0500 Subject: [PATCH 03/29] =?UTF-8?q?=F0=9F=94=A7=20adapt=20clayterm=20to=20ne?= =?UTF-8?q?w=20Clay=20signatures=20(OpenTextElement,=20EndLayout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/clayterm.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/clayterm.c b/src/clayterm.c index 2af9afd..069d105 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -577,7 +577,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { /* attrs byte -> alpha channel for render_text to extract */ config.textColor.a = (float)((cfg >> 24) & 0xff); - Clay__OpenTextElement(text, Clay__StoreTextElementConfig(config)); + Clay__OpenTextElement(text, config); break; } @@ -590,7 +590,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { } } - Clay_RenderCommandArray cmds = Clay_EndLayout(); + Clay_RenderCommandArray cmds = Clay_EndLayout(0.0f); /* reset output state */ ct->out.length = 0; From 272acd0b1fb910c0c472b23f6768219aa215f0e2 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 21:53:49 -0500 Subject: [PATCH 04/29] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20pin=20Clay=20to=2093?= =?UTF-8?q?8967a=20(work=20around=20upstream=20CLAY=5FWASM=5FEXPORT=20typo?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clay | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clay b/clay index 0896380..938967a 160000 --- a/clay +++ b/clay @@ -1 +1 @@ -Subproject commit 08963800a4db8a6980bf0f130f2e33ba88b096c4 +Subproject commit 938967ac9a62d3115bc25f8e4827cd46567f4bca From 04ae09cec7784b948e62f8bdc80c0c5cc2de62ec Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 22:28:12 -0500 Subject: [PATCH 05/29] =?UTF-8?q?=E2=9C=A8=20add=20deltaTime=20parameter?= =?UTF-8?q?=20to=20reduce()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/clayterm.c | 4 ++-- src/clayterm.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/clayterm.c b/src/clayterm.c index 069d105..59faabe 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -467,7 +467,7 @@ struct Clayterm *init(void *mem, int w, int h) { return ct; } -void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime) { int i = 0; ct->error_count = 0; @@ -590,7 +590,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { } } - Clay_RenderCommandArray cmds = Clay_EndLayout(0.0f); + Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); /* reset output state */ ct->out.length = 0; diff --git a/src/clayterm.h b/src/clayterm.h index 5065ed5..701c890 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -12,7 +12,7 @@ struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); -void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row); +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); void measure(int ret, int txt); From db1a1243311072031b01bdcbdddf49a21c0b838e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 22:28:59 -0500 Subject: [PATCH 06/29] =?UTF-8?q?=F0=9F=94=A7=20add=20deltaTime=20to=20Nat?= =?UTF-8?q?ive.reduce=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- term-native.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/term-native.ts b/term-native.ts index 40e646d..370cabc 100644 --- a/term-native.ts +++ b/term-native.ts @@ -20,7 +20,7 @@ export interface Native { memory: WebAssembly.Memory; statePtr: number; opsBuf: number; - reduce(ct: number, buf: number, len: number, mode: number, row: number): void; + reduce(ct: number, buf: number, len: number, mode: number, row: number, deltaTime: number): void; output(ct: number): number; length(ct: number): number; setPointer(x: number, y: number, down: boolean): void; @@ -75,6 +75,7 @@ export async function createTermNative( len: number, mode: number, row: number, + deltaTime: number, ): void; output(ct: number): number; length(ct: number): number; From 00db8aa25d3e2c8004c2793ac4733b51dc6d55d8 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 22:30:12 -0500 Subject: [PATCH 07/29] =?UTF-8?q?=E2=9C=A8=20track=20deltaTime=20on=20Term?= =?UTF-8?q?,=20accept=20deltaTime=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- term.ts | 14 +++++++++++++- test/transitions.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 test/transitions.test.ts diff --git a/term.ts b/term.ts index 12517d0..b727820 100644 --- a/term.ts +++ b/term.ts @@ -25,6 +25,7 @@ export interface RenderOptions { y: number; down: boolean; }; + deltaTime?: number; } export type PointerEvent = @@ -78,13 +79,24 @@ export async function createTerm(options: TermOptions): Promise { let prev = new Set(); let pressed = new Set(); let wasDown = false; + let lastRenderAt: number | undefined; return { render(ops: Op[], options?: RenderOptions): RenderResult { let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); let mode = options?.mode === "line" ? 1 : 0; let row = options?.row ?? 1; - native.reduce(statePtr, opsBuf, len, mode, row); + let now = performance.now() / 1000; + let dt: number; + if (options?.deltaTime !== undefined) { + dt = options.deltaTime; + } else if (lastRenderAt === undefined) { + dt = 0; + } else { + dt = now - lastRenderAt; + } + lastRenderAt = now; + native.reduce(statePtr, opsBuf, len, mode, row, dt); if (options?.pointer) { let { x, y, down } = options.pointer; diff --git a/test/transitions.test.ts b/test/transitions.test.ts new file mode 100644 index 0000000..5bf578a --- /dev/null +++ b/test/transitions.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "./suite.ts"; +import { close, createTerm, grow, open, text } from "../mod.ts"; + +describe("deltaTime", () => { + it("accepts explicit deltaTime without throwing", async () => { + let term = await createTerm({ width: 40, height: 10 }); + let result = term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi"), + close(), + ], { deltaTime: 0.016 }); + expect(result.output).toBeInstanceOf(Uint8Array); + }); +}); From e486d56bfdf48b486a19dae4e0ecf67425a6d4e9 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 22:35:40 -0500 Subject: [PATCH 08/29] =?UTF-8?q?=E2=9C=A8=20add=20animating=5Fcount=20to?= =?UTF-8?q?=20Clayterm=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/clayterm.c | 4 ++++ src/clayterm.h | 1 + 2 files changed, 5 insertions(+) diff --git a/src/clayterm.c b/src/clayterm.c index 59faabe..b871526 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -51,6 +51,7 @@ struct Clayterm { /* error collection */ Clay_ErrorData errors[MAX_ERRORS]; int error_count; + int animating_count; }; /* Memory layout inside the arena provided by the host: @@ -470,6 +471,7 @@ struct Clayterm *init(void *mem, int w, int h) { void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime) { int i = 0; ct->error_count = 0; + ct->animating_count = 0; Clay_BeginLayout(); @@ -644,6 +646,8 @@ char *output(struct Clayterm *ct) { return ct->out.data; } int length(struct Clayterm *ct) { return ct->out.length; } +int animating(struct Clayterm *ct) { return ct->animating_count; } + int get_element_bounds(const char *name, int name_len, float *out) { Clay_String str = {.length = name_len, .chars = name}; Clay_ElementId eid = Clay__HashString(str, 0); diff --git a/src/clayterm.h b/src/clayterm.h index 701c890..4e7845e 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -15,6 +15,7 @@ struct Clayterm *init(void *mem, int w, int h); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); +int animating(struct Clayterm *ct); void measure(int ret, int txt); int get_element_bounds(const char *name, int name_len, float *out); From 7b3afcb46c8f10341c68e8766520ac620c183e6b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 22:36:28 -0500 Subject: [PATCH 09/29] =?UTF-8?q?=F0=9F=94=A7=20expose=20animating()=20via?= =?UTF-8?q?=20Native=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- term-native.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/term-native.ts b/term-native.ts index 370cabc..78d850f 100644 --- a/term-native.ts +++ b/term-native.ts @@ -26,6 +26,7 @@ export interface Native { setPointer(x: number, y: number, down: boolean): void; getPointerOverIds(): string[]; getElementBounds(id: string): BoundingBox | undefined; + animating(ct: number): number; errorCount(ct: number): number; errorType(ct: number, index: number): number; errorMessage(ct: number, index: number): string; @@ -84,6 +85,7 @@ export async function createTermNative( pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; get_element_bounds(name: number, len: number, out: number): number; + animating(ct: number): number; error_count(ct: number): number; error_type(ct: number, index: number): number; error_message_length(ct: number, index: number): number; @@ -111,6 +113,7 @@ export async function createTermNative( reduce: ct.reduce, output: ct.output, length: ct.length, + animating: ct.animating as Native["animating"], setPointer(x: number, y: number, down: boolean) { let view = new DataView(memory.buffer); view.setFloat32(opsBuf, x, true); From 1aa74a38cb7adcdb43f52c6bdc1e546a8603d731 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 22:37:22 -0500 Subject: [PATCH 10/29] =?UTF-8?q?=E2=9C=A8=20surface=20animating:=20boolea?= =?UTF-8?q?n=20on=20RenderResult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- term.ts | 3 ++- test/transitions.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/term.ts b/term.ts index b727820..74a66ea 100644 --- a/term.ts +++ b/term.ts @@ -65,6 +65,7 @@ export interface RenderResult { events: PointerEvent[]; info: RenderInfo; errors: ClayError[]; + animating: boolean; } export interface Term { @@ -164,7 +165,7 @@ export async function createTerm(options: TermOptions): Promise { }); } - return { output, events, info, errors }; + return { output, events, info, errors, animating: native.animating(statePtr) > 0 }; }, }; } diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 5bf578a..184db3f 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -12,3 +12,15 @@ describe("deltaTime", () => { expect(result.output).toBeInstanceOf(Uint8Array); }); }); + +describe("animating", () => { + it("reports animating=false for a static frame", async () => { + let term = await createTerm({ width: 40, height: 10 }); + let result = term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi"), + close(), + ]); + expect(result.animating).toBe(false); + }); +}); From 0be2409f146465e24bfdd3935a6d7844b983a67c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 11:14:22 -0500 Subject: [PATCH 11/29] =?UTF-8?q?=F0=9F=93=9D=20rewrite=20transitions=20sp?= =?UTF-8?q?ec=20for=20v1=20(Clay-supported=20subset)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope v1 to what Clay currently supports without userData on transition callbacks: one duration and one easing per element, applied to all listed properties. Drop per-property longhand, enter/exit deltas, cubicBezier, and corner radius — each with an explicit "Deferred Until Upstream Clay" entry in §13 referencing nicbarker/clay#603 and the forthcoming exit-flag work. Easings are plain string literals ("linear" | "easeIn" | "easeOut" | "easeInOut") since v1 has no parametric easings. --- specs/transitions-spec.md | 566 ++++++++++++++++---------------------- 1 file changed, 240 insertions(+), 326 deletions(-) diff --git a/specs/transitions-spec.md b/specs/transitions-spec.md index 494dd79..1c957d4 100644 --- a/specs/transitions-spec.md +++ b/specs/transitions-spec.md @@ -1,6 +1,6 @@ # Clayterm Transitions Specification -**Version:** 0.1 (draft) **Status:** Design specification for a not-yet-implemented +**Version:** 0.1 (draft) **Status:** Design specification for a work-in-progress feature. Normative where it establishes invariants and contract. Descriptive where surfaces may settle during implementation. @@ -8,17 +8,25 @@ where surfaces may settle during implementation. ## 1. Purpose -A transition smoothly interpolates an element's visual properties over time. -This specification defines how transitions integrate with Clayterm's frame-snapshot -rendering model: how they are declared, how time is supplied, how enter and -exit behaviors are expressed, and how callers observe in-flight animation so -they can drive the render loop. +A transition smoothly interpolates an element's visual properties over time +when they change between frames. This specification defines how transitions +integrate with Clayterm's frame-snapshot rendering model: how they are +declared, how time is supplied, and how callers observe in-flight animation +so they can drive the render loop. Transitions are a first-class extension of the rendering contract defined in the [Clayterm Renderer Specification](renderer-spec.md). They do not change -the architectural model, do not introduce a component tree, and do not require -callers to hold cross-frame identity beyond the stable element identifiers they -already use. +the architectural model, do not introduce a component tree, and do not +require callers to hold cross-frame identity beyond the stable element +identifiers they already use. + +This specification covers what clayterm ships against the current upstream +Clay layout engine. Several capabilities that the rendering model naturally +invites — per-property easing, per-element enter/exit behaviors, custom +bezier easings — are intentionally excluded from v1 because the underlying +Clay API cannot express them without upstream changes that are still in +flight. Section 13 records these deferrals and the upstream dependencies +that unblock them. --- @@ -26,31 +34,32 @@ already use. ### In scope (normative) -- The transition model and its relationship to the frame-snapshot rendering contract +- The transition model and its relationship to the frame-snapshot rendering + contract - Time handling and the `deltaTime` convention - The animating signal returned from `render()` -- The declarative enter and exit model (no callbacks across the WASM boundary) - Element identity requirements for transitions - Cancellation semantics (as a consequence of the frame-snapshot model) ### In scope (non-normative, descriptive) -- The shape of the `transition` field on the `open()` directive (shorthand and longhand) -- The set of easing functions exposed in the initial surface +- The shape of the `transition` field on the `open()` directive +- The set of easing functions exposed in v1 +- The set of transition properties exposed in v1 - The wire encoding of transition data in the directive buffer - Interaction with line mode - Testing strategy -### Out of scope +### Out of scope (v1) -- Custom (JavaScript-authored) easing functions. Reserved for a future extension; - the enum space is designed not to preclude them. -- Proportional reversal (CSS-style dynamic shortening of duration when a - transition is cancelled mid-flight). -- Physics-based animation, spring interpolation, or keyframe sequences. -- Any framework-level concept of "animation groups," "timelines," or choreography - across multiple elements. Orchestration is a caller concern. -- Input parsing (see [Input Specification](input-spec.md)). +See Section 13 for the deferred features and their upstream unblockers. + +### Out of scope (indefinitely) + +- Physics-based animation, spring interpolation, keyframe sequences +- Framework-level concepts of "animation groups" or cross-element choreography + (orchestration is a caller concern) +- Input parsing (see [Input Specification](input-spec.md)) --- @@ -59,29 +68,15 @@ already use. **Transition.** A time-based interpolation of one or more of an element's visual properties between an initial value and a target value. -**Transition property.** A specific visual attribute of an element that can be -interpolated: position (x, y), size (width, height), background color, overlay -color, border color, border width, or corner radius. - -**Easing.** A function mapping normalized progress in [0, 1] to an eased value -in [0, 1]. Clayterm exposes a fixed set of built-in easings. - -**Transition state.** One of four modes an element can be in with respect to a -given transition: idle, entering (element newly mounted), transitioning -(property target changed on an existing element), or exiting (element removed -from the tree but still being animated out). - -**Enter transition.** The animation played when an element first appears in the -directive tree. Its initial state is derived from the element's target state -by applying caller-supplied deltas (e.g., offset position, transparent color). +**Transition property.** A specific visual attribute of an element that can +be interpolated: position (x, y), size (width, height), background color, +overlay color, border color, or border width. -**Exit transition.** The animation played when an element disappears from the -directive tree. Its final state is derived from the element's last-seen state -by applying caller-supplied deltas. The element is still rendered during its -exit even though it is no longer in the directive tree. +**Easing.** A function mapping normalized progress in [0, 1] to an eased +value in [0, 1]. Clayterm exposes a fixed set of built-in easings. -**Delta time (`deltaTime`).** The number of seconds elapsed since the previous -render transaction. Used by the renderer to advance interpolation. +**Delta time (`deltaTime`).** The number of seconds elapsed since the +previous render transaction. Used by the renderer to advance interpolation. **Animating signal.** A boolean flag in the render result indicating whether any transition is currently in progress. Callers use it to decide whether to @@ -96,43 +91,39 @@ _This section is normative._ ### 4.1 Relationship to the frame-snapshot model Transitions do not alter the frame-snapshot contract defined in INV-3 of the -renderer specification. The directive array still fully describes the desired -state for its frame. Transitions interpolate between the previous frame's -state and the current frame's target state; they do not reintroduce a -persistent component tree on the caller side. +renderer specification. The directive array still fully describes the +desired state for its frame. Transitions interpolate between the previous +frame's state and the current frame's target state; they do not reintroduce +a persistent component tree on the caller side. -What transitions add is the requirement that element identifiers remain stable -across frames for any element on which animation is desired. This is not a new -invariant — the existing pointer-event subsystem already relies on stable -identifiers — but it becomes load-bearing for transitions. +What transitions add is the requirement that element identifiers remain +stable across frames for any element on which animation is desired. This is +not a new invariant — the existing pointer-event subsystem already relies +on stable identifiers — but it becomes load-bearing for transitions. ### 4.2 Time ownership The `Term` instance is the sole source of frame-to-frame time. On each `render()` call, the Term reads a monotonic clock and computes the elapsed -seconds since the previous render. That value is passed to the layout engine -to advance any in-flight transitions. +seconds since the previous render. That value is passed to the layout +engine to advance any in-flight transitions. -The caller MAY override the computed delta via an explicit `deltaTime` option -on `render()`. Use cases include deterministic testing, snapshot rendering, -and compute-only renders where the caller is querying bounds without -displaying output. +The caller MAY override the computed delta via an explicit `deltaTime` +option on `render()`. Use cases include deterministic testing, snapshot +rendering, and compute-only renders where the caller is querying bounds +without displaying output. -The Term MUST NOT use a non-monotonic clock (e.g., `Date.now()`). Wall-clock -time can move backward under NTP adjustments or DST, which would produce -negative deltas and corrupt interpolation. +The Term MUST NOT use a non-monotonic clock (e.g., `Date.now()`). +Wall-clock time can move backward under NTP adjustments or DST, which would +produce negative deltas and corrupt interpolation. ### 4.3 Delta clamping Clayterm does not clamp `deltaTime`. Long gaps between frames (process -suspension, backgrounded terminal, debugger pause) produce large deltas. The -underlying interpolation is duration-based and naturally clamps at 1.0 of -progress, so a large delta causes in-flight transitions to complete rather -than to overshoot or become unstable. - -This differs from physics-based engines, which clamp deltas to prevent -tunneling. Transitions as specified here are not physics-based, so clamping -is unnecessary. +suspension, backgrounded terminal, debugger pause) produce large deltas. +The underlying interpolation is duration-based and naturally clamps at 1.0 +of progress, so a large delta causes in-flight transitions to complete +rather than to overshoot or become unstable. ### 4.4 Animation-loop signaling @@ -142,19 +133,19 @@ active, callers may stop rendering until the next external event (input, resize, application state change). This requirement exists because terminal applications typically render -on-demand rather than at a fixed refresh rate. Without an explicit animating -signal, a caller has no way to know that a transition it triggered is still -in progress. +on-demand rather than at a fixed refresh rate. Without an explicit +animating signal, a caller has no way to know that a transition it +triggered is still in progress. ### 4.5 Boundary preservation -Transitions MUST NOT require function pointers, callbacks, or other -non-serializable values to cross the TS→WASM boundary. Easing and -enter/exit initial-state computation are implemented on the C side using -declarative configuration carried in the directive buffer. +Transition configuration MUST be fully serializable. No function pointers, +closures, or callback registries cross the TS→WASM boundary during a +render transaction. This preserves INV-2 (single transaction per frame): one binary buffer in, -one result struct out. +one result struct out. On the C side, a fixed set of easing handlers is +pre-registered; the directive selects one by enum value. --- @@ -164,33 +155,35 @@ _This section is normative._ **INV-T1. Time is driven by delta, not wall clock.** All transition interpolation advances by `deltaTime`, a per-frame seconds value. The -renderer does not subscribe to an internal timer or schedule work of its own. +renderer does not subscribe to an internal timer or schedule work of its +own. -**INV-T2. Render remains pure under time override.** When the caller supplies -an explicit `deltaTime`, the render result depends only on the directive -array, the previous frame's cell buffer, and the supplied `deltaTime`. This -makes deterministic rendering possible for tests and snapshots. +**INV-T2. Render remains pure under time override.** When the caller +supplies an explicit `deltaTime`, the render result depends only on the +directive array, the previous frame's cell buffer, and the supplied +`deltaTime`. This makes deterministic rendering possible for tests and +snapshots. **INV-T3. No callbacks across the boundary.** Transition configuration MUST -be fully serializable. No function pointers, closures, or callback registries -cross the TS→WASM boundary during a render transaction. +be fully serializable. No function pointers, closures, or callback +registries cross the TS→WASM boundary during a render transaction. -**INV-T4. Identity is drawn from element IDs.** Transition state is associated -with elements by their declared `id`. Callers using transitions on an element -MUST assign it a stable, unique `id` across frames. Reusing an `id` for a -different logical element in a later frame is a caller error; behavior is -unspecified. +**INV-T4. Identity is drawn from element IDs.** Transition state is +associated with elements by their declared `id`. Callers using transitions +on an element MUST assign it a stable, unique `id` across frames. Reusing +an `id` for a different logical element in a later frame is a caller +error; behavior is unspecified. **INV-T5. Animating signal is accurate per transaction.** The `animating` -flag returned by `render()` reflects the state of transitions as of the end -of that transaction. If it is `true`, at least one transition has non-zero -remaining progress and calling `render()` again with positive `deltaTime` -will advance it. +flag returned by `render()` reflects the state of transitions as of the +end of that transaction. If it is `true`, at least one transition has +non-zero remaining progress and calling `render()` again with positive +`deltaTime` will advance it. **INV-T6. Cancellation is structural.** There is no imperative `cancel()` API. Transitions are cancelled by re-describing the previous target in a -later frame; the transition infrastructure re-anchors the interpolation from -the current visible value to the new target. +later frame; the transition infrastructure re-anchors the interpolation +from the current visible value to the new target. --- @@ -210,7 +203,7 @@ interface RenderOptions { mode?: "line"; row?: number; pointer?: { x, y, down }; - deltaTime?: number; // seconds; overrides Term's internal clock + deltaTime?: number; } ``` @@ -235,7 +228,7 @@ interface RenderResult { events: PointerEvent[]; info: RenderInfo; errors: ClayError[]; - animating: boolean; // NEW + animating: boolean; } ``` @@ -245,21 +238,20 @@ transition at the end of the transaction. ### 6.3 The `transition` field on `open()` An element may declare a transition by adding a `transition` field to its -open-element directive. The field is optional. Its absence means the element -has no transitions, which is the default. +open-element directive. The field is optional. Its absence means the +element has no transitions, which is the default. -The field accepts either shorthand or longhand form (Section 7). +See Section 7 for the shape. --- ## 7. Declarative Transition Surface -_This section is descriptive. The shapes may be revised during implementation, -but the architectural commitments above do not change._ +_This section is descriptive._ -### 7.1 Shorthand form +### 7.1 The `transition` field -All listed properties share one duration and one easing: +All listed properties share a single duration and a single easing. ```ts open("sidebar", { @@ -267,113 +259,57 @@ open("sidebar", { bg: rgba(30, 30, 30, 255), transition: { duration: 0.2, - easing: easeOut(), + easing: "easeOut", properties: ["x", "width", "bg"], - }, -}) -``` - -### 7.2 Longhand form - -Each property declares its own duration and easing independently: - -```ts -open("sidebar", { - transition: [ - { property: "x", duration: 0.3, easing: easeInOut() }, - { property: "width", duration: 0.3, easing: easeInOut() }, - { property: "bg", duration: 0.15, easing: easeOut() }, - ], -}) -``` - -The shorthand form is expanded to longhand during directive packing. The wire -encoding carries only longhand. - -### 7.3 Extended form (enter, exit, interaction handling) - -```ts -open("toast", { - transition: { - properties: [ - { property: "y", duration: 0.25, easing: easeOut() }, - { property: "bg", duration: 0.15, easing: linear() }, - ], - enter: { - independently: false, - from: { y: -2, bg: rgba(0, 0, 0, 0) }, - }, - exit: { - independently: false, - to: { y: -2, bg: rgba(0, 0, 0, 0) }, - paintOrder: "natural", - }, interactive: false, }, }) ``` -**`enter.from`** declares deltas relative to the element's target state. The -initial state used by the enter transition is `target + from`. A missing -`from` entry for a given property means the enter transition starts at the -target value for that property (no visible animation on that axis). - -**`exit.to`** declares deltas relative to the element's last-seen state. -The final state used by the exit transition is `initial + to`. - -**`enter.independently` / `exit.independently`** (default `false`) control -whether the element's enter/exit plays when its parent is also entering or -exiting in the same frame. The default couples the element to its parent: -child elements do not play their own enter/exit when the parent is itself -entering or exiting (this prevents cascaded animations when an entire -container mounts or unmounts). Setting `independently: true` opts in to -playing the animation unconditionally. +**`duration`** — seconds. Must be non-negative. -**`exit.paintOrder`** controls how an exiting element is drawn relative to -its reflowing siblings during the exit animation. One of: +**`easing`** — a string naming one of the built-in easing curves +(Section 7.2). Defaults to `"linear"` when omitted. -- `"natural"` (default) — paints in the element's natural DOM order. -- `"underSiblings"` — paints beneath siblings; reflowing neighbors cover the - exiting element. -- `"overSiblings"` — paints on top of siblings; the exiting element remains - visually prominent until its animation completes. +**`properties`** — list of property names to interpolate. Group names +(`position`, `size`, `all`) expand to the union of the underlying +properties. **`interactive`** (default `false`) — when `false`, pointer interactions with the element are disabled while a position transition is in progress. -When `true`, pointer interactions remain enabled throughout position -transitions. +When `true`, pointer interactions remain enabled throughout. -### 7.4 Easing helpers +### 7.2 Easing values -Exported from the top-level module: +The `easing` field takes one of four string values: ```ts -linear() -easeIn() -easeOut() -easeInOut() -cubicBezier(x1: number, y1: number, x2: number, y2: number) +type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; ``` -Each returns an `Easing` value: a tagged byte with optional parameters. The -easing enum space is deliberately larger than the current surface to allow -future additions (including a potential `custom()` form that bridges to a -JavaScript function) without breaking serialized frames. +Each value maps to a wire byte (see Section 8). The byte space is +deliberately larger than this set so additional easings can be added +later without breaking serialized frames. A future parametric easing +(e.g., cubic bezier) would extend the type to a discriminated union: +`"linear" | "easeIn" | ... | { cubicBezier: [number, number, number, number] }`. +Today all values are non-parametric, so the type is a plain string union. -### 7.5 Property names +### 7.3 Property names ```ts type TransitionProperty = | "x" | "y" | "position" | "width" | "height" | "size" | "bg" | "overlay" | "borderColor" - | "cornerRadius" | "borderWidth" + | "borderWidth" | "all"; ``` -Group names (`position`, `size`, `all`) expand to the underlying property -set during packing and are equivalent to listing the constituent properties -explicitly in longhand form. +Group names expand as follows: + +- `position` → `x`, `y` +- `size` → `width`, `height` +- `all` → every individual property above --- @@ -382,52 +318,44 @@ explicitly in longhand form. _This section is descriptive._ The transition block is a new optional tagged section on `OP_OPEN_ELEMENT`. -Its presence is indicated in the element's property bitmask (existing -mechanism for optional fields). When present, its layout is: +Its presence is indicated by a bit in the open-element property mask. +When present, the block is a fixed 8-byte record: ``` transition_block { - flags: u8 // bit 0: enter present - // bit 1: exit present - // bit 2: interactive (0 = disabled, 1 = enabled) - entry_count: u8 // number of property_transition entries - entries: property_transition[] // entry_count entries, in stable property order - enter?: transition_side // present iff flags bit 0 - exit?: transition_side // present iff flags bit 1 + duration: f32 // seconds, non-negative + properties: u16 // Clay-native bitmask (see below) + easing: u8 // easing kind (0 = linear, 1 = easeIn, 2 = easeOut, 3 = easeInOut) + flags: u8 // bit 0: interactive (0 = disable, 1 = allow) } +``` -property_transition { - property: u16 // single-bit mask from Clay's property enum - duration: f32 // seconds, non-negative - easing: u8 // easing kind - params: f32[0 or 4] // 4 floats iff easing == cubicBezier -} +The `properties` value is the Clay transition property bitmask: -transition_side { - flags: u8 // bit 0: independently - // bits 1-2: paintOrder (exit only: 0 natural, 1 under, 2 over) - mask: u16 // which properties have deltas - values: bytes // packed in stable property order; widths per property -} +``` +CLAY_TRANSITION_PROPERTY_X = 1 +CLAY_TRANSITION_PROPERTY_Y = 2 +CLAY_TRANSITION_PROPERTY_WIDTH = 4 +CLAY_TRANSITION_PROPERTY_HEIGHT = 8 +CLAY_TRANSITION_PROPERTY_BACKGROUND_COLOR = 16 +CLAY_TRANSITION_PROPERTY_OVERLAY_COLOR = 32 +CLAY_TRANSITION_PROPERTY_BORDER_COLOR = 128 +CLAY_TRANSITION_PROPERTY_BORDER_WIDTH = 256 ``` -Value widths are property-specific: `f32` for position and size, `u32` for -colors, `u8[4]` for border widths, `u8[4]` for corner radii (8-bit -resolution per corner is consistent with the existing cornerRadius -encoding). +(Value 64, `CLAY_TRANSITION_PROPERTY_CORNER_RADIUS`, is defined upstream +but has no field in `Clay_TransitionData` and is not emitted by clayterm.) -The shorthand form is never present on the wire. TS fans shorthand out to -per-property longhand entries before packing. The C side sees only longhand. +The property-name helpers on the TS side expand to this bitmask during +packing. ### 8.1 Validation -The existing `validate()` utility gains checks: +`validate()` checks: -- `duration >= 0` for every entry. -- `easing` is one of the defined enum values. -- Property names in entries are valid and appear at most once. -- Property names in `enter.from` / `exit.to` are a subset of the entries - (deltas for a property not being transitioned are ignored or flagged). +- `duration >= 0`. +- `easing` is one of the defined enum values (0-3). +- Property names are from the defined set (Section 7.3). --- @@ -455,25 +383,19 @@ state rather than an imperative operation. ## 10. Interaction with Line Mode -_This section is descriptive; the concrete behavior will be finalized -during implementation._ +_This section is descriptive._ Line mode emits cells as newline-separated rows without absolute cursor positioning. Position transitions (`x`, `y`) have no meaningful effect in -this mode: the rendering output places each row at the current cursor, -not at absolute coordinates. +this mode: rows are placed at the current cursor, not at absolute +coordinates. Expected behavior in line mode: - Color and size transitions proceed normally. -- Position transitions are silently skipped (treated as if the property is - not being transitioned for that frame). -- Enter/exit transitions that declare `from` or `to` deltas on position - properties have those position deltas dropped; other delta properties - still apply. - -The `animating` signal reports accurately regardless of mode; line-mode -color or size transitions still report as animating. +- Position transitions are silently skipped (the property bits for x and y + are cleared before the configuration reaches Clay). +- The `animating` signal reports accurately regardless of mode. --- @@ -486,23 +408,22 @@ A test sequence looks like: ```ts term.render(opsA, { deltaTime: 0 }); -term.render(opsB, { deltaTime: 0 }); // target change, no time elapsed -term.render(opsB, { deltaTime: 0.1 }); // 50% through a 0.2s transition -term.render(opsB, { deltaTime: 0.1 }); // 100%, completed +term.render(opsB, { deltaTime: 0 }); // target change, no time elapsed +term.render(opsB, { deltaTime: 0.1 }); // 50% through a 0.2s transition +term.render(opsB, { deltaTime: 0.1 }); // 100%, completed ``` Test coverage should include, at minimum: -- Shorthand and longhand produce identical output for equivalent configs. -- Enter transitions with `independently: true` and `false`. -- Exit transitions with each `paintOrder` value. -- Cancellation: target change mid-flight re-anchors initial to current. -- Re-appearance during an exit transition. -- Transition config present one frame and absent the next. -- Multiple concurrent transitions on a single element (longhand). +- Property change mid-stream interpolates and completes. +- `animating` is false on static frames, true during interpolation, false + again when the transition completes. +- Mid-transition target change re-anchors initial to current value. - Multiple concurrent transitions on multiple elements. -- Line mode rendering: color and size transitions apply, position transitions - are silently skipped. +- Line mode: color and size transitions apply, position transitions are + silently skipped. +- Each easing enum produces distinct progression (linear, easeIn, easeOut, + easeInOut). --- @@ -510,127 +431,120 @@ Test coverage should include, at minimum: _This section is descriptive and may change without affecting contract._ -### 12.1 Clay submodule version +### 12.1 Clay submodule pin -clayterm currently pins Clay at commit `76ec363`. The transition API was -introduced upstream in commit `ee192f4`, with follow-up bug fixes. Before -implementing transitions, the Clay submodule must be advanced to a post- -`ee192f4` commit. Non-transition Clay changes introduced between the current -pin and the target pin — notably the `Clay_OnHover` signature change and the -element ID scheme split — require an audit of existing clayterm integration. - -Upgrading Clay is a prerequisite and should be treated as its own commit -ahead of transition work. +clayterm pins Clay at a specific commit that includes the transition API +introduced upstream in commit `ee192f4`. The pin is recorded in the `clay` +submodule pointer. Advancing the pin is a prerequisite when upstream adds +capabilities clayterm depends on (Section 13). ### 12.2 Handler architecture -Each `Term` registers a single C-side transition handler with Clay. -Per-element transition metadata (per-property duration, easing, easing -params, enter deltas, exit deltas) is stored in a side table keyed by -Clay element ID, owned by the Term's context. +Each `Term` registers one C-side transition handler per easing kind (four +total for v1: linear, easeIn, easeOut, easeInOut). At element-configuration +time the decoder selects the handler matching the element's easing enum +and stores it on the `Clay_TransitionElementConfig`. -The handler: +Each handler: -1. Resolves the active Term context. -2. Looks up metadata for the element by its Clay ID. -3. For each property in the active bitmask, computes local progress as - `clamp(elapsedTime / property.duration, 0, 1)`, applies the property's - easing, writes the interpolated value into the output struct. -4. Increments the Term context's `animating_count`. -5. Returns `true` if any property's local progress is below 1.0. +1. Computes progress as `clamp(elapsedTime / duration, 0, 1)`. +2. Applies its easing curve to progress. +3. Lerps each property named in the `properties` bitmask from `initial` to + `target`. +4. Increments the Term context's `animating_count` unless progress is 1.0. +5. Returns `true` if progress is 1.0 (transition complete), `false` + otherwise. -At the start of each `render()`, the Term resets its `animating_count` to +At the start of each `render()`, the Term resets `animating_count` to zero. At the end, the value is copied into the result struct as the -`animating` flag (true if count > 0). - -The `setInitialState` and `setFinalState` callbacks Clay expects are -implemented as fixed C functions that apply the per-element `from` / `to` -deltas from the side table to the target / initial state Clay passes in. +`animating` flag (`true` if count > 0). -### 12.3 Per-element storage lifetime +### 12.3 Per-Term isolation -Metadata is repopulated each frame during directive unpacking. Clay's -handler is invoked synchronously inside `Clay_EndLayout`, so per-frame -metadata remains valid when the handler fires. No metadata needs to persist -across frames on our side; Clay's internal hashmap persists the actual -transition state (elapsed time, current value, state machine phase). +The `animating_count` lives on the Term's C-side context, not as +module-level state. Multiple Terms created in the same process remain +isolated. -### 12.4 Multiple Term instances +### 12.4 Resolving the active Term inside the handler -`animating_count` and the metadata side table live on the Term's C-side -context, not as module-level state. Multiple Terms created in the same -process remain isolated. +Clay's transition-handler signature does not carry a `userData` pointer or +element ID. Each `reduce()` call records the currently-active Term pointer +in a module-level variable (`ct_active_context`) and clears it at the end. +The handler reads this variable to reach the Term's `animating_count`. A +single render pass cannot overlap with another (renders are synchronous), +so there is no concurrency concern. --- -## 13. Open Questions +## 13. Deferred Until Upstream Clay -These items remain undecided and will be resolved during implementation. -They do not affect the contract. +These capabilities are intentionally not in v1 because the required Clay +primitives are either missing or in flight upstream. The absence is +motivated; re-adding them is straightforward once Clay lands the pieces. -### 13.1 First-frame delta +### 13.1 Per-property easing and duration -On the very first `render()` after `createTerm()`, there is no previous -frame to compute a delta against. Clay's own behavior on its first -`Clay_EndLayout(deltaTime)` call (with a non-zero delta) is the source of -truth: clayterm will pass through whatever delta it has computed and adopt -whatever Clay does. Verification and documentation occur during -integration. +The directive API could allow each property to have its own duration and +easing (e.g., "fade bg in 150ms, slide x in 300ms"). Clay's +`Clay_TransitionElementConfig` carries a single `duration`, a single +`handler`, and a single `properties` bitmask per element, so the handler +has no way to distinguish per-property timing. Working around this +requires per-element metadata addressable from inside the handler. -### 13.2 Mid-transition target change +**Unblocked by:** Clay adding `void* userData` to the transition +arguments (upstream PR +[nicbarker/clay#603](https://github.com/nicbarker/clay/pull/603)). -The cancellation semantics in Section 9 require that a target change -mid-flight re-anchors `initial` to the current visible value. Clay's -`TRANSITIONING` state machine is expected to handle this, but it must be -verified. If Clay does not re-anchor, our handler adds the logic by -tracking the last-seen target per element. +### 13.2 Enter and exit transitions -### 13.3 Element re-appearance mid-exit +Elements mounted or removed between frames cannot express per-element +initial or final state deltas. Clay exposes `setInitialState` and +`setFinalState` callbacks with signatures that take no element identifier +or user pointer, so there is no way to look up per-element deltas from +inside the callbacks. Additionally, exit transitions require their +configuration to survive past the frame on which the element was last +declared, which requires a lifetime signal. -If an element is exiting and reappears in the next frame's directives, -the expected behavior is to cancel the exit and interpolate from the -current visible state to the new target. Implementation-dependent on Clay. +**Unblocked by:** -### 13.4 Transition removed mid-flight +- Clay `userData` on transition arguments (PR #603, above). +- An exit-completion callback or an `exiting` flag on the render command, + both of which have been discussed upstream with Clay's maintainer as + forthcoming. -If an element has a transition one frame and the `transition` field is -absent in the next frame, Clay's behavior for in-flight transitions -determines the outcome. Two reasonable options: (a) in-flight transitions -complete using their original config; (b) they freeze at their current -value. Deferred to Clay's observed behavior. Documented once verified. +### 13.3 `cubicBezier` easing -### 13.5 Custom easing escape hatch +Custom cubic-bezier curves need per-element control-point parameters, and +Clay's fixed handler signature has no mechanism to thread parameters to a +shared handler. -The easing enum space is deliberately larger than the initial surface. A -future `custom()` easing that bridges to a JavaScript function is -anticipated but not specified here. Its design must preserve INV-T3 -(no callbacks across the boundary during a render transaction) — likely -via a pre-sampled lookup table supplied in the directive buffer. +**Unblocked by:** the same Clay `userData` addition as 13.1. + +### 13.4 Corner-radius transitions + +`CLAY_TRANSITION_PROPERTY_CORNER_RADIUS` is defined in the Clay property +enum, but `Clay_TransitionData` has no field carrying corner radius. +Upstream `Clay_EaseOut` does not interpolate it. Clayterm cannot either. + +**Unblocked by:** Clay adding a `cornerRadius` field to +`Clay_TransitionData` and interpolating it in layout. --- ## 14. Demos -Two demos accompany the feature: - -1. **`demo/transitions.ts`** — a clayterm-native demo meaningfully - exercising transitions in a terminal context (e.g., a collapsing - sidebar, a list reorder, or a toast notification). Primary purpose: - surface real-world sharp edges in the API. +One demo accompanies v1: -2. **A reproduction of Clay's upstream `raylib-transitions` demo** — - the example that accompanied the Clay transition-API commit - (`ee192f4`). Primary purpose: provide a reference implementation - that can be visually compared to upstream, validating that the - clayterm integration faithfully exercises the full transition API - surface. +**`demo/transitions.ts`** — exercises v1 transitions meaningfully in a +terminal context (e.g., a collapsing sidebar or a colored highlight that +fades between states). Purpose: surface real-world API sharp edges. --- ## Appendix A. Relationship to the Renderer Specification -This specification extends, but does not modify, the renderer specification. -Specifically: +This specification extends, but does not modify, the renderer +specification. Specifically: - **INV-1 (Zero IO).** Transitions introduce reading of a monotonic clock for `deltaTime` computation. A clock read is not terminal IO and does @@ -654,5 +568,5 @@ Specifically: resolved values would. The "Deferred/Future Areas" section of the renderer specification should -be updated to remove transitions from its list and to reference this -specification. +be updated to reference this specification rather than list transitions +as a single bullet. From c2395439270004ddb130b28b86dac8f4f5446cf3 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 11:15:17 -0500 Subject: [PATCH 12/29] =?UTF-8?q?=E2=9C=A8=20add=20transition=20property?= =?UTF-8?q?=20names,=20bitmask=20helpers,=20and=20Easing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mod.ts | 1 + ops-transitions.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 ops-transitions.ts diff --git a/mod.ts b/mod.ts index 8862d13..4a5f09a 100644 --- a/mod.ts +++ b/mod.ts @@ -1,4 +1,5 @@ export * from "./ops.ts"; +export * from "./ops-transitions.ts"; export * from "./term.ts"; export * from "./input.ts"; export * from "./settings.ts"; diff --git a/ops-transitions.ts b/ops-transitions.ts new file mode 100644 index 0000000..ce5bd71 --- /dev/null +++ b/ops-transitions.ts @@ -0,0 +1,53 @@ +export type TransitionProperty = + | "x" | "y" | "position" + | "width" | "height" | "size" + | "bg" | "overlay" | "borderColor" + | "borderWidth" + | "all"; + +export const TP_X = 1; +export const TP_Y = 2; +export const TP_WIDTH = 4; +export const TP_HEIGHT = 8; +export const TP_BG = 16; +export const TP_OVERLAY = 32; +export const TP_BORDER_COLOR = 128; +export const TP_BORDER_WIDTH = 256; + +export const TP_POSITION = TP_X | TP_Y; +export const TP_SIZE = TP_WIDTH | TP_HEIGHT; +export const TP_ALL = + TP_X | TP_Y | TP_WIDTH | TP_HEIGHT | + TP_BG | TP_OVERLAY | TP_BORDER_COLOR | TP_BORDER_WIDTH; + +export function propertyMask(name: TransitionProperty): number { + switch (name) { + case "x": return TP_X; + case "y": return TP_Y; + case "position": return TP_POSITION; + case "width": return TP_WIDTH; + case "height": return TP_HEIGHT; + case "size": return TP_SIZE; + case "bg": return TP_BG; + case "overlay": return TP_OVERLAY; + case "borderColor": return TP_BORDER_COLOR; + case "borderWidth": return TP_BORDER_WIDTH; + case "all": return TP_ALL; + } +} + +export type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; + +export const EASING_LINEAR = 0; +export const EASING_EASE_IN = 1; +export const EASING_EASE_OUT = 2; +export const EASING_EASE_IN_OUT = 3; + +export function easingByte(easing: Easing): number { + switch (easing) { + case "linear": return EASING_LINEAR; + case "easeIn": return EASING_EASE_IN; + case "easeOut": return EASING_EASE_OUT; + case "easeInOut": return EASING_EASE_IN_OUT; + } +} From 015349cc7f4ff572a28d93987cc3368de6197fc0 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 11:16:08 -0500 Subject: [PATCH 13/29] =?UTF-8?q?=E2=9C=A8=20add=20transition=20field=20ty?= =?UTF-8?q?pe=20to=20OpenElement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops-transitions.ts | 7 +++++++ ops.ts | 3 +++ 2 files changed, 10 insertions(+) diff --git a/ops-transitions.ts b/ops-transitions.ts index ce5bd71..ad636fd 100644 --- a/ops-transitions.ts +++ b/ops-transitions.ts @@ -51,3 +51,10 @@ export function easingByte(easing: Easing): number { case "easeInOut": return EASING_EASE_IN_OUT; } } + +export interface Transition { + duration: number; + easing?: Easing; + properties: TransitionProperty[]; + interactive?: boolean; +} diff --git a/ops.ts b/ops.ts index 3344eea..db266e3 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,5 @@ +import type { Transition } from "./ops-transitions.ts"; + /* Command buffer opcodes — mirrors ops.h */ const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; @@ -269,6 +271,7 @@ export interface OpenElement { attachPoints?: number; zIndex?: number; }; + transition?: Transition; } export interface Text { From 7eea06930856c74766ed8917c8263a0092d7cc9d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 11:17:38 -0500 Subject: [PATCH 14/29] =?UTF-8?q?=E2=9C=A8=20encode=20transition=20block?= =?UTF-8?q?=20in=20pack()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops.ts | 18 +++++++++++++++ test/transitions-pack.test.ts | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 test/transitions-pack.test.ts diff --git a/ops.ts b/ops.ts index db266e3..bb363fb 100644 --- a/ops.ts +++ b/ops.ts @@ -1,4 +1,5 @@ import type { Transition } from "./ops-transitions.ts"; +import { easingByte, propertyMask } from "./ops-transitions.ts"; /* Command buffer opcodes — mirrors ops.h */ const OP_OPEN_ELEMENT = 0x02; @@ -12,6 +13,7 @@ const PROP_CORNER_RADIUS = 0x04; const PROP_BORDER = 0x08; const PROP_CLIP = 0x10; const PROP_FLOATING = 0x20; +const PROP_TRANSITION = 0x40; const encoder = new TextEncoder(); @@ -93,6 +95,7 @@ export function pack( if (op.border) mask |= PROP_BORDER; if (op.clip) mask |= PROP_CLIP; if (op.floating) mask |= PROP_FLOATING; + if (op.transition) mask |= PROP_TRANSITION; view.setUint32(o, mask, true); o += 4; @@ -175,6 +178,21 @@ export function pack( ); o += 4; } + + if (op.transition) { + let t = op.transition; + let pmask = 0; + for (let name of t.properties) pmask |= propertyMask(name); + + view.setFloat32(o, t.duration, true); + o += 4; + view.setUint16(o, pmask, true); + o += 2; + view.setUint8(o, easingByte(t.easing ?? "linear")); + o += 1; + view.setUint8(o, t.interactive ? 1 : 0); + o += 1; + } break; } diff --git a/test/transitions-pack.test.ts b/test/transitions-pack.test.ts new file mode 100644 index 0000000..885a89a --- /dev/null +++ b/test/transitions-pack.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "./suite.ts"; +import { close, open, pack } from "../mod.ts"; + +describe("pack transition", () => { + it("encodes a transition without throwing", () => { + let mem = new ArrayBuffer(4096); + let len = pack( + [ + open("a", { + transition: { + duration: 0.2, + easing: "easeOut", + properties: ["x", "bg"], + }, + }), + close(), + ], + mem, + 0, + 4096, + ); + expect(len).toBeGreaterThan(0); + }); + + it("writes a longer buffer when a transition is present", () => { + let mem1 = new ArrayBuffer(4096); + let withoutLen = pack([open("a", {}), close()], mem1, 0, 4096); + let mem2 = new ArrayBuffer(4096); + let withLen = pack( + [ + open("a", { transition: { duration: 0.2, properties: ["x"] } }), + close(), + ], + mem2, + 0, + 4096, + ); + expect(withLen).toBeGreaterThan(withoutLen); + // The transition block is exactly 8 bytes = 2 words. + expect(withLen - withoutLen).toBe(2); + }); +}); From c18a97b2e0921d2406d4c4240feb093a85ef433d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 11:22:11 -0500 Subject: [PATCH 15/29] =?UTF-8?q?=E2=9C=A8=20register=20Clay=20handlers,?= =?UTF-8?q?=20interpolate=20on=20property=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: --- src/clayterm.c | 22 ++++++ src/module.c | 1 + src/transitions.c | 131 +++++++++++++++++++++++++++++++++++ src/transitions.h | 19 +++++ test/transitions-run.test.ts | 51 ++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 src/transitions.c create mode 100644 src/transitions.h create mode 100644 test/transitions-run.test.ts diff --git a/src/clayterm.c b/src/clayterm.c index b871526..6984633 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -12,6 +12,7 @@ */ #include "clayterm.h" +#include "transitions.h" #include "../clay/clay.h" #include "buffer.h" #include "cell.h" @@ -19,6 +20,8 @@ #include "utf8.h" #include "wcwidth.h" +struct Clayterm *ct_active_context = NULL; + /* ── Command buffer protocol ──────────────────────────────────────── */ #define OP_BEGIN_LAYOUT 0x01 @@ -33,6 +36,7 @@ #define PROP_BORDER 0x08 #define PROP_CLIP 0x10 #define PROP_FLOATING 0x20 +#define PROP_TRANSITION 0x40 /* ── Instance state ───────────────────────────────────────────────── */ @@ -470,6 +474,7 @@ struct Clayterm *init(void *mem, int w, int h) { void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime) { int i = 0; + ct_active_context = ct; ct->error_count = 0; ct->animating_count = 0; @@ -557,6 +562,21 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, floa decl.floating.zIndex = (int16_t)((fc >> 24) & 0xff); } + if (mask & PROP_TRANSITION) { + float duration = rdf(buf, len, &i); + uint32_t props_and_flags = rd(buf, len, &i); + uint16_t props = props_and_flags & 0xFFFF; + uint8_t easing = (props_and_flags >> 16) & 0xFF; + uint8_t interactive = (props_and_flags >> 24) & 0xFF; + + decl.transition.handler = ct_handler_for(easing); + decl.transition.duration = duration; + decl.transition.properties = (Clay_TransitionProperty)props; + decl.transition.interactionHandling = interactive + ? CLAY_TRANSITION_ALLOW_INTERACTIONS_WHILE_TRANSITIONING_POSITION + : CLAY_TRANSITION_DISABLE_INTERACTIONS_WHILE_TRANSITIONING_POSITION; + } + Clay__ConfigureOpenElement(decl); break; } @@ -640,6 +660,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, floa } else { present_cups(ct, row); } + + ct_active_context = NULL; } char *output(struct Clayterm *ct) { return ct->out.data; } diff --git a/src/module.c b/src/module.c index 709884d..bca0757 100644 --- a/src/module.c +++ b/src/module.c @@ -8,5 +8,6 @@ #include "utf8.c" #include "wcwidth.c" #include "clayterm.c" +#include "transitions.c" #include "trie.c" #include "input.c" diff --git a/src/transitions.c b/src/transitions.c new file mode 100644 index 0000000..6c7d15e --- /dev/null +++ b/src/transitions.c @@ -0,0 +1,131 @@ +#include "transitions.h" +#include "clayterm.h" + +extern struct Clayterm *ct_active_context; + +static float clampf(float v, float lo, float hi) { + if (v < lo) { + return lo; + } else if (v > hi) { + return hi; + } else { + return v; + } +} + +static float ease_in(float t) { + return t * t; +} + +static float ease_out(float t) { + float inv = 1.0f - t; + return 1.0f - inv * inv; +} + +static float ease_in_out(float t) { + if (t < 0.5f) { + return 2.0f * t * t; + } else { + float inv = 1.0f - t; + return 1.0f - 2.0f * inv * inv; + } +} + +static float lerpf(float a, float b, float t) { + return a + (b - a) * t; +} + +static Clay_Color lerp_color(Clay_Color a, Clay_Color b, float t) { + Clay_Color out; + out.r = lerpf(a.r, b.r, t); + out.g = lerpf(a.g, b.g, t); + out.b = lerpf(a.b, b.b, t); + out.a = lerpf(a.a, b.a, t); + return out; +} + +static bool apply(Clay_TransitionCallbackArguments args, float eased, bool done) { + if (args.properties & CLAY_TRANSITION_PROPERTY_X) { + args.current->boundingBox.x = + lerpf(args.initial.boundingBox.x, args.target.boundingBox.x, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_Y) { + args.current->boundingBox.y = + lerpf(args.initial.boundingBox.y, args.target.boundingBox.y, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_WIDTH) { + args.current->boundingBox.width = + lerpf(args.initial.boundingBox.width, args.target.boundingBox.width, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_HEIGHT) { + args.current->boundingBox.height = + lerpf(args.initial.boundingBox.height, args.target.boundingBox.height, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_BACKGROUND_COLOR) { + args.current->backgroundColor = + lerp_color(args.initial.backgroundColor, args.target.backgroundColor, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_OVERLAY_COLOR) { + args.current->overlayColor = + lerp_color(args.initial.overlayColor, args.target.overlayColor, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_BORDER_COLOR) { + args.current->borderColor = + lerp_color(args.initial.borderColor, args.target.borderColor, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_BORDER_WIDTH) { + args.current->borderWidth.left = + (uint16_t)lerpf(args.initial.borderWidth.left, args.target.borderWidth.left, eased); + args.current->borderWidth.right = + (uint16_t)lerpf(args.initial.borderWidth.right, args.target.borderWidth.right, eased); + args.current->borderWidth.top = + (uint16_t)lerpf(args.initial.borderWidth.top, args.target.borderWidth.top, eased); + args.current->borderWidth.bottom = + (uint16_t)lerpf(args.initial.borderWidth.bottom, args.target.borderWidth.bottom, eased); + args.current->borderWidth.betweenChildren = + (uint16_t)lerpf(args.initial.borderWidth.betweenChildren, args.target.borderWidth.betweenChildren, eased); + } + if (ct_active_context && !done) { + ct_active_context->animating_count++; + } + return done; +} + +static float progress(Clay_TransitionCallbackArguments args) { + if (args.duration <= 0.0f) { + return 1.0f; + } else { + return clampf(args.elapsedTime / args.duration, 0.0f, 1.0f); + } +} + +bool ct_handler_linear(Clay_TransitionCallbackArguments args) { + float p = progress(args); + return apply(args, p, p >= 1.0f); +} + +bool ct_handler_ease_in(Clay_TransitionCallbackArguments args) { + float p = progress(args); + return apply(args, ease_in(p), p >= 1.0f); +} + +bool ct_handler_ease_out(Clay_TransitionCallbackArguments args) { + float p = progress(args); + return apply(args, ease_out(p), p >= 1.0f); +} + +bool ct_handler_ease_in_out(Clay_TransitionCallbackArguments args) { + float p = progress(args); + return apply(args, ease_in_out(p), p >= 1.0f); +} + +bool (*ct_handler_for(int kind))(Clay_TransitionCallbackArguments) { + switch (kind) { + case CT_EASING_EASE_IN: return ct_handler_ease_in; + case CT_EASING_EASE_OUT: return ct_handler_ease_out; + case CT_EASING_EASE_IN_OUT: return ct_handler_ease_in_out; + case CT_EASING_LINEAR: + default: + return ct_handler_linear; + } +} diff --git a/src/transitions.h b/src/transitions.h new file mode 100644 index 0000000..4a68e43 --- /dev/null +++ b/src/transitions.h @@ -0,0 +1,19 @@ +#ifndef CLAYTERM_TRANSITIONS_H +#define CLAYTERM_TRANSITIONS_H + +#include +#include "../clay/clay.h" + +#define CT_EASING_LINEAR 0 +#define CT_EASING_EASE_IN 1 +#define CT_EASING_EASE_OUT 2 +#define CT_EASING_EASE_IN_OUT 3 + +bool ct_handler_linear(Clay_TransitionCallbackArguments args); +bool ct_handler_ease_in(Clay_TransitionCallbackArguments args); +bool ct_handler_ease_out(Clay_TransitionCallbackArguments args); +bool ct_handler_ease_in_out(Clay_TransitionCallbackArguments args); + +bool (*ct_handler_for(int kind))(Clay_TransitionCallbackArguments); + +#endif diff --git a/test/transitions-run.test.ts b/test/transitions-run.test.ts new file mode 100644 index 0000000..184084a --- /dev/null +++ b/test/transitions-run.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "./suite.ts"; +import { + close, + createTerm, + fixed, + grow, + open, + rgba, + type Op, +} from "../mod.ts"; + +describe("transition lifecycle", () => { + it("animates bg change between frames", async () => { + let term = await createTerm({ width: 20, height: 5 }); + let frame = (bg: number): Op[] => [ + open("box", { + layout: { width: fixed(10), height: fixed(3) }, + bg, + transition: { duration: 0.2, easing: "easeInOut", properties: ["bg"] }, + }), + close(), + ]; + + let r0 = term.render(frame(rgba(255, 0, 0)), { deltaTime: 0 }); + expect(r0.animating).toBe(false); + + term.render(frame(rgba(0, 0, 255)), { deltaTime: 0 }); + let mid = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.1 }); + expect(mid.animating).toBe(true); + + term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.15 }); + let done = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.05 }); + expect(done.animating).toBe(false); + }); + + it("reports animating=false when duration is 0", async () => { + let term = await createTerm({ width: 10, height: 3 }); + let frame = (bg: number): Op[] => [ + open("box", { + layout: { width: fixed(5), height: fixed(2) }, + bg, + transition: { duration: 0, properties: ["bg"] }, + }), + close(), + ]; + + term.render(frame(rgba(255, 0, 0)), { deltaTime: 0 }); + let r = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.1 }); + expect(r.animating).toBe(false); + }); +}); From cbd6109da5595e7975ad36ea64ce1baa4699c0c8 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 19:34:07 -0500 Subject: [PATCH 16/29] =?UTF-8?q?=E2=9C=A8=20reset=20deltaTime=20to=200=20?= =?UTF-8?q?after=20idle=20(preserve=20transitions=20across=20long=20gaps)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/transitions-spec.md | 18 +++++++++++++++--- term.ts | 7 +++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/specs/transitions-spec.md b/specs/transitions-spec.md index 1c957d4..6b9cf92 100644 --- a/specs/transitions-spec.md +++ b/specs/transitions-spec.md @@ -108,6 +108,16 @@ The `Term` instance is the sole source of frame-to-frame time. On each seconds since the previous render. That value is passed to the layout engine to advance any in-flight transitions. +If the previous render reported `animating=false`, the Term passes +`deltaTime=0` to the layout engine on the current render, regardless of +wall-clock time elapsed. The rationale: Clay is delta-based and has no +concept of when a transition began. Idle time between renders must not +count toward any subsequent transition's elapsed clock, otherwise a long +idle gap followed by a mutation would cause the transition to complete +instantly. Passing `deltaTime=0` on the first frame of any new transition +gives it a clean elapsed=0 starting point; real deltas resume once the +previous render signals `animating=true`. + The caller MAY override the computed delta via an explicit `deltaTime` option on `render()`. Use cases include deterministic testing, snapshot rendering, and compute-only renders where the caller is querying bounds @@ -209,9 +219,11 @@ interface RenderOptions { Each `render()` call advances transitions by its `deltaTime`: -- If `deltaTime` is omitted, Term computes it as the monotonic wall-clock - time elapsed since the previous `render()` call. -- If `deltaTime` is provided, it is used verbatim for that frame. +- If `deltaTime` is provided explicitly, it is used verbatim. +- Otherwise, if the previous render reported `animating=false`, + `deltaTime=0` (see §4.2 for rationale). +- Otherwise, `deltaTime` is the monotonic wall-clock time elapsed since + the previous `render()` call. On every `render()` call, Term captures the current monotonic timestamp as the reference point for the next implicit delta. The two modes can be diff --git a/term.ts b/term.ts index 74a66ea..db61018 100644 --- a/term.ts +++ b/term.ts @@ -81,6 +81,7 @@ export async function createTerm(options: TermOptions): Promise { let pressed = new Set(); let wasDown = false; let lastRenderAt: number | undefined; + let wasAnimating = false; return { render(ops: Op[], options?: RenderOptions): RenderResult { @@ -91,7 +92,7 @@ export async function createTerm(options: TermOptions): Promise { let dt: number; if (options?.deltaTime !== undefined) { dt = options.deltaTime; - } else if (lastRenderAt === undefined) { + } else if (!wasAnimating || lastRenderAt === undefined) { dt = 0; } else { dt = now - lastRenderAt; @@ -165,7 +166,9 @@ export async function createTerm(options: TermOptions): Promise { }); } - return { output, events, info, errors, animating: native.animating(statePtr) > 0 }; + let animating = native.animating(statePtr) > 0; + wasAnimating = animating; + return { output, events, info, errors, animating }; }, }; } From 732516450d3170ce9b0342383553b22e97e5ce33 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 19:37:03 -0500 Subject: [PATCH 17/29] =?UTF-8?q?=E2=9C=85=20verify=20color=20transitions?= =?UTF-8?q?=20work=20in=20line=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/transitions-run.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/transitions-run.test.ts b/test/transitions-run.test.ts index 184084a..283a3e1 100644 --- a/test/transitions-run.test.ts +++ b/test/transitions-run.test.ts @@ -49,3 +49,23 @@ describe("transition lifecycle", () => { expect(r.animating).toBe(false); }); }); + +describe("transitions in line mode", () => { + it("runs color transitions in line mode", async () => { + let term = await createTerm({ width: 20, height: 5 }); + let frame = (bg: number): Op[] => [ + open("box", { + layout: { width: fixed(10), height: fixed(2) }, + bg, + transition: { duration: 0.2, properties: ["bg"] }, + }), + close(), + ]; + + term.render(frame(rgba(255, 0, 0)), { deltaTime: 0, mode: "line" }); + term.render(frame(rgba(0, 255, 0)), { deltaTime: 0, mode: "line" }); + let r = term.render(frame(rgba(0, 255, 0)), { deltaTime: 0.1, mode: "line" }); + expect(r.animating).toBe(true); + expect(r.output).toBeInstanceOf(Uint8Array); + }); +}); From 9e273d4377a4b225907cf32c4dab8fd6f32e945a Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 20:14:12 -0500 Subject: [PATCH 18/29] =?UTF-8?q?=F0=9F=8E=A8=20apply=20deno=20fmt=20and?= =?UTF-8?q?=20clang-format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops-transitions.ts | 60 +++-- specs/transitions-spec.md | 445 +++++++++++++++++------------------ src/clayterm.c | 10 +- src/clayterm.h | 3 +- src/transitions.c | 65 ++--- term-native.ts | 9 +- test/transitions-run.test.ts | 15 +- 7 files changed, 312 insertions(+), 295 deletions(-) diff --git a/ops-transitions.ts b/ops-transitions.ts index ad636fd..f3e2cd5 100644 --- a/ops-transitions.ts +++ b/ops-transitions.ts @@ -1,7 +1,13 @@ export type TransitionProperty = - | "x" | "y" | "position" - | "width" | "height" | "size" - | "bg" | "overlay" | "borderColor" + | "x" + | "y" + | "position" + | "width" + | "height" + | "size" + | "bg" + | "overlay" + | "borderColor" | "borderWidth" | "all"; @@ -16,23 +22,33 @@ export const TP_BORDER_WIDTH = 256; export const TP_POSITION = TP_X | TP_Y; export const TP_SIZE = TP_WIDTH | TP_HEIGHT; -export const TP_ALL = - TP_X | TP_Y | TP_WIDTH | TP_HEIGHT | +export const TP_ALL = TP_X | TP_Y | TP_WIDTH | TP_HEIGHT | TP_BG | TP_OVERLAY | TP_BORDER_COLOR | TP_BORDER_WIDTH; export function propertyMask(name: TransitionProperty): number { switch (name) { - case "x": return TP_X; - case "y": return TP_Y; - case "position": return TP_POSITION; - case "width": return TP_WIDTH; - case "height": return TP_HEIGHT; - case "size": return TP_SIZE; - case "bg": return TP_BG; - case "overlay": return TP_OVERLAY; - case "borderColor": return TP_BORDER_COLOR; - case "borderWidth": return TP_BORDER_WIDTH; - case "all": return TP_ALL; + case "x": + return TP_X; + case "y": + return TP_Y; + case "position": + return TP_POSITION; + case "width": + return TP_WIDTH; + case "height": + return TP_HEIGHT; + case "size": + return TP_SIZE; + case "bg": + return TP_BG; + case "overlay": + return TP_OVERLAY; + case "borderColor": + return TP_BORDER_COLOR; + case "borderWidth": + return TP_BORDER_WIDTH; + case "all": + return TP_ALL; } } @@ -45,10 +61,14 @@ export const EASING_EASE_IN_OUT = 3; export function easingByte(easing: Easing): number { switch (easing) { - case "linear": return EASING_LINEAR; - case "easeIn": return EASING_EASE_IN; - case "easeOut": return EASING_EASE_OUT; - case "easeInOut": return EASING_EASE_IN_OUT; + case "linear": + return EASING_LINEAR; + case "easeIn": + return EASING_EASE_IN; + case "easeOut": + return EASING_EASE_OUT; + case "easeInOut": + return EASING_EASE_IN_OUT; } } diff --git a/specs/transitions-spec.md b/specs/transitions-spec.md index 6b9cf92..10ec2f5 100644 --- a/specs/transitions-spec.md +++ b/specs/transitions-spec.md @@ -8,25 +8,24 @@ where surfaces may settle during implementation. ## 1. Purpose -A transition smoothly interpolates an element's visual properties over time -when they change between frames. This specification defines how transitions -integrate with Clayterm's frame-snapshot rendering model: how they are -declared, how time is supplied, and how callers observe in-flight animation -so they can drive the render loop. - -Transitions are a first-class extension of the rendering contract defined in -the [Clayterm Renderer Specification](renderer-spec.md). They do not change -the architectural model, do not introduce a component tree, and do not -require callers to hold cross-frame identity beyond the stable element -identifiers they already use. - -This specification covers what clayterm ships against the current upstream -Clay layout engine. Several capabilities that the rendering model naturally -invites — per-property easing, per-element enter/exit behaviors, custom -bezier easings — are intentionally excluded from v1 because the underlying -Clay API cannot express them without upstream changes that are still in -flight. Section 13 records these deferrals and the upstream dependencies -that unblock them. +A transition smoothly interpolates an element's visual properties over time when +they change between frames. This specification defines how transitions integrate +with Clayterm's frame-snapshot rendering model: how they are declared, how time +is supplied, and how callers observe in-flight animation so they can drive the +render loop. + +Transitions are a first-class extension of the rendering contract defined in the +[Clayterm Renderer Specification](renderer-spec.md). They do not change the +architectural model, do not introduce a component tree, and do not require +callers to hold cross-frame identity beyond the stable element identifiers they +already use. + +This specification covers what clayterm ships against the current upstream Clay +layout engine. Several capabilities that the rendering model naturally invites — +per-property easing, per-element enter/exit behaviors, custom bezier easings — +are intentionally excluded from v1 because the underlying Clay API cannot +express them without upstream changes that are still in flight. Section 13 +records these deferrals and the upstream dependencies that unblock them. --- @@ -65,21 +64,21 @@ See Section 13 for the deferred features and their upstream unblockers. ## 3. Terminology -**Transition.** A time-based interpolation of one or more of an element's -visual properties between an initial value and a target value. +**Transition.** A time-based interpolation of one or more of an element's visual +properties between an initial value and a target value. -**Transition property.** A specific visual attribute of an element that can -be interpolated: position (x, y), size (width, height), background color, -overlay color, border color, or border width. +**Transition property.** A specific visual attribute of an element that can be +interpolated: position (x, y), size (width, height), background color, overlay +color, border color, or border width. -**Easing.** A function mapping normalized progress in [0, 1] to an eased -value in [0, 1]. Clayterm exposes a fixed set of built-in easings. +**Easing.** A function mapping normalized progress in [0, 1] to an eased value +in [0, 1]. Clayterm exposes a fixed set of built-in easings. -**Delta time (`deltaTime`).** The number of seconds elapsed since the -previous render transaction. Used by the renderer to advance interpolation. +**Delta time (`deltaTime`).** The number of seconds elapsed since the previous +render transaction. Used by the renderer to advance interpolation. -**Animating signal.** A boolean flag in the render result indicating whether -any transition is currently in progress. Callers use it to decide whether to +**Animating signal.** A boolean flag in the render result indicating whether any +transition is currently in progress. Callers use it to decide whether to schedule another frame. --- @@ -91,70 +90,68 @@ _This section is normative._ ### 4.1 Relationship to the frame-snapshot model Transitions do not alter the frame-snapshot contract defined in INV-3 of the -renderer specification. The directive array still fully describes the -desired state for its frame. Transitions interpolate between the previous -frame's state and the current frame's target state; they do not reintroduce -a persistent component tree on the caller side. +renderer specification. The directive array still fully describes the desired +state for its frame. Transitions interpolate between the previous frame's state +and the current frame's target state; they do not reintroduce a persistent +component tree on the caller side. -What transitions add is the requirement that element identifiers remain -stable across frames for any element on which animation is desired. This is -not a new invariant — the existing pointer-event subsystem already relies -on stable identifiers — but it becomes load-bearing for transitions. +What transitions add is the requirement that element identifiers remain stable +across frames for any element on which animation is desired. This is not a new +invariant — the existing pointer-event subsystem already relies on stable +identifiers — but it becomes load-bearing for transitions. ### 4.2 Time ownership The `Term` instance is the sole source of frame-to-frame time. On each `render()` call, the Term reads a monotonic clock and computes the elapsed -seconds since the previous render. That value is passed to the layout -engine to advance any in-flight transitions. - -If the previous render reported `animating=false`, the Term passes -`deltaTime=0` to the layout engine on the current render, regardless of -wall-clock time elapsed. The rationale: Clay is delta-based and has no -concept of when a transition began. Idle time between renders must not -count toward any subsequent transition's elapsed clock, otherwise a long -idle gap followed by a mutation would cause the transition to complete -instantly. Passing `deltaTime=0` on the first frame of any new transition -gives it a clean elapsed=0 starting point; real deltas resume once the -previous render signals `animating=true`. - -The caller MAY override the computed delta via an explicit `deltaTime` -option on `render()`. Use cases include deterministic testing, snapshot -rendering, and compute-only renders where the caller is querying bounds -without displaying output. - -The Term MUST NOT use a non-monotonic clock (e.g., `Date.now()`). -Wall-clock time can move backward under NTP adjustments or DST, which would -produce negative deltas and corrupt interpolation. +seconds since the previous render. That value is passed to the layout engine to +advance any in-flight transitions. + +If the previous render reported `animating=false`, the Term passes `deltaTime=0` +to the layout engine on the current render, regardless of wall-clock time +elapsed. The rationale: Clay is delta-based and has no concept of when a +transition began. Idle time between renders must not count toward any subsequent +transition's elapsed clock, otherwise a long idle gap followed by a mutation +would cause the transition to complete instantly. Passing `deltaTime=0` on the +first frame of any new transition gives it a clean elapsed=0 starting point; +real deltas resume once the previous render signals `animating=true`. + +The caller MAY override the computed delta via an explicit `deltaTime` option on +`render()`. Use cases include deterministic testing, snapshot rendering, and +compute-only renders where the caller is querying bounds without displaying +output. + +The Term MUST NOT use a non-monotonic clock (e.g., `Date.now()`). Wall-clock +time can move backward under NTP adjustments or DST, which would produce +negative deltas and corrupt interpolation. ### 4.3 Delta clamping Clayterm does not clamp `deltaTime`. Long gaps between frames (process -suspension, backgrounded terminal, debugger pause) produce large deltas. -The underlying interpolation is duration-based and naturally clamps at 1.0 -of progress, so a large delta causes in-flight transitions to complete -rather than to overshoot or become unstable. +suspension, backgrounded terminal, debugger pause) produce large deltas. The +underlying interpolation is duration-based and naturally clamps at 1.0 of +progress, so a large delta causes in-flight transitions to complete rather than +to overshoot or become unstable. ### 4.4 Animation-loop signaling The render result MUST surface whether any transition is currently active. Callers use this signal to schedule the next frame. When no transition is -active, callers may stop rendering until the next external event (input, -resize, application state change). +active, callers may stop rendering until the next external event (input, resize, +application state change). -This requirement exists because terminal applications typically render -on-demand rather than at a fixed refresh rate. Without an explicit -animating signal, a caller has no way to know that a transition it -triggered is still in progress. +This requirement exists because terminal applications typically render on-demand +rather than at a fixed refresh rate. Without an explicit animating signal, a +caller has no way to know that a transition it triggered is still in progress. ### 4.5 Boundary preservation Transition configuration MUST be fully serializable. No function pointers, -closures, or callback registries cross the TS→WASM boundary during a -render transaction. +closures, or callback registries cross the TS→WASM boundary during a render +transaction. -This preserves INV-2 (single transaction per frame): one binary buffer in, -one result struct out. On the C side, a fixed set of easing handlers is +This preserves INV-2 (single transaction per frame): one binary buffer in, one +result struct out. On the C side, a fixed set of easing handlers is pre-registered; the directive selects one by enum value. --- @@ -164,36 +161,33 @@ pre-registered; the directive selects one by enum value. _This section is normative._ **INV-T1. Time is driven by delta, not wall clock.** All transition -interpolation advances by `deltaTime`, a per-frame seconds value. The -renderer does not subscribe to an internal timer or schedule work of its -own. - -**INV-T2. Render remains pure under time override.** When the caller -supplies an explicit `deltaTime`, the render result depends only on the -directive array, the previous frame's cell buffer, and the supplied -`deltaTime`. This makes deterministic rendering possible for tests and -snapshots. - -**INV-T3. No callbacks across the boundary.** Transition configuration MUST -be fully serializable. No function pointers, closures, or callback -registries cross the TS→WASM boundary during a render transaction. - -**INV-T4. Identity is drawn from element IDs.** Transition state is -associated with elements by their declared `id`. Callers using transitions -on an element MUST assign it a stable, unique `id` across frames. Reusing -an `id` for a different logical element in a later frame is a caller -error; behavior is unspecified. - -**INV-T5. Animating signal is accurate per transaction.** The `animating` -flag returned by `render()` reflects the state of transitions as of the -end of that transaction. If it is `true`, at least one transition has -non-zero remaining progress and calling `render()` again with positive -`deltaTime` will advance it. - -**INV-T6. Cancellation is structural.** There is no imperative `cancel()` -API. Transitions are cancelled by re-describing the previous target in a -later frame; the transition infrastructure re-anchors the interpolation -from the current visible value to the new target. +interpolation advances by `deltaTime`, a per-frame seconds value. The renderer +does not subscribe to an internal timer or schedule work of its own. + +**INV-T2. Render remains pure under time override.** When the caller supplies an +explicit `deltaTime`, the render result depends only on the directive array, the +previous frame's cell buffer, and the supplied `deltaTime`. This makes +deterministic rendering possible for tests and snapshots. + +**INV-T3. No callbacks across the boundary.** Transition configuration MUST be +fully serializable. No function pointers, closures, or callback registries cross +the TS→WASM boundary during a render transaction. + +**INV-T4. Identity is drawn from element IDs.** Transition state is associated +with elements by their declared `id`. Callers using transitions on an element +MUST assign it a stable, unique `id` across frames. Reusing an `id` for a +different logical element in a later frame is a caller error; behavior is +unspecified. + +**INV-T5. Animating signal is accurate per transaction.** The `animating` flag +returned by `render()` reflects the state of transitions as of the end of that +transaction. If it is `true`, at least one transition has non-zero remaining +progress and calling `render()` again with positive `deltaTime` will advance it. + +**INV-T6. Cancellation is structural.** There is no imperative `cancel()` API. +Transitions are cancelled by re-describing the previous target in a later frame; +the transition infrastructure re-anchors the interpolation from the current +visible value to the new target. --- @@ -220,15 +214,15 @@ interface RenderOptions { Each `render()` call advances transitions by its `deltaTime`: - If `deltaTime` is provided explicitly, it is used verbatim. -- Otherwise, if the previous render reported `animating=false`, - `deltaTime=0` (see §4.2 for rationale). -- Otherwise, `deltaTime` is the monotonic wall-clock time elapsed since - the previous `render()` call. +- Otherwise, if the previous render reported `animating=false`, `deltaTime=0` + (see §4.2 for rationale). +- Otherwise, `deltaTime` is the monotonic wall-clock time elapsed since the + previous `render()` call. -On every `render()` call, Term captures the current monotonic timestamp as -the reference point for the next implicit delta. The two modes can be -freely mixed, but mixing within a single session is primarily useful for -tests that step time manually and should otherwise be avoided. +On every `render()` call, Term captures the current monotonic timestamp as the +reference point for the next implicit delta. The two modes can be freely mixed, +but mixing within a single session is primarily useful for tests that step time +manually and should otherwise be avoided. ### 6.2 `RenderResult` addition @@ -250,8 +244,8 @@ transition at the end of the transaction. ### 6.3 The `transition` field on `open()` An element may declare a transition by adding a `transition` field to its -open-element directive. The field is optional. Its absence means the -element has no transitions, which is the default. +open-element directive. The field is optional. Its absence means the element has +no transitions, which is the default. See Section 7 for the shape. @@ -275,21 +269,20 @@ open("sidebar", { properties: ["x", "width", "bg"], interactive: false, }, -}) +}); ``` **`duration`** — seconds. Must be non-negative. -**`easing`** — a string naming one of the built-in easing curves -(Section 7.2). Defaults to `"linear"` when omitted. +**`easing`** — a string naming one of the built-in easing curves (Section 7.2). +Defaults to `"linear"` when omitted. **`properties`** — list of property names to interpolate. Group names -(`position`, `size`, `all`) expand to the union of the underlying -properties. +(`position`, `size`, `all`) expand to the union of the underlying properties. -**`interactive`** (default `false`) — when `false`, pointer interactions -with the element are disabled while a position transition is in progress. -When `true`, pointer interactions remain enabled throughout. +**`interactive`** (default `false`) — when `false`, pointer interactions with +the element are disabled while a position transition is in progress. When +`true`, pointer interactions remain enabled throughout. ### 7.2 Easing values @@ -299,10 +292,10 @@ The `easing` field takes one of four string values: type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; ``` -Each value maps to a wire byte (see Section 8). The byte space is -deliberately larger than this set so additional easings can be added -later without breaking serialized frames. A future parametric easing -(e.g., cubic bezier) would extend the type to a discriminated union: +Each value maps to a wire byte (see Section 8). The byte space is deliberately +larger than this set so additional easings can be added later without breaking +serialized frames. A future parametric easing (e.g., cubic bezier) would extend +the type to a discriminated union: `"linear" | "easeIn" | ... | { cubicBezier: [number, number, number, number] }`. Today all values are non-parametric, so the type is a plain string union. @@ -310,9 +303,15 @@ Today all values are non-parametric, so the type is a plain string union. ```ts type TransitionProperty = - | "x" | "y" | "position" - | "width" | "height" | "size" - | "bg" | "overlay" | "borderColor" + | "x" + | "y" + | "position" + | "width" + | "height" + | "size" + | "bg" + | "overlay" + | "borderColor" | "borderWidth" | "all"; ``` @@ -329,9 +328,9 @@ Group names expand as follows: _This section is descriptive._ -The transition block is a new optional tagged section on `OP_OPEN_ELEMENT`. -Its presence is indicated by a bit in the open-element property mask. -When present, the block is a fixed 8-byte record: +The transition block is a new optional tagged section on `OP_OPEN_ELEMENT`. Its +presence is indicated by a bit in the open-element property mask. When present, +the block is a fixed 8-byte record: ``` transition_block { @@ -355,11 +354,10 @@ CLAY_TRANSITION_PROPERTY_BORDER_COLOR = 128 CLAY_TRANSITION_PROPERTY_BORDER_WIDTH = 256 ``` -(Value 64, `CLAY_TRANSITION_PROPERTY_CORNER_RADIUS`, is defined upstream -but has no field in `Clay_TransitionData` and is not emitted by clayterm.) +(Value 64, `CLAY_TRANSITION_PROPERTY_CORNER_RADIUS`, is defined upstream but has +no field in `Clay_TransitionData` and is not emitted by clayterm.) -The property-name helpers on the TS side expand to this bitmask during -packing. +The property-name helpers on the TS side expand to this bitmask during packing. ### 8.1 Validation @@ -375,21 +373,21 @@ packing. _This section is normative._ -A caller cancels an in-flight transition by emitting a new frame whose -directive for that element describes a different target state. The -transition infrastructure re-anchors the interpolation: +A caller cancels an in-flight transition by emitting a new frame whose directive +for that element describes a different target state. The transition +infrastructure re-anchors the interpolation: - The new `initial` value becomes the element's currently-visible value. - `elapsedTime` resets to zero. - The new `target` is the value declared in the current frame. -The transition duration is unchanged. A cancelled-and-reversed transition -takes its full configured duration regardless of how far it had progressed -at the time of cancellation. +The transition duration is unchanged. A cancelled-and-reversed transition takes +its full configured duration regardless of how far it had progressed at the time +of cancellation. -There is no `term.cancelTransition(id)` call. The frame-snapshot model -makes cancellation a structural consequence of re-describing the desired -state rather than an imperative operation. +There is no `term.cancelTransition(id)` call. The frame-snapshot model makes +cancellation a structural consequence of re-describing the desired state rather +than an imperative operation. --- @@ -398,15 +396,14 @@ state rather than an imperative operation. _This section is descriptive._ Line mode emits cells as newline-separated rows without absolute cursor -positioning. Position transitions (`x`, `y`) have no meaningful effect in -this mode: rows are placed at the current cursor, not at absolute -coordinates. +positioning. Position transitions (`x`, `y`) have no meaningful effect in this +mode: rows are placed at the current cursor, not at absolute coordinates. Expected behavior in line mode: - Color and size transitions proceed normally. -- Position transitions are silently skipped (the property bits for x and y - are cleared before the configuration reaches Clay). +- Position transitions are silently skipped (the property bits for x and y are + cleared before the configuration reaches Clay). - The `animating` signal reports accurately regardless of mode. --- @@ -415,25 +412,25 @@ Expected behavior in line mode: _This section is descriptive._ -The `deltaTime` override enables deterministic, snapshot-friendly tests. -A test sequence looks like: +The `deltaTime` override enables deterministic, snapshot-friendly tests. A test +sequence looks like: ```ts term.render(opsA, { deltaTime: 0 }); -term.render(opsB, { deltaTime: 0 }); // target change, no time elapsed -term.render(opsB, { deltaTime: 0.1 }); // 50% through a 0.2s transition -term.render(opsB, { deltaTime: 0.1 }); // 100%, completed +term.render(opsB, { deltaTime: 0 }); // target change, no time elapsed +term.render(opsB, { deltaTime: 0.1 }); // 50% through a 0.2s transition +term.render(opsB, { deltaTime: 0.1 }); // 100%, completed ``` Test coverage should include, at minimum: - Property change mid-stream interpolates and completes. -- `animating` is false on static frames, true during interpolation, false - again when the transition completes. +- `animating` is false on static frames, true during interpolation, false again + when the transition completes. - Mid-transition target change re-anchors initial to current value. - Multiple concurrent transitions on multiple elements. -- Line mode: color and size transitions apply, position transitions are - silently skipped. +- Line mode: color and size transitions apply, position transitions are silently + skipped. - Each easing enum produces distinct progression (linear, easeIn, easeOut, easeInOut). @@ -452,10 +449,10 @@ capabilities clayterm depends on (Section 13). ### 12.2 Handler architecture -Each `Term` registers one C-side transition handler per easing kind (four -total for v1: linear, easeIn, easeOut, easeInOut). At element-configuration -time the decoder selects the handler matching the element's easing enum -and stores it on the `Clay_TransitionElementConfig`. +Each `Term` registers one C-side transition handler per easing kind (four total +for v1: linear, easeIn, easeOut, easeInOut). At element-configuration time the +decoder selects the handler matching the element's easing enum and stores it on +the `Clay_TransitionElementConfig`. Each handler: @@ -464,82 +461,77 @@ Each handler: 3. Lerps each property named in the `properties` bitmask from `initial` to `target`. 4. Increments the Term context's `animating_count` unless progress is 1.0. -5. Returns `true` if progress is 1.0 (transition complete), `false` - otherwise. +5. Returns `true` if progress is 1.0 (transition complete), `false` otherwise. -At the start of each `render()`, the Term resets `animating_count` to -zero. At the end, the value is copied into the result struct as the -`animating` flag (`true` if count > 0). +At the start of each `render()`, the Term resets `animating_count` to zero. At +the end, the value is copied into the result struct as the `animating` flag +(`true` if count > 0). ### 12.3 Per-Term isolation -The `animating_count` lives on the Term's C-side context, not as -module-level state. Multiple Terms created in the same process remain -isolated. +The `animating_count` lives on the Term's C-side context, not as module-level +state. Multiple Terms created in the same process remain isolated. ### 12.4 Resolving the active Term inside the handler Clay's transition-handler signature does not carry a `userData` pointer or -element ID. Each `reduce()` call records the currently-active Term pointer -in a module-level variable (`ct_active_context`) and clears it at the end. -The handler reads this variable to reach the Term's `animating_count`. A -single render pass cannot overlap with another (renders are synchronous), -so there is no concurrency concern. +element ID. Each `reduce()` call records the currently-active Term pointer in a +module-level variable (`ct_active_context`) and clears it at the end. The +handler reads this variable to reach the Term's `animating_count`. A single +render pass cannot overlap with another (renders are synchronous), so there is +no concurrency concern. --- ## 13. Deferred Until Upstream Clay These capabilities are intentionally not in v1 because the required Clay -primitives are either missing or in flight upstream. The absence is -motivated; re-adding them is straightforward once Clay lands the pieces. +primitives are either missing or in flight upstream. The absence is motivated; +re-adding them is straightforward once Clay lands the pieces. ### 13.1 Per-property easing and duration -The directive API could allow each property to have its own duration and -easing (e.g., "fade bg in 150ms, slide x in 300ms"). Clay's -`Clay_TransitionElementConfig` carries a single `duration`, a single -`handler`, and a single `properties` bitmask per element, so the handler -has no way to distinguish per-property timing. Working around this -requires per-element metadata addressable from inside the handler. +The directive API could allow each property to have its own duration and easing +(e.g., "fade bg in 150ms, slide x in 300ms"). Clay's +`Clay_TransitionElementConfig` carries a single `duration`, a single `handler`, +and a single `properties` bitmask per element, so the handler has no way to +distinguish per-property timing. Working around this requires per-element +metadata addressable from inside the handler. -**Unblocked by:** Clay adding `void* userData` to the transition -arguments (upstream PR -[nicbarker/clay#603](https://github.com/nicbarker/clay/pull/603)). +**Unblocked by:** Clay adding `void* userData` to the transition arguments +(upstream PR [nicbarker/clay#603](https://github.com/nicbarker/clay/pull/603)). ### 13.2 Enter and exit transitions -Elements mounted or removed between frames cannot express per-element -initial or final state deltas. Clay exposes `setInitialState` and -`setFinalState` callbacks with signatures that take no element identifier -or user pointer, so there is no way to look up per-element deltas from -inside the callbacks. Additionally, exit transitions require their -configuration to survive past the frame on which the element was last -declared, which requires a lifetime signal. +Elements mounted or removed between frames cannot express per-element initial or +final state deltas. Clay exposes `setInitialState` and `setFinalState` callbacks +with signatures that take no element identifier or user pointer, so there is no +way to look up per-element deltas from inside the callbacks. Additionally, exit +transitions require their configuration to survive past the frame on which the +element was last declared, which requires a lifetime signal. **Unblocked by:** - Clay `userData` on transition arguments (PR #603, above). -- An exit-completion callback or an `exiting` flag on the render command, - both of which have been discussed upstream with Clay's maintainer as - forthcoming. +- An exit-completion callback or an `exiting` flag on the render command, both + of which have been discussed upstream with Clay's maintainer as forthcoming. ### 13.3 `cubicBezier` easing -Custom cubic-bezier curves need per-element control-point parameters, and -Clay's fixed handler signature has no mechanism to thread parameters to a -shared handler. +Custom cubic-bezier curves need per-element control-point parameters, and Clay's +fixed handler signature has no mechanism to thread parameters to a shared +handler. **Unblocked by:** the same Clay `userData` addition as 13.1. ### 13.4 Corner-radius transitions -`CLAY_TRANSITION_PROPERTY_CORNER_RADIUS` is defined in the Clay property -enum, but `Clay_TransitionData` has no field carrying corner radius. -Upstream `Clay_EaseOut` does not interpolate it. Clayterm cannot either. +`CLAY_TRANSITION_PROPERTY_CORNER_RADIUS` is defined in the Clay property enum, +but `Clay_TransitionData` has no field carrying corner radius. Upstream +`Clay_EaseOut` does not interpolate it. Clayterm cannot either. -**Unblocked by:** Clay adding a `cornerRadius` field to -`Clay_TransitionData` and interpolating it in layout. +**Unblocked by:** Clay adding a `cornerRadius` field to `Clay_TransitionData` +and interpolating it in layout. --- @@ -547,38 +539,37 @@ Upstream `Clay_EaseOut` does not interpolate it. Clayterm cannot either. One demo accompanies v1: -**`demo/transitions.ts`** — exercises v1 transitions meaningfully in a -terminal context (e.g., a collapsing sidebar or a colored highlight that -fades between states). Purpose: surface real-world API sharp edges. +**`demo/transitions.ts`** — exercises v1 transitions meaningfully in a terminal +context (e.g., a collapsing sidebar or a colored highlight that fades between +states). Purpose: surface real-world API sharp edges. --- ## Appendix A. Relationship to the Renderer Specification -This specification extends, but does not modify, the renderer -specification. Specifically: +This specification extends, but does not modify, the renderer specification. +Specifically: -- **INV-1 (Zero IO).** Transitions introduce reading of a monotonic clock - for `deltaTime` computation. A clock read is not terminal IO and does - not violate this invariant. The renderer still produces bytes only; it - does not read or write terminals. +- **INV-1 (Zero IO).** Transitions introduce reading of a monotonic clock for + `deltaTime` computation. A clock read is not terminal IO and does not violate + this invariant. The renderer still produces bytes only; it does not read or + write terminals. -- **INV-2 (Single transaction per frame).** Transitions preserve this. - All transition configuration is serialized into the single directive - buffer; no additional boundary crossings occur during rendering. +- **INV-2 (Single transaction per frame).** Transitions preserve this. All + transition configuration is serialized into the single directive buffer; no + additional boundary crossings occur during rendering. -- **INV-3 (Frame-snapshot independence).** Transitions preserve this at - the API level. Each directive array still fully describes the desired - state. Element IDs carry more weight (Section 4.1) but callers do not - acquire new cross-frame bookkeeping responsibilities. +- **INV-3 (Frame-snapshot independence).** Transitions preserve this at the API + level. Each directive array still fully describes the desired state. Element + IDs carry more weight (Section 4.1) but callers do not acquire new cross-frame + bookkeeping responsibilities. - **INV-4 (ANSI byte output).** Unchanged. -- **INV-5 (Layout/render/diff ownership).** The renderer additionally - owns transition interpolation. Interpolated values feed into the - existing layout and diff pipeline at the same pipeline stage that - resolved values would. +- **INV-5 (Layout/render/diff ownership).** The renderer additionally owns + transition interpolation. Interpolated values feed into the existing layout + and diff pipeline at the same pipeline stage that resolved values would. -The "Deferred/Future Areas" section of the renderer specification should -be updated to reference this specification rather than list transitions -as a single bullet. +The "Deferred/Future Areas" section of the renderer specification should be +updated to reference this specification rather than list transitions as a single +bullet. diff --git a/src/clayterm.c b/src/clayterm.c index 6984633..64f705d 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -472,7 +472,8 @@ struct Clayterm *init(void *mem, int w, int h) { return ct; } -void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime) { +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, + float deltaTime) { int i = 0; ct_active_context = ct; ct->error_count = 0; @@ -572,9 +573,10 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, floa decl.transition.handler = ct_handler_for(easing); decl.transition.duration = duration; decl.transition.properties = (Clay_TransitionProperty)props; - decl.transition.interactionHandling = interactive - ? CLAY_TRANSITION_ALLOW_INTERACTIONS_WHILE_TRANSITIONING_POSITION - : CLAY_TRANSITION_DISABLE_INTERACTIONS_WHILE_TRANSITIONING_POSITION; + decl.transition.interactionHandling = + interactive + ? CLAY_TRANSITION_ALLOW_INTERACTIONS_WHILE_TRANSITIONING_POSITION + : CLAY_TRANSITION_DISABLE_INTERACTIONS_WHILE_TRANSITIONING_POSITION; } Clay__ConfigureOpenElement(decl); diff --git a/src/clayterm.h b/src/clayterm.h index 4e7845e..8a24db4 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -12,7 +12,8 @@ struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); -void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime); +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, + float deltaTime); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); int animating(struct Clayterm *ct); diff --git a/src/transitions.c b/src/transitions.c index 6c7d15e..7c6837b 100644 --- a/src/transitions.c +++ b/src/transitions.c @@ -13,9 +13,7 @@ static float clampf(float v, float lo, float hi) { } } -static float ease_in(float t) { - return t * t; -} +static float ease_in(float t) { return t * t; } static float ease_out(float t) { float inv = 1.0f - t; @@ -31,9 +29,7 @@ static float ease_in_out(float t) { } } -static float lerpf(float a, float b, float t) { - return a + (b - a) * t; -} +static float lerpf(float a, float b, float t) { return a + (b - a) * t; } static Clay_Color lerp_color(Clay_Color a, Clay_Color b, float t) { Clay_Color out; @@ -44,46 +40,48 @@ static Clay_Color lerp_color(Clay_Color a, Clay_Color b, float t) { return out; } -static bool apply(Clay_TransitionCallbackArguments args, float eased, bool done) { +static bool apply(Clay_TransitionCallbackArguments args, float eased, + bool done) { if (args.properties & CLAY_TRANSITION_PROPERTY_X) { args.current->boundingBox.x = - lerpf(args.initial.boundingBox.x, args.target.boundingBox.x, eased); + lerpf(args.initial.boundingBox.x, args.target.boundingBox.x, eased); } if (args.properties & CLAY_TRANSITION_PROPERTY_Y) { args.current->boundingBox.y = - lerpf(args.initial.boundingBox.y, args.target.boundingBox.y, eased); + lerpf(args.initial.boundingBox.y, args.target.boundingBox.y, eased); } if (args.properties & CLAY_TRANSITION_PROPERTY_WIDTH) { - args.current->boundingBox.width = - lerpf(args.initial.boundingBox.width, args.target.boundingBox.width, eased); + args.current->boundingBox.width = lerpf( + args.initial.boundingBox.width, args.target.boundingBox.width, eased); } if (args.properties & CLAY_TRANSITION_PROPERTY_HEIGHT) { - args.current->boundingBox.height = - lerpf(args.initial.boundingBox.height, args.target.boundingBox.height, eased); + args.current->boundingBox.height = lerpf( + args.initial.boundingBox.height, args.target.boundingBox.height, eased); } if (args.properties & CLAY_TRANSITION_PROPERTY_BACKGROUND_COLOR) { - args.current->backgroundColor = - lerp_color(args.initial.backgroundColor, args.target.backgroundColor, eased); + args.current->backgroundColor = lerp_color( + args.initial.backgroundColor, args.target.backgroundColor, eased); } if (args.properties & CLAY_TRANSITION_PROPERTY_OVERLAY_COLOR) { args.current->overlayColor = - lerp_color(args.initial.overlayColor, args.target.overlayColor, eased); + lerp_color(args.initial.overlayColor, args.target.overlayColor, eased); } if (args.properties & CLAY_TRANSITION_PROPERTY_BORDER_COLOR) { args.current->borderColor = - lerp_color(args.initial.borderColor, args.target.borderColor, eased); + lerp_color(args.initial.borderColor, args.target.borderColor, eased); } if (args.properties & CLAY_TRANSITION_PROPERTY_BORDER_WIDTH) { - args.current->borderWidth.left = - (uint16_t)lerpf(args.initial.borderWidth.left, args.target.borderWidth.left, eased); - args.current->borderWidth.right = - (uint16_t)lerpf(args.initial.borderWidth.right, args.target.borderWidth.right, eased); - args.current->borderWidth.top = - (uint16_t)lerpf(args.initial.borderWidth.top, args.target.borderWidth.top, eased); - args.current->borderWidth.bottom = - (uint16_t)lerpf(args.initial.borderWidth.bottom, args.target.borderWidth.bottom, eased); + args.current->borderWidth.left = (uint16_t)lerpf( + args.initial.borderWidth.left, args.target.borderWidth.left, eased); + args.current->borderWidth.right = (uint16_t)lerpf( + args.initial.borderWidth.right, args.target.borderWidth.right, eased); + args.current->borderWidth.top = (uint16_t)lerpf( + args.initial.borderWidth.top, args.target.borderWidth.top, eased); + args.current->borderWidth.bottom = (uint16_t)lerpf( + args.initial.borderWidth.bottom, args.target.borderWidth.bottom, eased); args.current->borderWidth.betweenChildren = - (uint16_t)lerpf(args.initial.borderWidth.betweenChildren, args.target.borderWidth.betweenChildren, eased); + (uint16_t)lerpf(args.initial.borderWidth.betweenChildren, + args.target.borderWidth.betweenChildren, eased); } if (ct_active_context && !done) { ct_active_context->animating_count++; @@ -121,11 +119,14 @@ bool ct_handler_ease_in_out(Clay_TransitionCallbackArguments args) { bool (*ct_handler_for(int kind))(Clay_TransitionCallbackArguments) { switch (kind) { - case CT_EASING_EASE_IN: return ct_handler_ease_in; - case CT_EASING_EASE_OUT: return ct_handler_ease_out; - case CT_EASING_EASE_IN_OUT: return ct_handler_ease_in_out; - case CT_EASING_LINEAR: - default: - return ct_handler_linear; + case CT_EASING_EASE_IN: + return ct_handler_ease_in; + case CT_EASING_EASE_OUT: + return ct_handler_ease_out; + case CT_EASING_EASE_IN_OUT: + return ct_handler_ease_in_out; + case CT_EASING_LINEAR: + default: + return ct_handler_linear; } } diff --git a/term-native.ts b/term-native.ts index 78d850f..cdd0637 100644 --- a/term-native.ts +++ b/term-native.ts @@ -20,7 +20,14 @@ export interface Native { memory: WebAssembly.Memory; statePtr: number; opsBuf: number; - reduce(ct: number, buf: number, len: number, mode: number, row: number, deltaTime: number): void; + reduce( + ct: number, + buf: number, + len: number, + mode: number, + row: number, + deltaTime: number, + ): void; output(ct: number): number; length(ct: number): number; setPointer(x: number, y: number, down: boolean): void; diff --git a/test/transitions-run.test.ts b/test/transitions-run.test.ts index 283a3e1..f3eda68 100644 --- a/test/transitions-run.test.ts +++ b/test/transitions-run.test.ts @@ -1,13 +1,5 @@ import { describe, expect, it } from "./suite.ts"; -import { - close, - createTerm, - fixed, - grow, - open, - rgba, - type Op, -} from "../mod.ts"; +import { close, createTerm, fixed, grow, type Op, open, rgba } from "../mod.ts"; describe("transition lifecycle", () => { it("animates bg change between frames", async () => { @@ -64,7 +56,10 @@ describe("transitions in line mode", () => { term.render(frame(rgba(255, 0, 0)), { deltaTime: 0, mode: "line" }); term.render(frame(rgba(0, 255, 0)), { deltaTime: 0, mode: "line" }); - let r = term.render(frame(rgba(0, 255, 0)), { deltaTime: 0.1, mode: "line" }); + let r = term.render(frame(rgba(0, 255, 0)), { + deltaTime: 0.1, + mode: "line", + }); expect(r.animating).toBe(true); expect(r.output).toBeInstanceOf(Uint8Array); }); From 31ce2cb93f8a512eae4a6c30fa71e2a9e32f6624 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 20:15:45 -0500 Subject: [PATCH 19/29] =?UTF-8?q?=E2=9C=A8=20add=20transitions=20demo=20(c?= =?UTF-8?q?ollapsing=20sidebar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/transitions.ts | 89 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 demo/transitions.ts diff --git a/demo/transitions.ts b/demo/transitions.ts new file mode 100644 index 0000000..a521c0f --- /dev/null +++ b/demo/transitions.ts @@ -0,0 +1,89 @@ +/** + * Transitions demo — a sidebar that smoothly toggles between collapsed and expanded states. + * + * Exercises v1 transitions: one duration, one easing, multiple properties + * (width + bg) on a single element. + */ + +import { main, sleep, until } from "effection"; +import { + close, + createTerm, + cursor, + fixed, + grow, + open, + rgba, + settings, + text, +} from "../mod.ts"; +import { alternateBuffer } from "../settings.ts"; + +const BG_COLLAPSED = rgba(30, 30, 60); +const BG_EXPANDED = rgba(80, 80, 140); +const CONTENT_BG = rgba(20, 20, 20); +const TEXT_COLOR = rgba(220, 220, 220); + +await main(function* () { + let term = yield* until(createTerm({ width: 60, height: 18 })); + let tty = settings(alternateBuffer(), cursor(false)); + Deno.stdout.writeSync(tty.apply); + + try { + let expanded = false; + let lastToggle = 0; + + for (let i = 0; i < 400; i++) { + let wallMs = i * 25; + if (wallMs - lastToggle > 2000) { + expanded = !expanded; + lastToggle = wallMs; + } + + let ops = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("sidebar", { + layout: { + width: fixed(expanded ? 24 : 4), + height: grow(), + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + direction: "ttb", + }, + bg: expanded ? BG_EXPANDED : BG_COLLAPSED, + transition: { + duration: 0.4, + easing: "easeInOut", + properties: ["width", "bg"], + }, + }), + open("label", { + layout: { width: grow(), height: fixed(1) }, + }), + text(expanded ? "Menu" : "", { color: TEXT_COLOR }), + close(), + close(), + open("content", { + layout: { + width: grow(), + height: grow(), + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + }, + bg: CONTENT_BG, + }), + open("body", { layout: { width: grow(), height: grow() } }), + text("clayterm transitions demo", { color: TEXT_COLOR }), + close(), + close(), + close(), + ]; + + let r = term.render(ops); + Deno.stdout.writeSync(r.output); + yield* sleep(25); + } + } finally { + Deno.stdout.writeSync(tty.revert); + } +}); From 83decb43e22dc229b42f3f71012a6760d2992a0e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 20:16:21 -0500 Subject: [PATCH 20/29] =?UTF-8?q?=F0=9F=93=9D=20reference=20transitions-sp?= =?UTF-8?q?ec=20from=20renderer-spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/renderer-spec.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index fa4276a..fabda38 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -25,6 +25,9 @@ pointer event model and certain wrapper types — those are described in Section Input parsing is specified separately in the [Clayterm Input Specification](input-spec.md). +Transitions are specified separately in the +[Clayterm Transitions Specification](transitions-spec.md). + --- ## 2. Scope From 53bc7233f788af05b79f0a01ef35fc862bfd8b40 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 20:59:38 -0500 Subject: [PATCH 21/29] =?UTF-8?q?=E2=9C=A8=20rewrite=20transitions=20demo?= =?UTF-8?q?=20as=20interactive=20full-screen=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/transitions.ts | 326 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 262 insertions(+), 64 deletions(-) diff --git a/demo/transitions.ts b/demo/transitions.ts index a521c0f..5a884dd 100644 --- a/demo/transitions.ts +++ b/demo/transitions.ts @@ -1,89 +1,287 @@ /** - * Transitions demo — a sidebar that smoothly toggles between collapsed and expanded states. + * Interactive transitions demo — a sidebar that smoothly expands and collapses. * - * Exercises v1 transitions: one duration, one easing, multiple properties - * (width + bg) on a single element. + * Press Enter to open the menu sidebar, Esc to close it, q or Ctrl+C to quit. + * Exercises v1 transitions: width + bg animated simultaneously. */ -import { main, sleep, until } from "effection"; +import { + createChannel, + each, + ensure, + main, + race, + resource, + sleep, + spawn, + type Stream, + until, +} from "effection"; import { close, createTerm, - cursor, fixed, grow, + type InputEvent, + type Op, open, + percent, rgba, - settings, text, } from "../mod.ts"; -import { alternateBuffer } from "../settings.ts"; +import { alternateBuffer, cursor, settings } from "../settings.ts"; +import { useInput } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const SIDEBAR_BG_OPEN = rgba(80, 80, 140); +const SIDEBAR_BG_CLOSED = rgba(30, 30, 50); +const CONTENT_BG = rgba(18, 18, 22); +const MODELINE_BG = rgba(40, 40, 55); +const TEXT = rgba(220, 220, 220); +const DIM = rgba(130, 130, 150); +const HEADING = rgba(255, 220, 120); +const MENU_ITEM = rgba(180, 200, 240); +const KEY_LABEL = rgba(255, 220, 120); + +const MENU_ITEMS = [ + "New file", + "Open file…", + "Save", + "Save as…", + "—", + "Preferences", + "Quit (q)", +]; + +const BODY = [ + { kind: "h1", text: "Lorem Ipsum" }, + { + kind: "p", + text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + { + kind: "p", + text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + }, + { kind: "h2", text: "Section" }, + { + kind: "p", + text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + }, + { + kind: "p", + text: "Duis aute irure dolor in reprehenderit in voluptate velit esse.", + }, + { + kind: "p", + text: "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui.", + }, +]; + +interface State { + menuOpen: boolean; +} + +function view(state: State): Op[] { + let ops: Op[] = []; + + ops.push( + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ); + + ops.push( + open("main-row", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + ); + + ops.push( + open("sidebar", { + layout: { + width: state.menuOpen ? percent(0.2) : fixed(2), + height: grow(), + direction: "ttb", + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + gap: 1, + }, + bg: state.menuOpen ? SIDEBAR_BG_OPEN : SIDEBAR_BG_CLOSED, + transition: { + duration: 0.25, + easing: "easeInOut", + properties: ["width", "bg"], + }, + }), + ); + + if (state.menuOpen) { + ops.push( + open("menu-title", { layout: { height: fixed(1) } }), + text("Menu", { color: HEADING }), + close(), + ); + for (let item of MENU_ITEMS) { + ops.push( + open(`menu:${item}`, { layout: { height: fixed(1) } }), + text(item, { color: item === "—" ? DIM : MENU_ITEM }), + close(), + ); + } + } + + ops.push(close()); // sidebar + + ops.push( + open("content", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 3, right: 3, top: 1, bottom: 1 }, + gap: 1, + }, + bg: CONTENT_BG, + }), + ); + + for (let { kind, text: t } of BODY) { + ops.push(open(`body:${t.slice(0, 8)}`, { layout: { height: fixed(1) } })); + let color = kind === "h1" ? HEADING : kind === "h2" ? KEY_LABEL : TEXT; + ops.push(text(t, { color })); + ops.push(close()); + } + + ops.push(close()); // content + + ops.push(close()); // main-row + + ops.push( + open("modeline", { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + padding: { left: 1, right: 1 }, + gap: 2, + }, + bg: MODELINE_BG, + }), + open("mod:quit", { layout: { direction: "ltr", gap: 0 } }), + text("q", { color: KEY_LABEL }), + text(" quit", { color: TEXT }), + close(), + open("mod:menu", { layout: { direction: "ltr", gap: 0 } }), + text("enter", { color: KEY_LABEL }), + text(" show menu", { color: TEXT }), + close(), + open("mod:hide", { layout: { direction: "ltr", gap: 0 } }), + text("esc", { color: KEY_LABEL }), + text(" hide menu", { color: TEXT }), + close(), + close(), // modeline + ); + + ops.push(close()); // root -const BG_COLLAPSED = rgba(30, 30, 60); -const BG_EXPANDED = rgba(80, 80, 140); -const CONTENT_BG = rgba(20, 20, 20); -const TEXT_COLOR = rgba(220, 220, 220); + return ops; +} + +// A stream that emits at ~60fps intervals, but only while the shared flag is true. +function ticker(flag: { animating: boolean }): Stream { + return resource(function* (provide) { + let ch = createChannel(); + yield* spawn(function* () { + while (true) { + if (flag.animating) { + yield* sleep(16); + yield* ch.send(); + } else { + // Park until animating becomes true; check every 50ms. + yield* sleep(50); + } + } + }); + let sub = yield* ch; + yield* race([provide(sub), drain(ch)]); + }); +} + +function merge( + a: Stream, + b: Stream, +): Stream { + return resource(function* (provide) { + let sub = { + a: yield* a, + b: yield* b, + }; + return yield* provide({ + *next() { + return yield* race([sub.a.next(), sub.b.next()]); + }, + }); + }); +} + +function* drain(stream: Stream) { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} await main(function* () { - let term = yield* until(createTerm({ width: 60, height: 18 })); + let { columns, rows } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 80, rows: 24 }; + + Deno.stdin.setRaw(true); + yield* ensure(() => Deno.stdin.setRaw(false)); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let term = yield* until(createTerm({ width: columns, height: rows })); + let tty = settings(alternateBuffer(), cursor(false)); Deno.stdout.writeSync(tty.apply); + yield* ensure(() => { + Deno.stdout.writeSync(tty.revert); + }); - try { - let expanded = false; - let lastToggle = 0; + let state: State = { menuOpen: false }; + let flag = { animating: false }; - for (let i = 0; i < 400; i++) { - let wallMs = i * 25; - if (wallMs - lastToggle > 2000) { - expanded = !expanded; - lastToggle = wallMs; - } + function draw(): void { + let { output, animating } = term.render(view(state)); + flag.animating = animating; + Deno.stdout.writeSync(output); + } - let ops = [ - open("root", { - layout: { width: grow(), height: grow(), direction: "ltr" }, - }), - open("sidebar", { - layout: { - width: fixed(expanded ? 24 : 4), - height: grow(), - padding: { left: 1, right: 1, top: 1, bottom: 1 }, - direction: "ttb", - }, - bg: expanded ? BG_EXPANDED : BG_COLLAPSED, - transition: { - duration: 0.4, - easing: "easeInOut", - properties: ["width", "bg"], - }, - }), - open("label", { - layout: { width: grow(), height: fixed(1) }, - }), - text(expanded ? "Menu" : "", { color: TEXT_COLOR }), - close(), - close(), - open("content", { - layout: { - width: grow(), - height: grow(), - padding: { left: 2, right: 2, top: 1, bottom: 1 }, - }, - bg: CONTENT_BG, - }), - open("body", { layout: { width: grow(), height: grow() } }), - text("clayterm transitions demo", { color: TEXT_COLOR }), - close(), - close(), - close(), - ]; + draw(); + + let ticks = ticker(flag); + let events = merge(input, ticks); - let r = term.render(ops); - Deno.stdout.writeSync(r.output); - yield* sleep(25); + for (let _ of yield* each(events)) { + if (_ !== undefined && typeof _ === "object" && "type" in _) { + let event = _ as InputEvent; + if (event.type === "keydown") { + if (event.ctrl && event.key === "c") { + break; + } + if (event.key === "q") { + break; + } + if (event.key === "Enter") { + state = { ...state, menuOpen: true }; + } + if (event.key === "Escape") { + state = { ...state, menuOpen: false }; + } + } } - } finally { - Deno.stdout.writeSync(tty.revert); + draw(); + yield* each.next(); } }); From 896435c5d91550101ef4df6701cfc5e08bc5c673 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 21:08:11 -0500 Subject: [PATCH 22/29] =?UTF-8?q?=E2=9C=A8=20add=20clay-transitions=20demo?= =?UTF-8?q?=20port=20(v1-compatible=20subset)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the spirit of the raylib-transitions demo to clayterm: a 4×4 grid of colored boxes that animate position, size, and bg color. Shuffle (s) animates positions via Clay's transition system; recolor (c) toggles between two palettes with animated bg interpolation; hover tints each box by blending its bg toward white (overlay-color field is not yet in the v1 command buffer, so lighten-on-hover substitutes). Full mouse tracking is wired via mouseTracking() + pointer state from input events. --- demo/clay-transitions.ts | 451 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 demo/clay-transitions.ts diff --git a/demo/clay-transitions.ts b/demo/clay-transitions.ts new file mode 100644 index 0000000..bf05ffd --- /dev/null +++ b/demo/clay-transitions.ts @@ -0,0 +1,451 @@ +/** + * Clay-transitions demo — a port of the raylib-transitions example to clayterm. + * + * A grid of colored boxes that animate position, size, and background color. + * Press 's' to shuffle (animates position), 'c' to recolor (animates bg). + * Hover any box to see a bg-tint transition on mouse over. + * Press 'q' or Ctrl+C to quit. + * + * Omits enter/exit transitions and "Add Box" (v1 constraints). + * Overlay-color field is not yet in the v1 command buffer; hover tint is + * achieved by blending the bg color toward a highlight shade instead. + */ + +import { + createChannel, + each, + ensure, + main, + race, + resource, + sleep, + spawn, + type Stream, + until, +} from "effection"; +import { + close, + createTerm, + fixed, + grow, + type InputEvent, + type Op, + open, + type PointerEvent, + rgba, + text, +} from "../mod.ts"; +import { + alternateBuffer, + cursor, + mouseTracking, + settings, +} from "../settings.ts"; +import { useInput } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const DEFAULT_PALETTE = [ + rgba(225, 138, 50), + rgba(111, 173, 162), + rgba(184, 87, 134), + rgba(87, 134, 184), + rgba(134, 184, 87), + rgba(184, 134, 87), + rgba(87, 184, 134), + rgba(134, 87, 184), + rgba(200, 100, 100), + rgba(100, 200, 100), + rgba(100, 100, 200), + rgba(200, 200, 100), + rgba(200, 100, 200), + rgba(100, 200, 200), + rgba(180, 160, 80), + rgba(80, 160, 180), +]; + +const PINK_PALETTE = DEFAULT_PALETTE.map((c) => { + let r = (c >> 24) & 0xff; + let g = (c >> 16) & 0xff; + let b = (c >> 8) & 0xff; + let a = c & 0xff; + let pr = Math.min(255, r + 80); + let pg = Math.max(0, g - 60); + let pb = Math.max(0, Math.min(255, b + 40)); + return rgba(pr, pg, pb, a); +}); + +// Blend a packed rgba color toward white by ratio [0,1]. +function lighten(color: number, ratio: number): number { + let r = (color >> 24) & 0xff; + let g = (color >> 16) & 0xff; + let b = (color >> 8) & 0xff; + let a = color & 0xff; + return rgba( + Math.round(r + (255 - r) * ratio), + Math.round(g + (255 - g) * ratio), + Math.round(b + (255 - b) * ratio), + a, + ); +} + +// Lighten ratio applied to bg when box is hovered (blends toward white). +const HOVER_LIGHTEN = 0.35; + +const ROOT_BG = rgba(18, 18, 22); +const TOPBAR_BG = rgba(40, 40, 55); +const MODELINE_BG = rgba(30, 30, 45); +const BTN_DEFAULT = rgba(60, 60, 80); +const BTN_HOVER = rgba(90, 90, 120); +const KEY_COLOR = rgba(255, 220, 120); +const LABEL_COLOR = rgba(200, 200, 220); + +const COLS = 4; + +interface Box { + id: number; + color: number; +} + +interface State { + boxes: Box[]; + palette: "default" | "pink"; + entered: Set; + pointer: { x: number; y: number; down: boolean } | undefined; +} + +function fisherYates(arr: T[]): T[] { + let out = arr.slice(); + for (let i = out.length - 1; i > 0; i--) { + let j = Math.floor(Math.random() * (i + 1)); + let tmp = out[i]; + out[i] = out[j]; + out[j] = tmp; + } + return out; +} + +function recolor(boxes: Box[], palette: "default" | "pink"): Box[] { + let pal = palette === "pink" ? PINK_PALETTE : DEFAULT_PALETTE; + return boxes.map((b, i) => ({ ...b, color: pal[i % pal.length] })); +} + +function button( + id: string, + label: string, + hovered: boolean, + key: string, +): Op[] { + return [ + open(id, { + layout: { + direction: "ltr", + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + alignX: 2, + alignY: 2, + height: grow(), + }, + bg: hovered ? BTN_HOVER : BTN_DEFAULT, + border: hovered + ? { color: KEY_COLOR, left: 1, right: 1, top: 1, bottom: 1 } + : undefined, + }), + text(key, { color: KEY_COLOR }), + text(` ${label}`, { color: LABEL_COLOR }), + close(), + ]; +} + +function view(state: State): Op[] { + let ops: Op[] = []; + + ops.push( + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + bg: ROOT_BG, + }), + ); + + ops.push( + open("topbar", { + layout: { + width: grow(), + height: fixed(3), + direction: "ltr", + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + gap: 2, + alignY: 2, + }, + bg: TOPBAR_BG, + }), + ); + + ops.push( + ...button( + "btn:shuffle", + "shuffle", + state.entered.has("btn:shuffle"), + "s", + ), + ...button( + "btn:recolor", + "recolor", + state.entered.has("btn:recolor"), + "c", + ), + ...button("btn:quit", "quit", state.entered.has("btn:quit"), "q"), + ); + + ops.push(close()); + + ops.push( + open("grid", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + gap: 1, + }, + }), + ); + + let boxes = state.boxes; + let rows = Math.ceil(boxes.length / COLS); + + for (let r = 0; r < rows; r++) { + ops.push( + open(`row:${r}`, { + layout: { + width: grow(), + height: fixed(3), + direction: "ltr", + gap: 1, + }, + }), + ); + + for (let c = 0; c < COLS; c++) { + let i = r * COLS + c; + if (i >= boxes.length) { + break; + } + let b = boxes[i]; + let bid = `box:${b.id}`; + let hov = state.entered.has(bid); + let bg = hov ? lighten(b.color, HOVER_LIGHTEN) : b.color; + ops.push( + open(bid, { + layout: { + width: grow(), + height: grow(), + alignX: 2, + alignY: 2, + }, + bg, + transition: { + duration: 0.5, + easing: "easeOut", + properties: ["width", "position", "bg"], + interactive: true, + }, + }), + text(`${b.id < 10 ? "0" : ""}${b.id}`, { color: LABEL_COLOR }), + close(), + ); + } + + ops.push(close()); + } + + ops.push(close()); + + ops.push( + open("modeline", { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + padding: { left: 1, right: 1 }, + gap: 2, + }, + bg: MODELINE_BG, + }), + open("ml:s", { layout: { direction: "ltr", gap: 0 } }), + text("s", { color: KEY_COLOR }), + text(" shuffle", { color: LABEL_COLOR }), + close(), + open("ml:c", { layout: { direction: "ltr", gap: 0 } }), + text("c", { color: KEY_COLOR }), + text(" recolor", { color: LABEL_COLOR }), + close(), + open("ml:q", { layout: { direction: "ltr", gap: 0 } }), + text("q", { color: KEY_COLOR }), + text(" quit", { color: LABEL_COLOR }), + close(), + close(), + ); + + ops.push(close()); + + return ops; +} + +function ticker(flag: { animating: boolean }): Stream { + return resource(function* (provide) { + let ch = createChannel(); + yield* spawn(function* () { + while (true) { + if (flag.animating) { + yield* sleep(16); + yield* ch.send(); + } else { + yield* sleep(50); + } + } + }); + let sub = yield* ch; + yield* race([provide(sub), drain(ch)]); + }); +} + +function merge( + a: Stream, + b: Stream, +): Stream { + return resource(function* (provide) { + let sub = { + a: yield* a, + b: yield* b, + }; + return yield* provide({ + *next() { + return yield* race([sub.a.next(), sub.b.next()]); + }, + }); + }); +} + +function* drain(stream: Stream) { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} + +await main(function* () { + let { columns, rows } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 80, rows: 24 }; + + Deno.stdin.setRaw(true); + yield* ensure(() => Deno.stdin.setRaw(false)); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let term = yield* until(createTerm({ width: columns, height: rows })); + + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); + Deno.stdout.writeSync(tty.apply); + yield* ensure(() => { + Deno.stdout.writeSync(tty.revert); + }); + + let count = 16; + let pal = DEFAULT_PALETTE; + let initialBoxes: Box[] = Array.from({ length: count }, (_, i) => ({ + id: i, + color: pal[i % pal.length], + })); + + let state: State = { + boxes: initialBoxes, + palette: "default", + entered: new Set(), + pointer: undefined, + }; + + let flag = { animating: false }; + + function draw(): void { + let { output, animating, events } = term.render(view(state), { + pointer: state.pointer, + }); + flag.animating = animating; + for (let ev of events) { + if (ev.type === "pointerenter") { + state = { ...state, entered: new Set([...state.entered, ev.id]) }; + } else if (ev.type === "pointerleave") { + let next = new Set(state.entered); + next.delete(ev.id); + state = { ...state, entered: next }; + } + } + Deno.stdout.writeSync(output); + } + + draw(); + + let pointer = createChannel(); + let ticks = ticker(flag); + let events = merge(merge(input, pointer), ticks); + + for (let ev of yield* each(events)) { + if (ev !== undefined && typeof ev === "object" && "type" in ev) { + let e = ev as InputEvent | PointerEvent; + + if (e.type === "keydown") { + if (e.ctrl && e.key === "c") { + break; + } + if (e.key === "q") { + break; + } + if (e.key === "s") { + state = { ...state, boxes: fisherYates(state.boxes) }; + } + if (e.key === "c") { + let next: "default" | "pink" = state.palette === "default" + ? "pink" + : "default"; + state = { + ...state, + palette: next, + boxes: recolor(state.boxes, next), + }; + } + } + + if ("x" in e && "y" in e) { + let me = e as { x: number; y: number; type: string }; + state = { + ...state, + pointer: { + x: me.x, + y: me.y, + down: me.type === "mousedown", + }, + }; + } + } + + let { output, animating, events: pevents } = term.render(view(state), { + pointer: state.pointer, + }); + flag.animating = animating; + + for (let pev of pevents) { + if (pev.type === "pointerenter") { + state = { ...state, entered: new Set([...state.entered, pev.id]) }; + } else if (pev.type === "pointerleave") { + let next = new Set(state.entered); + next.delete(pev.id); + state = { ...state, entered: next }; + } + yield* pointer.send(pev); + } + + Deno.stdout.writeSync(output); + + yield* each.next(); + } +}); From 10d38abe34a6402a7c0e7b7024cb474112482724 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 21:10:32 -0500 Subject: [PATCH 23/29] =?UTF-8?q?=F0=9F=8E=A8=20let=20clay-transitions=20d?= =?UTF-8?q?emo=20rows=20fill=20available=20height?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/clay-transitions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/clay-transitions.ts b/demo/clay-transitions.ts index bf05ffd..1550323 100644 --- a/demo/clay-transitions.ts +++ b/demo/clay-transitions.ts @@ -217,7 +217,7 @@ function view(state: State): Op[] { open(`row:${r}`, { layout: { width: grow(), - height: fixed(3), + height: grow(), direction: "ltr", gap: 1, }, From 8730e55d9d25e72dc631eea25c703b54af1d4f72 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 22 Apr 2026 22:21:14 -0500 Subject: [PATCH 24/29] =?UTF-8?q?=F0=9F=8E=A8=20remove=20modeline=20from?= =?UTF-8?q?=20clay-transitions=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/clay-transitions.ts | 33 +++------------------------------ demo/transitions.ts | 2 +- 2 files changed, 4 insertions(+), 31 deletions(-) diff --git a/demo/clay-transitions.ts b/demo/clay-transitions.ts index 1550323..7017bae 100644 --- a/demo/clay-transitions.ts +++ b/demo/clay-transitions.ts @@ -93,7 +93,6 @@ const HOVER_LIGHTEN = 0.35; const ROOT_BG = rgba(18, 18, 22); const TOPBAR_BG = rgba(40, 40, 55); -const MODELINE_BG = rgba(30, 30, 45); const BTN_DEFAULT = rgba(60, 60, 80); const BTN_HOVER = rgba(90, 90, 120); const KEY_COLOR = rgba(255, 220, 120); @@ -243,8 +242,8 @@ function view(state: State): Op[] { }, bg, transition: { - duration: 0.5, - easing: "easeOut", + duration: 0.4, + easing: "easeInOut", properties: ["width", "position", "bg"], interactive: true, }, @@ -259,32 +258,6 @@ function view(state: State): Op[] { ops.push(close()); - ops.push( - open("modeline", { - layout: { - width: grow(), - height: fixed(1), - direction: "ltr", - padding: { left: 1, right: 1 }, - gap: 2, - }, - bg: MODELINE_BG, - }), - open("ml:s", { layout: { direction: "ltr", gap: 0 } }), - text("s", { color: KEY_COLOR }), - text(" shuffle", { color: LABEL_COLOR }), - close(), - open("ml:c", { layout: { direction: "ltr", gap: 0 } }), - text("c", { color: KEY_COLOR }), - text(" recolor", { color: LABEL_COLOR }), - close(), - open("ml:q", { layout: { direction: "ltr", gap: 0 } }), - text("q", { color: KEY_COLOR }), - text(" quit", { color: LABEL_COLOR }), - close(), - close(), - ); - ops.push(close()); return ops; @@ -296,7 +269,7 @@ function ticker(flag: { animating: boolean }): Stream { yield* spawn(function* () { while (true) { if (flag.animating) { - yield* sleep(16); + yield* sleep(2); yield* ch.send(); } else { yield* sleep(50); diff --git a/demo/transitions.ts b/demo/transitions.ts index 5a884dd..c1f178b 100644 --- a/demo/transitions.ts +++ b/demo/transitions.ts @@ -108,7 +108,7 @@ function view(state: State): Op[] { }, bg: state.menuOpen ? SIDEBAR_BG_OPEN : SIDEBAR_BG_CLOSED, transition: { - duration: 0.25, + duration: 0.2, easing: "easeInOut", properties: ["width", "bg"], }, From c6c7f87f5b0eeabb979ddd0aeff5af90de4a857a Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 23 Apr 2026 11:20:09 -0500 Subject: [PATCH 25/29] =?UTF-8?q?=F0=9F=93=9D=20note=20ct=5Factive=5Fconte?= =?UTF-8?q?xt=20is=20a=20workaround=20for=20Clay=20userData=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/clayterm.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/clayterm.c b/src/clayterm.c index 64f705d..38348df 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -20,6 +20,12 @@ #include "utf8.h" #include "wcwidth.h" +/* Module-level pointer to the Term currently executing reduce(). + * Set/cleared around each render pass so transition handlers (which Clay + * invokes with no userData — see Clay_TransitionCallbackArguments) can + * report back to the right Term's animating_count. Revisit once + * nicbarker/clay#603 lands userData on transition callbacks; then the + * handler can resolve its Term from args directly and this can go away. */ struct Clayterm *ct_active_context = NULL; /* ── Command buffer protocol ──────────────────────────────────────── */ From b7eb6bbf45982fbcb330df9f6ce33401f6180435 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 23 Apr 2026 11:22:06 -0500 Subject: [PATCH 26/29] =?UTF-8?q?=F0=9F=8E=A8=20use=20border-only=20boxes?= =?UTF-8?q?=20in=20clay-transitions=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/clay-transitions.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/demo/clay-transitions.ts b/demo/clay-transitions.ts index 7017bae..77f7ce8 100644 --- a/demo/clay-transitions.ts +++ b/demo/clay-transitions.ts @@ -231,7 +231,7 @@ function view(state: State): Op[] { let b = boxes[i]; let bid = `box:${b.id}`; let hov = state.entered.has(bid); - let bg = hov ? lighten(b.color, HOVER_LIGHTEN) : b.color; + let borderColor = hov ? lighten(b.color, HOVER_LIGHTEN) : b.color; ops.push( open(bid, { layout: { @@ -240,15 +240,21 @@ function view(state: State): Op[] { alignX: 2, alignY: 2, }, - bg, + border: { + color: borderColor, + left: 1, + right: 1, + top: 1, + bottom: 1, + }, transition: { duration: 0.4, easing: "easeInOut", - properties: ["width", "position", "bg"], + properties: ["width", "position", "borderColor"], interactive: true, }, }), - text(`${b.id < 10 ? "0" : ""}${b.id}`, { color: LABEL_COLOR }), + text(`${b.id < 10 ? "0" : ""}${b.id}`, { color: b.color }), close(), ); } From 03058a3bffa428a532b650d14e526d6626fe1f4b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 23 Apr 2026 11:26:12 -0500 Subject: [PATCH 27/29] =?UTF-8?q?=F0=9F=8E=A8=20prevent=20menu=20text=20fr?= =?UTF-8?q?om=20wrapping=20during=20sidebar=20transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/transitions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/demo/transitions.ts b/demo/transitions.ts index c1f178b..96a8620 100644 --- a/demo/transitions.ts +++ b/demo/transitions.ts @@ -107,6 +107,7 @@ function view(state: State): Op[] { gap: 1, }, bg: state.menuOpen ? SIDEBAR_BG_OPEN : SIDEBAR_BG_CLOSED, + clip: { horizontal: true }, transition: { duration: 0.2, easing: "easeInOut", @@ -118,13 +119,13 @@ function view(state: State): Op[] { if (state.menuOpen) { ops.push( open("menu-title", { layout: { height: fixed(1) } }), - text("Menu", { color: HEADING }), + text("Menu", { color: HEADING, wrap: 2 }), close(), ); for (let item of MENU_ITEMS) { ops.push( open(`menu:${item}`, { layout: { height: fixed(1) } }), - text(item, { color: item === "—" ? DIM : MENU_ITEM }), + text(item, { color: item === "—" ? DIM : MENU_ITEM, wrap: 2 }), close(), ); } From 949dea5ec0033102ed2b3e1421e31beda399f2e2 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 23 Apr 2026 11:58:25 -0500 Subject: [PATCH 28/29] =?UTF-8?q?=F0=9F=94=A5=20drop=20unused=20grow=20imp?= =?UTF-8?q?ort=20in=20transitions-run=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/transitions-run.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/transitions-run.test.ts b/test/transitions-run.test.ts index f3eda68..16d77eb 100644 --- a/test/transitions-run.test.ts +++ b/test/transitions-run.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "./suite.ts"; -import { close, createTerm, fixed, grow, type Op, open, rgba } from "../mod.ts"; +import { close, createTerm, fixed, type Op, open, rgba } from "../mod.ts"; describe("transition lifecycle", () => { it("animates bg change between frames", async () => { From 2bd87408350311e2fb394557e579094e9157ab00 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sat, 13 Jun 2026 14:53:30 -0400 Subject: [PATCH 29/29] Update transitions branch with upstream changes (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ add snapshot() for pre-packing directive subtrees Introduces a new `snapshot(ops)` constructor that pre-packs a directive array into its transfer encoding. The returned opaque `Op` can be spliced into any directive array, and during packing its bytes are copied directly into the command buffer without re-encoding. This enables higher-level frameworks to implement dirty tracking: unchanged component subtrees can reuse a cached snapshot, skipping the per-frame packing cost entirely. * docs: add maintainer build guide * 💄 format build docs * 🐛 improve pack string overflow errors * os matrix test in ci (#36) * 🧼 optimize build * 🧼 compress bundled wasm * 🧼 optimize wcwidth.c size * ⚙️ update npm settings * 🐛 install wasm-opt in ci * ⚡ use brotli-11 + z85 wasm encoding * 📌 pin @types/node to v22 * 🧼 apply @ghostdevv review suggestions from PR #35 Co-Authored-By: ghostdevv * 🔨 add type to bundle-wasm * 💌 signed, sealed, delivered * chore: use hashes for versions * chore: don't save git credentials * chore: use array syntax for some reason the schema for the actions wants it to be an array * perf: set concurrency limits to reduce cost and improve dx Without this it means that, for example, if I push a change to a PR then shortly push again this workflow will be running twice. This change will cancel the old run before starting the new one, which reduces the overall actions cost and DX as you don't have extra runs * chore: use hashes for versions * chore: update node version * perf: set concurrency limits to reduce cost and improve dx Without this it means that, for example, if I push a change to a PR then shortly push again this workflow will be running twice. This change will cancel the old run before starting the new one, which reduces the overall actions cost and DX as you don't have extra runs * chore: don't save git credentials * chore: mitigate potential template injection See https://docs.zizmor.sh/audits/#template-injection * chore: update ::set-output command to new syntax https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ * chore: use hashes for versions * chore: don't save git credentials * chore: limit id-token permission to the publishing steps * chore: explicitly disable npm cache to mitigate cache poisoning attacks * chore: mitigate potential template injection See https://docs.zizmor.sh/audits/#template-injection * chore: use oidc This should be using OIDC for publishing * chore: update ::set-output command to new syntax https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ * 🙅 revert aggressive optimization experiments pending benchmark Reverts three changes that need benchmarks before landing: - -Oz / wasm-opt - brotli+Z85 wasm compression - wcwidth.c rewrite * Add CodSpeed performance benchmarks * 🔨 use deno * 🧼 deno fmt * ⚙️ vitest -> tinybench * 🧼 remove codspeed assets * 🔨 fix ci * add type module * fmt * chore: update github url (#38) * downgrade to tinybench@5 * move to examples folder with readme * 🔧 export animating from wasm build * 🧪 cover transitions in snapshots and validation * ✅ enforce nonnegative transition duration --------- Co-authored-by: Charles Lowell Co-authored-by: Jacob Bolda Co-authored-by: Nate Moore Co-authored-by: ghostdevv Co-authored-by: Nate Moore Co-authored-by: codspeed-hq[bot] <117304815+codspeed-hq[bot]@users.noreply.github.com> Co-authored-by: Nate Moore --- .github/workflows/benchmark.yml | 48 +++ .github/workflows/preview.yml | 20 +- .github/workflows/publish.yml | 54 ++-- .github/workflows/verify.yaml | 66 +++- BUILD.md | 296 ++++++++++++++++++ Makefile | 35 ++- README.md | 57 ++-- bench/input.bench.ts | 55 ++++ bench/mod.ts | 3 + bench/ops.bench.ts | 124 ++++++++ bench/render.bench.ts | 158 ++++++++++ deno.json | 7 +- deno.lock | 214 ++++++++++++- examples/README.md | 63 ++++ .../inline-regions/index.ts | 6 +- .../keyboard}/clay-transitions.ts | 4 +- .../keyboard.ts => examples/keyboard/index.ts | 4 +- .../keyboard}/keyboard-key-events.gif | Bin .../keyboard}/keyboard-pointer-events.gif | Bin {demo => examples/keyboard}/transitions.ts | 4 +- {demo => examples/keyboard}/use-input.ts | 2 +- {demo => examples/keyboard}/use-stdin.ts | 0 ops.ts | 78 ++++- package.json | 3 + specs/renderer-spec.md | 31 ++ tasks/build-npm.ts | 10 +- test/pack.test.ts | 52 +++ test/term.test.ts | 104 +++++- test/transitions-pack.test.ts | 11 +- test/validate.test.ts | 31 ++ validate.ts | 29 ++ 31 files changed, 1478 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/benchmark.yml create mode 100644 BUILD.md create mode 100644 bench/input.bench.ts create mode 100644 bench/mod.ts create mode 100644 bench/ops.bench.ts create mode 100644 bench/render.bench.ts create mode 100644 examples/README.md rename demo/inline-region.ts => examples/inline-regions/index.ts (98%) rename {demo => examples/keyboard}/clay-transitions.ts (99%) rename demo/keyboard.ts => examples/keyboard/index.ts (99%) rename {demo => examples/keyboard}/keyboard-key-events.gif (100%) rename {demo => examples/keyboard}/keyboard-pointer-events.gif (100%) rename {demo => examples/keyboard}/transitions.ts (98%) rename {demo => examples/keyboard}/use-input.ts (98%) rename {demo => examples/keyboard}/use-stdin.ts (100%) create mode 100644 package.json create mode 100644 test/pack.test.ts diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..123abef --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,48 @@ +name: Benchmark + +on: + push: + branches: [main] + pull_request: + branches: [main] + # `workflow_dispatch` allows CodSpeed to trigger backtest + # performance analysis in order to generate initial data. + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build WASM + run: make + + - name: Install dependencies + run: deno install + + - name: Run benchmarks + uses: CodSpeedHQ/action@v4 + with: + mode: simulation + # IMPORTANT! deno task bench fails in CI due to incompatible V8 bindings + run: node bench/mod.ts diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index dee9fab..5fc0b91 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -5,19 +5,24 @@ on: [pull_request] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: preview: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -26,16 +31,17 @@ jobs: - name: Get Version id: vars - run: echo ::set-output name=version::$(git describe --abbrev=0 --tags | sed 's/^v//')-pr+$(git rev-parse HEAD) + run: echo "version=$(git describe --abbrev=0 --tags | sed 's/^v//')-pr+$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 20.x - registry-url: https://registry.npmjs.com + node-version: 24 - name: Build NPM - run: deno task build:npm ${{steps.vars.outputs.version}} + run: deno task build:npm "${STEPS_VARS_OUTPUTS_VERSION}" + env: + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} - name: Publish Preview Versions run: npx pkg-pr-new publish './build/npm' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 800f1d4..c130955 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,19 +7,19 @@ on: permissions: contents: read - id-token: write jobs: verify-jsr: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -28,10 +28,12 @@ jobs: - name: Get Version id: vars - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT - name: Build JSR - run: deno task build:jsr ${{steps.vars.outputs.version}} + run: deno task build:jsr "${STEPS_VARS_OUTPUTS_VERSION}" + env: + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} - name: dry run publish run: deno publish --dry-run --allow-dirty @@ -40,12 +42,13 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -54,22 +57,26 @@ jobs: - name: Get Version id: vars - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + package-manager-cache: false + cache: "" - name: Build NPM - run: deno task build:npm ${{steps.vars.outputs.version}} + run: deno task build:npm "${STEPS_VARS_OUTPUTS_VERSION}" + env: + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} - name: dry run publish run: npm publish --dry-run --tag=verify working-directory: ./build/npm - name: upload build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: npm-build path: ./build/npm @@ -77,15 +84,20 @@ jobs: publish-npm: needs: [verify-jsr, verify-npm] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + package-manager-cache: false + cache: "" - name: download build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: npm-build path: ./build/npm @@ -97,15 +109,19 @@ jobs: publish-jsr: needs: [verify-jsr, verify-npm] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -114,10 +130,12 @@ jobs: - name: Get Version id: vars - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT - name: Build JSR - run: deno task build:jsr ${{steps.vars.outputs.version}} + run: deno task build:jsr "${STEPS_VARS_OUTPUTS_VERSION}" + env: + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} - name: Publish JSR - run: deno publish --allow-dirty --token=${{ secrets.JSR_TOKEN }} + run: deno publish --allow-dirty diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 3ec7956..09bce38 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -2,25 +2,32 @@ name: Verify on: push: - branches: main + branches: + - main pull_request: - branches: main + branches: + - main permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -33,6 +40,45 @@ jobs: - name: build wasm run: make + - name: upload wasm artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clayterm-wasm + path: | + clayterm.wasm + wasm.ts + + test-alt-os: + needs: test + strategy: + matrix: + os: + - name: macos + value: macos-latest + - name: windows + value: windows-latest + fail-fast: false + runs-on: ${{ matrix.os.value }} + name: test ${{ matrix.os.name }} + + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + persist-credentials: false + + - name: setup deno + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: download wasm artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: clayterm-wasm + path: . + - name: test run: deno task test @@ -41,12 +87,13 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -64,17 +111,18 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..4946ba1 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,296 @@ +# Building clayterm from source + +This guide is for maintainers and builders working on clayterm itself. + +It covers: + +- cloning the repo correctly, +- initializing the `clay` git submodule, +- installing the toolchain needed to compile the C sources to WebAssembly, +- building the local development artifacts, and +- verifying that the repo is ready for development. + +It does **not** cover npm/JSR packaging or publishing. + +## What the local build produces + +The local source build is driven by `make`. + +It generates: + +- `clayterm.wasm` — the compiled WebAssembly module built from the C sources +- `wasm.ts` — a generated TypeScript file derived from `clayterm.wasm` + +`wasm.ts` is generated output, not hand-maintained source. + +## Clone the repo with submodules + +The build depends on the `clay` git submodule. + +Preferred fresh clone: + +```sh +git clone --recurse-submodules https://github.com/bombshell-dev/clayterm.git +cd clayterm +``` + +If you already cloned without submodules: + +```sh +git submodule update --init --recursive +``` + +Quick check: + +```sh +git submodule status --recursive +``` + +You should also see a populated `clay/` directory. If `clay/` is missing or +empty, fix the submodule state before building. + +## Required tools + +You need: + +- `git` +- `make` +- `clang` with wasm32-capable support +- `deno` + +Equivalent packages are fine if your package manager uses different names. + +## Install the toolchain + +### macOS + +Install Apple's command line tools first. They provide the base developer tools, +including `git` and `make`. + +```sh +xcode-select --install +``` + +Then install LLVM and Deno with Homebrew: + +```sh +brew install llvm deno +``` + +Use Homebrew LLVM before Apple's system `clang` when building clayterm: + +```sh +echo 'export PATH="$(brew --prefix llvm)/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc +``` + +If you do not already have `git` available after installing the command line +tools, install it with Homebrew: + +```sh +brew install git +``` + +### Debian / Ubuntu + +Install the build toolchain and Git: + +```sh +sudo apt-get update +sudo apt-get install -y build-essential clang lld git curl +``` + +Install Deno with the official installer: + +```sh +curl -fsSL https://deno.land/install.sh | sh +``` + +Add Deno to your shell path: + +```sh +echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +### Fedora / RHEL + +Install the build toolchain and Git: + +```sh +sudo dnf install -y clang lld make git curl +``` + +Install Deno with the official installer: + +```sh +curl -fsSL https://deno.land/install.sh | sh +``` + +Add Deno to your shell path: + +```sh +echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +### Windows + +The recommended Windows build-host path is **WSL2 with Ubuntu**. + +From an elevated PowerShell prompt: + +```powershell +wsl --install -d Ubuntu +``` + +Then open the Ubuntu environment and follow the **Debian / Ubuntu** instructions +above. + +The build host runs inside WSL2, but the resulting WebAssembly artifacts are +intended to run on **native Windows** at runtime. + +## Verify the toolchain + +Before building, confirm the required tools are available: + +```sh +git --version +make --version +clang --version +deno --version +``` + +For a quick wasm-target smoke test, make sure `clang` can compile for `wasm32`: + +```sh +clang --target=wasm32 -c -x c /dev/null -o /tmp/clayterm-wasm-test.o +rm -f /tmp/clayterm-wasm-test.o +``` + +On macOS, if `which clang` still points to `/usr/bin/clang` and the wasm test +fails, make sure the Homebrew LLVM `bin/` directory is at the front of your +`PATH`. + +## Build from source + +Run the local source build from the repository root: + +```sh +make +``` + +This should produce: + +- `clayterm.wasm` +- `wasm.ts` + +For a clean rebuild: + +```sh +make clean && make +``` + +## When to rebuild + +Re-run `make` when: + +- you change files under `src/` +- you update the `clay` submodule +- `clayterm.wasm` or `wasm.ts` is missing +- generated outputs look stale after switching branches or pulling changes + +When in doubt, use a clean rebuild: + +```sh +make clean && make +``` + +## Verify the build + +After `make` succeeds, run the test suite: + +```sh +deno task test +``` + +Before opening a PR, it is also a good idea to run the same checks CI runs: + +```sh +deno task fmt:check +deno lint +``` + +## Troubleshooting + +### `clay/` is missing or empty + +Symptoms may include build failures such as: + +- `fatal error: '../clay/clay.h' file not found` + +Recovery: + +```sh +git submodule update --init --recursive +``` + +Then verify the submodule state and rebuild: + +```sh +git submodule status --recursive +make clean && make +``` + +### `clang` cannot target `wasm32` + +Symptoms may include: + +- target-related `clang` errors mentioning `wasm32` +- linker failures while producing `clayterm.wasm` + +Recovery: + +- make sure you are using an LLVM/Clang build with wasm support +- on macOS, prefer the Homebrew `llvm` toolchain over `/usr/bin/clang` +- on Linux/WSL2, make sure both `clang` and `lld` are installed +- rerun the wasm smoke test: + +```sh +clang --target=wasm32 -c -x c /dev/null -o /tmp/clayterm-wasm-test.o +rm -f /tmp/clayterm-wasm-test.o +``` + +If the smoke test fails, fix the toolchain first and only then rerun `make`. + +### Generated artifacts are missing or stale + +Symptoms may include: + +- `clayterm.wasm` is missing +- `wasm.ts` is missing +- you changed `src/` or updated `clay/`, but the generated outputs do not match + +Recovery: + +```sh +make clean && make +``` + +Then verify the repo is in a good state: + +```sh +deno task test +``` + +## Scope note + +This document is intentionally limited to local source builds for development. + +Out of scope: + +- `deno task build:npm` +- `deno task build:jsr` +- `npm publish` +- `deno publish` +- release tagging and package publishing workflows diff --git a/Makefile b/Makefile index bdd0d97..05a2dd1 100644 --- a/Makefile +++ b/Makefile @@ -3,18 +3,47 @@ TARGET = clayterm.wasm SRC = src/module.c CFLAGS = --target=wasm32 -nostdlib -O2 \ + -ffunction-sections -fdata-sections \ + -mbulk-memory \ -DCLAY_IMPLEMENTATION -DCLAY_WASM \ -Isrc -I. +EXPORTS = \ + -Wl,--export=__heap_base \ + -Wl,--export=clayterm_size \ + -Wl,--export=init \ + -Wl,--export=reduce \ + -Wl,--export=output \ + -Wl,--export=length \ + -Wl,--export=measure \ + -Wl,--export=Clay_SetPointerState \ + -Wl,--export=pointer_over_count \ + -Wl,--export=pointer_over_id_string_length \ + -Wl,--export=pointer_over_id_string_ptr \ + -Wl,--export=get_element_bounds \ + -Wl,--export=animating \ + -Wl,--export=error_count \ + -Wl,--export=error_type \ + -Wl,--export=error_message_length \ + -Wl,--export=error_message_ptr \ + -Wl,--export=input_size \ + -Wl,--export=input_init \ + -Wl,--export=input_scan \ + -Wl,--export=input_count \ + -Wl,--export=input_event \ + -Wl,--export=input_delay + LDFLAGS = -Wl,--no-entry \ -Wl,--import-memory \ -Wl,--stack-first \ - -Wl,--export-all \ + -Wl,--strip-all \ + -Wl,--gc-sections \ -Wl,--undefined=Clay__MeasureText \ - -Wl,--undefined=Clay__QueryScrollOffset + -Wl,--undefined=Clay__QueryScrollOffset \ + $(EXPORTS) all: $(TARGET) wasm.ts - @echo "Built $(TARGET) ($$(wc -c < $(TARGET)) bytes)" + @echo "Built $(TARGET) ($$(wc -c < $(TARGET)) bytes raw, $$(gzip -c $(TARGET) | wc -c) bytes gzip)" DEPS = $(wildcard src/*.c src/*.h) diff --git a/README.md b/README.md index 6af34a3..7e2fd70 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ computation. will run anywhere JavaScript runs with no native dependencies, and no build step for consumers. -### Demo +### Examples The application in this demo uses Clayterm for all layout and input parsing @@ -30,14 +30,14 @@ The application in this demo uses Clayterm for all layout and input parsing The input parser decodes raw terminal bytes into structured events. Here you can see each key event as the string "hello world" is typed. -![Keyboard events demo](demo/keyboard-key-events.gif) +![Keyboard events demo](examples/keyboard/keyboard-key-events.gif) #### Pointer Events Here we see hover styles applied to UI elements in response to the pointer state. Clay drives the hit testing; no manual coordinate math required. -![Pointer events demo](demo/keyboard-pointer-events.gif) +![Pointer events demo](examples/keyboard/keyboard-pointer-events.gif) ## Architecture @@ -149,25 +149,28 @@ Pass pointer state to `render()` to have clayterm do hit detection and return pointer events in addition to the byte sequence. ```typescript -let { output, events } = term.render([ - open("root", { - layout: { width: grow(), height: grow(), direction: "ltr" }, - }), - open("sidebar", { - layout: { width: fixed(20), height: grow() }, - bg: rgba(30, 30, 40), - }), - text("Sidebar"), - close(), - open("main", { - layout: { width: grow(), height: grow() }, - }), - text("Main content"), - close(), - close(), -], { - pointer: { x: mouseX, y: mouseY, down: mouseDown }, -}); +let { output, events } = term.render( + [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("sidebar", { + layout: { width: fixed(20), height: grow() }, + bg: rgba(30, 30, 40), + }), + text("Sidebar"), + close(), + open("main", { + layout: { width: grow(), height: grow() }, + }), + text("Main content"), + close(), + close(), + ], + { + pointer: { x: mouseX, y: mouseY, down: mouseDown }, + }, +); for (let event of events) { // { type: "pointerenter", id: "sidebar" } @@ -212,16 +215,12 @@ process.stdin.on("data", (buf) => { ## Development -Requires `clang` with wasm32 target support. +For local source builds, toolchain setup, and `clay` submodule instructions, see +[BUILD.md](BUILD.md). -First build the `.wasm` +Quick local validation: ```sh make -``` - -run tests - -```sh deno task test ``` diff --git a/bench/input.bench.ts b/bench/input.bench.ts new file mode 100644 index 0000000..8e2c96b --- /dev/null +++ b/bench/input.bench.ts @@ -0,0 +1,55 @@ +import { Bench } from "tinybench"; +import { withCodSpeed } from "@codspeed/tinybench-plugin"; +import { createInput } from "../input.ts"; + +function bytes(...values: number[]): Uint8Array { + return new Uint8Array(values); +} + +function str(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +let input = await createInput({ escLatency: 25 }); + +let longBurst = new Uint8Array(200); +for (let i = 0; i < 200; i++) { + longBurst[i] = 0x61 + (i % 26); +} + +let bench = withCodSpeed(new Bench()); + +bench + .add("printable ASCII (single char)", () => { + input.scan(bytes(0x61)); + }) + .add("printable ASCII (short string)", () => { + input.scan(str("hello world")); + }) + .add("arrow key (CSI sequence)", () => { + input.scan(bytes(0x1b, 0x5b, 0x41)); + }) + .add("modifier combo (Ctrl+Shift+Arrow)", () => { + input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x38, 0x41)); + }) + .add("SGR mouse press", () => { + input.scan(str("\x1b[<0;35;12M")); + }) + .add("multi-event burst (arrows + text)", () => { + input.scan(bytes(0x1b, 0x5b, 0x41, 0x1b, 0x5b, 0x42, 0x68, 0x69)); + }) + .add("UTF-8 3-byte character", () => { + input.scan(bytes(0xe4, 0xb8, 0xad)); + }) + .add("UTF-8 4-byte emoji", () => { + input.scan(bytes(0xf0, 0x9f, 0x8e, 0x89)); + }) + .add("Kitty protocol (CSI u with modifiers)", () => { + input.scan(str("\x1b[97;3u")); + }) + .add("long input burst (200 bytes)", () => { + input.scan(longBurst); + }); + +await bench.run(); +console.table(bench.table()); diff --git a/bench/mod.ts b/bench/mod.ts new file mode 100644 index 0000000..ca9de34 --- /dev/null +++ b/bench/mod.ts @@ -0,0 +1,3 @@ +import "./input.bench.ts"; +import "./render.bench.ts"; +import "./ops.bench.ts"; diff --git a/bench/ops.bench.ts b/bench/ops.bench.ts new file mode 100644 index 0000000..3019792 --- /dev/null +++ b/bench/ops.bench.ts @@ -0,0 +1,124 @@ +import { Bench } from "tinybench"; +import { withCodSpeed } from "@codspeed/tinybench-plugin"; +import { close, fixed, grow, open, pack, rgba, text } from "../ops.ts"; +import type { Op } from "../ops.ts"; + +function makeBuf(size: number): ArrayBuffer { + return new ArrayBuffer(size); +} + +let simpleOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hello, World!"), + close(), +]; + +let complexOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("header", { + layout: { + width: grow(), + height: fixed(3), + padding: { left: 1, right: 1 }, + direction: "ltr", + }, + bg: rgba(30, 30, 40), + border: { + color: rgba(100, 100, 120), + bottom: 1, + }, + }), + text("Title", { color: rgba(255, 255, 255), fontSize: 1 }), + close(), + open("body", { + layout: { + width: grow(), + height: grow(), + direction: "ltr", + gap: 1, + }, + }), + open("sidebar", { + layout: { + width: fixed(20), + height: grow(), + direction: "ttb", + padding: { left: 1, right: 1, top: 1 }, + }, + bg: rgba(25, 25, 35), + border: { + color: rgba(60, 60, 80), + right: 1, + }, + }), + text("Menu Item 1"), + text("Menu Item 2"), + text("Menu Item 3"), + close(), + open("main", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 2, top: 1 }, + }, + }), + text("Main content area with longer text to exercise the encoder"), + close(), + close(), + open("footer", { + layout: { + width: grow(), + height: fixed(1), + padding: { left: 1 }, + direction: "ltr", + }, + bg: rgba(30, 30, 40), + }), + text("Status: OK"), + close(), + close(), +]; + +let listOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ...Array.from({ length: 50 }, (_, i) => [ + open(`item-${i}`, { + layout: { + width: grow(), + height: fixed(1), + padding: { left: 2 }, + direction: "ltr", + }, + bg: i % 2 === 0 ? rgba(30, 30, 40) : rgba(35, 35, 45), + }), + text(`List item ${i}: some description text`), + close(), + ]).flat(), + close(), +]; + +let bench = withCodSpeed(new Bench()); + +bench + .add("simple tree (root + text)", () => { + let buf = makeBuf(4096); + pack(simpleOps, buf, 0); + }) + .add("complex layout (header + sidebar + main + footer)", () => { + let buf = makeBuf(8192); + pack(complexOps, buf, 0); + }) + .add("large list (50 items)", () => { + let buf = makeBuf(32768); + pack(listOps, buf, 0); + }); + +await bench.run(); +console.table(bench.table()); diff --git a/bench/render.bench.ts b/bench/render.bench.ts new file mode 100644 index 0000000..1c3b8c3 --- /dev/null +++ b/bench/render.bench.ts @@ -0,0 +1,158 @@ +import { Bench } from "tinybench"; +import { withCodSpeed } from "@codspeed/tinybench-plugin"; +import { createTerm } from "../term.ts"; +import { close, fixed, grow, open, rgba, text } from "../ops.ts"; +import type { Op } from "../ops.ts"; + +let term = await createTerm({ width: 80, height: 24 }); +let termPtr = await createTerm({ width: 80, height: 24 }); + +let helloOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hello, World!"), + close(), +]; + +let borderedOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("box", { + layout: { + width: grow(), + height: grow(), + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + direction: "ttb", + }, + border: { + color: rgba(0, 255, 0), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + }), + text("Bordered content"), + close(), + close(), +]; + +let dashboardOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("header", { + layout: { + width: grow(), + height: fixed(3), + padding: { left: 1 }, + direction: "ltr", + }, + bg: rgba(30, 30, 40), + border: { color: rgba(80, 80, 100), bottom: 1 }, + }), + text("Dashboard", { color: rgba(255, 255, 255) }), + close(), + open("body", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("sidebar", { + layout: { + width: fixed(20), + height: grow(), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + bg: rgba(25, 25, 35), + border: { color: rgba(60, 60, 80), right: 1 }, + }), + text("Nav 1"), + text("Nav 2"), + text("Nav 3"), + text("Nav 4"), + close(), + open("main", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 2, top: 1 }, + }, + }), + ...Array.from({ length: 10 }, (_, i) => [ + open(`row-${i}`, { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + }, + bg: i % 2 === 0 ? rgba(35, 35, 45) : undefined, + }), + text(`Row ${i}: data value ${i * 42}`), + close(), + ]).flat(), + close(), + close(), + open("footer", { + layout: { + width: grow(), + height: fixed(1), + padding: { left: 1 }, + }, + bg: rgba(30, 30, 40), + }), + text("Ready"), + close(), + close(), +]; + +let uiOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("button", { + layout: { + width: fixed(20), + height: fixed(3), + padding: { left: 1, right: 1 }, + }, + bg: rgba(50, 50, 200), + border: { + color: rgba(100, 100, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + }), + text("Click me"), + close(), + close(), +]; + +let bench = withCodSpeed(new Bench()); + +bench + .add("simple text", () => { + term.render(helloOps); + }) + .add("bordered box with corner radius", () => { + term.render(borderedOps); + }) + .add("dashboard layout", () => { + term.render(dashboardOps); + }) + .add("diff render (second frame)", () => { + term.render(dashboardOps); + term.render(dashboardOps); + }) + .add("render with pointer hit testing", () => { + termPtr.render(uiOps, { pointer: { x: 10, y: 1, down: false } }); + }); + +await bench.run(); +console.table(bench.table()); diff --git a/deno.json b/deno.json index fab9ce2..901feb9 100644 --- a/deno.json +++ b/deno.json @@ -7,7 +7,7 @@ "fmt:check": "deno fmt --check && clang-format --dry-run --Werror src/*.c src/*.h", "build:npm": "deno run -A tasks/build-npm.ts", "build:jsr": "deno run -A tasks/build-jsr.ts", - "demo": "deno run demo/keyboard.ts" + "bench": "deno run -A bench/mod.ts" }, "imports": { "@std/testing": "jsr:@std/testing@1", @@ -15,7 +15,9 @@ "@sinclair/typebox": "npm:@sinclair/typebox@^0.34", "dnt": "jsr:@deno/dnt@0.42.3", "effection": "npm:effection@^4.0.2", - "@std/encoding": "jsr:@std/encoding@1" + "@std/encoding": "jsr:@std/encoding@1", + "@codspeed/tinybench-plugin": "npm:@codspeed/tinybench-plugin@^5.4.0", + "tinybench": "npm:tinybench@^5.0.0" }, "exports": { ".": "./mod.ts", @@ -25,6 +27,7 @@ "include": ["*.ts"], "exclude": ["!wasm.ts"] }, + "nodeModulesDir": "auto", "fmt": { "exclude": ["clay", "build"] }, diff --git a/deno.lock b/deno.lock index 7947a80..d0f8ef2 100644 --- a/deno.lock +++ b/deno.lock @@ -20,9 +20,11 @@ "jsr:@std/testing@1": "1.0.17", "jsr:@ts-morph/bootstrap@0.27": "0.27.0", "jsr:@ts-morph/common@0.27": "0.27.0", + "npm:@codspeed/tinybench-plugin@^5.4.0": "5.4.0_tinybench@5.1.0", "npm:@sinclair/typebox@*": "0.34.48", "npm:@sinclair/typebox@0.34": "0.34.48", "npm:effection@^4.0.2": "4.0.2", + "npm:tinybench@5": "5.1.0", "npm:valrs@*": "0.1.0" }, "jsr": { @@ -109,14 +111,222 @@ } }, "npm": { + "@codspeed/core@5.4.0": { + "integrity": "sha512-SwGjXDixN/zX1awBR95LzS0KxIs931qwf7Hbk7BRWv1jAdlMYf9o9GlSnWER4zGBHz941BvzFQJ1O2RIofW3cg==", + "dependencies": [ + "axios", + "find-up", + "form-data", + "node-gyp-build" + ] + }, + "@codspeed/tinybench-plugin@5.4.0_tinybench@5.1.0": { + "integrity": "sha512-jzuFoyyoGxc3Lc+TTl54PnRsgqO3CYbbbnwYSVp/m/4rqvCwSUZChY9EuQJ6uZFbamT3UhWF2N6tDEGShkvsrw==", + "dependencies": [ + "@codspeed/core", + "stack-trace", + "tinybench" + ] + }, "@sinclair/typebox@0.34.48": { "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==" }, + "agent-base@6.0.2": { + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": [ + "debug" + ] + }, + "asynckit@0.4.0": { + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios@1.16.1": { + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "dependencies": [ + "follow-redirects", + "form-data", + "https-proxy-agent", + "proxy-from-env" + ] + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "combined-stream@1.0.8": { + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": [ + "delayed-stream" + ] + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "delayed-stream@1.0.0": { + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, "effection@4.0.2": { "integrity": "sha512-O8WMGP10nPuJDwbNGILcaCNWS+CvDYjcdsUSD79nWZ+WtUQ8h1MEV7JJwCSZCSeKx8+TdEaZ/8r6qPTR2o/o8w==" }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.2": { + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": [ + "es-errors" + ] + }, + "es-set-tostringtag@2.1.0": { + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": [ + "es-errors", + "get-intrinsic", + "has-tostringtag", + "hasown" + ] + }, + "find-up@6.3.0": { + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "follow-redirects@1.16.0": { + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" + }, + "form-data@4.0.5": { + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": [ + "asynckit", + "combined-stream", + "es-set-tostringtag", + "hasown", + "mime-types" + ] + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag@1.0.2": { + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": [ + "has-symbols" + ] + }, + "hasown@2.0.3": { + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": [ + "function-bind" + ] + }, + "https-proxy-agent@5.0.1": { + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "locate-path@7.2.0": { + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dependencies": [ + "p-locate" + ] + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": [ + "mime-db" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node-gyp-build@4.8.4": { + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "bin": true + }, + "p-limit@4.0.0": { + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dependencies": [ + "yocto-queue" + ] + }, + "p-locate@6.0.0": { + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dependencies": [ + "p-limit" + ] + }, + "path-exists@5.0.0": { + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==" + }, + "proxy-from-env@2.1.0": { + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" + }, + "stack-trace@1.0.0-pre2": { + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==" + }, + "tinybench@5.1.0": { + "integrity": "sha512-LXKNtFualiKOm6gADe1UXPtf8+Nfn1CtPMEHAT33Fd2YjQatrujkDcK0+4wRC1X6t7fxUDXUs6BsvuIgfkDgDg==" + }, "valrs@0.1.0": { "integrity": "sha512-BqVkjx3qhsRLHerblLDoqEx0OEx7ms0DB6LPv40oWkMfFKUVKrqVuklaGdrPrHyubC5hSHYfEtUiQXrCkC6xHQ==" + }, + "yocto-queue@1.2.2": { + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==" } }, "workspace": { @@ -125,8 +335,10 @@ "jsr:@std/encoding@1", "jsr:@std/expect@1", "jsr:@std/testing@1", + "npm:@codspeed/tinybench-plugin@^5.4.0", "npm:@sinclair/typebox@0.34", - "npm:effection@^4.0.2" + "npm:effection@^4.0.2", + "npm:tinybench@5" ] } } diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..212f7a0 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,63 @@ +# examples + +This directory contains runnable example applications that exercise different +features of libs. If any of these examples are not working, please open an issue +with information about your terminal, shell, operating system and any other +information that could be pertinent to reproducing the issue. + +> [!NOTE] +> Run the commands in this document from the repository root. + +## Prerequisites + +Build the generated WebAssembly bundle before running the examples: + +```sh +make +``` + +## Keyboard + +Path: `examples/keyboard/index.ts` + +Run it with: + +```sh +deno run examples/keyboard/index.ts +``` + +What it shows: + +- raw keyboard input decoded into structured key events +- progressive keyboard protocol support +- pointer tracking and hover/click-driven UI updates +- terminal mode configuration such as alternate buffer, hidden cursor, and mouse + reporting + +Related files: + +- `examples/keyboard/use-input.ts` wraps the input parser as a stream of decoded + events +- `examples/keyboard/use-stdin.ts` adapts stdin into a byte stream for the demo + +## Inline Regions + +Path: `examples/inline-regions/index.ts` + +Run it with: + +```sh +deno run examples/inline-regions/index.ts +``` + +What it shows: + +- rendering animated regions into normal terminal scrollback +- querying cursor position with DSR to place later frames correctly +- updating a previously allocated region without taking over the whole screen +- small animated demos including a spinner, a progress bar, and a nyan-cat-style + sequence + +This example is useful if you want to embed transient or animated UI output into +a normal command-line workflow instead of switching to a full-screen alternate +buffer interface. diff --git a/demo/inline-region.ts b/examples/inline-regions/index.ts similarity index 98% rename from demo/inline-region.ts rename to examples/inline-regions/index.ts index 75a5b92..601dea8 100644 --- a/demo/inline-region.ts +++ b/examples/inline-regions/index.ts @@ -24,9 +24,9 @@ import { rgba, SHOWCURSOR, text, -} from "../mod.ts"; -import { cursor, settings } from "../settings.ts"; -import { validated } from "../validate.ts"; +} from "../../mod.ts"; +import { cursor, settings } from "../../settings.ts"; +import { validated } from "../../validate.ts"; const encode = (s: string) => new TextEncoder().encode(s); const write = (b: Uint8Array) => Deno.stdout.writeSync(b); diff --git a/demo/clay-transitions.ts b/examples/keyboard/clay-transitions.ts similarity index 99% rename from demo/clay-transitions.ts rename to examples/keyboard/clay-transitions.ts index 77f7ce8..a7580da 100644 --- a/demo/clay-transitions.ts +++ b/examples/keyboard/clay-transitions.ts @@ -34,13 +34,13 @@ import { type PointerEvent, rgba, text, -} from "../mod.ts"; +} from "../../mod.ts"; import { alternateBuffer, cursor, mouseTracking, settings, -} from "../settings.ts"; +} from "../../settings.ts"; import { useInput } from "./use-input.ts"; import { useStdin } from "./use-stdin.ts"; diff --git a/demo/keyboard.ts b/examples/keyboard/index.ts similarity index 99% rename from demo/keyboard.ts rename to examples/keyboard/index.ts index 0dce0c3..ef8fec8 100644 --- a/demo/keyboard.ts +++ b/examples/keyboard/index.ts @@ -21,7 +21,7 @@ import { type PointerEvent, rgba, text, -} from "../mod.ts"; +} from "../../mod.ts"; import { alternateBuffer, cursor, @@ -29,7 +29,7 @@ import { progressiveInput, type Setting, settings, -} from "../settings.ts"; +} from "../../settings.ts"; import { useInput } from "./use-input.ts"; import { useStdin } from "./use-stdin.ts"; diff --git a/demo/keyboard-key-events.gif b/examples/keyboard/keyboard-key-events.gif similarity index 100% rename from demo/keyboard-key-events.gif rename to examples/keyboard/keyboard-key-events.gif diff --git a/demo/keyboard-pointer-events.gif b/examples/keyboard/keyboard-pointer-events.gif similarity index 100% rename from demo/keyboard-pointer-events.gif rename to examples/keyboard/keyboard-pointer-events.gif diff --git a/demo/transitions.ts b/examples/keyboard/transitions.ts similarity index 98% rename from demo/transitions.ts rename to examples/keyboard/transitions.ts index 96a8620..f800e0b 100644 --- a/demo/transitions.ts +++ b/examples/keyboard/transitions.ts @@ -28,8 +28,8 @@ import { percent, rgba, text, -} from "../mod.ts"; -import { alternateBuffer, cursor, settings } from "../settings.ts"; +} from "../../mod.ts"; +import { alternateBuffer, cursor, settings } from "../../settings.ts"; import { useInput } from "./use-input.ts"; import { useStdin } from "./use-stdin.ts"; diff --git a/demo/use-input.ts b/examples/keyboard/use-input.ts similarity index 98% rename from demo/use-input.ts rename to examples/keyboard/use-input.ts index 73a4467..f85566c 100644 --- a/demo/use-input.ts +++ b/examples/keyboard/use-input.ts @@ -11,7 +11,7 @@ import { suspend, until, } from "effection"; -import { createInput, type InputEvent, type InputOptions } from "../mod.ts"; +import { createInput, type InputEvent, type InputOptions } from "../../mod.ts"; function nothing() { return suspend() as unknown as Operation< diff --git a/demo/use-stdin.ts b/examples/keyboard/use-stdin.ts similarity index 100% rename from demo/use-stdin.ts rename to examples/keyboard/use-stdin.ts diff --git a/ops.ts b/ops.ts index bb363fb..637a0c4 100644 --- a/ops.ts +++ b/ops.ts @@ -5,6 +5,7 @@ import { easingByte, propertyMask } from "./ops-transitions.ts"; const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; const OP_CLOSE_ELEMENT = 0x04; +const OP_SNAPSHOT = 0x05; /* Property group masks for OPEN_ELEMENT */ const PROP_LAYOUT = 0x01; @@ -56,11 +57,27 @@ function packAxis(view: DataView, offset: number, axis: SizingAxis): number { return o; } -function packString(view: DataView, bytes: Uint8Array, o: number): number { +function packString( + view: DataView, + bytes: Uint8Array, + o: number, + end: number, + context: string, +): number { + let paddedLength = Math.ceil(bytes.length / 4) * 4; + let next = o + 4 + paddedLength; + if (next > end) { + throw new RangeError( + `clayterm transfer buffer capacity exceeded while packing ${context} ` + + `(${next} byte offset, ${end} byte limit). ` + + `Render a smaller visible slice or reduce frame content.`, + ); + } + view.setUint32(o, bytes.length, true); o += 4; new Uint8Array(view.buffer).set(bytes, o); - o += Math.ceil(bytes.length / 4) * 4; + o += paddedLength; return o; } @@ -86,7 +103,7 @@ export function pack( o += 4; let bytes = encoder.encode(op.id); - o = packString(view, bytes, o); + o = packString(view, bytes, o, end, "element id"); let mask = 0; if (op.layout) mask |= PROP_LAYOUT; @@ -196,6 +213,12 @@ export function pack( break; } + case OP_SNAPSHOT: { + new Uint8Array(mem).set(op.data, o); + o += op.data.length; + break; + } + case OP_TEXT: { view.setUint32(o, OP_TEXT, true); o += 4; @@ -212,7 +235,7 @@ export function pack( o += 4; let str = encoder.encode(op.content); - o = packString(view, str, o); + o = packString(view, str, o, end, "text content"); break; } } @@ -302,7 +325,12 @@ export interface Text { attrs?: number; } -export type Op = OpenElement | Text | CloseElement; +interface Snapshot { + directive: typeof OP_SNAPSHOT; + data: Uint8Array; +} + +export type Op = OpenElement | Text | CloseElement | Snapshot; export function open( id: string, @@ -321,3 +349,43 @@ export function text( export function close(): CloseElement { return { directive: OP_CLOSE_ELEMENT }; } + +function packSize(ops: Op[]): number { + let n = 0; + for (let op of ops) { + switch (op.directive) { + case OP_CLOSE_ELEMENT: + n += 4; + break; + case OP_SNAPSHOT: + n += op.data.length; + break; + case OP_OPEN_ELEMENT: { + n += 4; // opcode + n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string + n += 4; // mask + if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align + if (op.bg !== undefined) n += 4; + if (op.cornerRadius) n += 4; + if (op.border) n += 8; + if (op.clip) n += 4; + if (op.floating) n += 16; + if (op.transition) n += 8; + break; + } + case OP_TEXT: { + n += 4 + 4 + 4; // opcode + color + cfg + n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string + break; + } + } + } + return n; +} + +export function snapshot(ops: Op[]): Op { + let size = packSize(ops); + let buf = new ArrayBuffer(size); + let words = pack(ops, buf, 0, size); + return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index fabda38..5f0f09d 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -406,6 +406,26 @@ Text directives MUST appear between a matching open/close pair. The set of styling properties accepted by `props` is part of the current implementation surface and may be extended. +#### 8.3.4 snapshot + +``` +snapshot(ops: Op[]): Op +``` + +Creates a snapshot by pre-packing the given directive array into its transfer +encoding. The returned value is an `Op` and can appear anywhere in a directive +array where the original ops would have appeared. The internal representation is +opaque. + +When the renderer encounters a snapshot during transfer, it copies the +pre-packed bytes directly into the command buffer without re-encoding. The +snapshot's ops MUST be structurally balanced (every `open` matched by a +`close`). + +Snapshots enable higher-level frameworks to implement dirty tracking: a +component whose inputs have not changed can reuse a previously created snapshot, +avoiding the cost of re-packing its subtree each frame. + ### 8.4 Sizing helpers These functions produce sizing-axis values for use in element layout @@ -462,6 +482,10 @@ that do not match a preceding open, is invalid input. Callers SHOULD validate directive arrays before rendering. The renderer's behavior when given an invalid directive array is unspecified by this specification. +A snapshot is semantically equivalent to splicing its source ops into the array +at the snapshot's position. The renderer MUST produce identical layout and +output regardless of whether ops are provided directly or via a snapshot. + ### 9.2 Transfer to the WASM module As part of the render transaction, the directive array is transferred into a @@ -469,6 +493,13 @@ form that the WASM module can process. This transfer is handled internally by the renderer and is not an operation the caller performs or observes. The transfer mechanism is an implementation detail described in Section 12.1. +If a frame exceeds transfer-buffer capacity while packing string content, the +renderer MUST throw a descriptive `RangeError` that identifies the condition as +a transfer-buffer, frame-capacity, or packing overflow. The renderer MUST NOT +expose only the raw host-level TypedArray message `"offset is out of bounds"` +for this condition. The error message SHOULD direct callers to render a smaller +visible slice or reduce frame content. + ### 9.3 Directive identity Each element directive carries an `id` provided by the caller via `open()`. diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts index 7259863..da64792 100644 --- a/tasks/build-npm.ts +++ b/tasks/build-npm.ts @@ -20,9 +20,8 @@ await build({ typeCheck: false, compilerOptions: { lib: ["ESNext"], - target: "ES2020", - sourceMap: true, }, + skipSourceOutput: true, package: { name: "clayterm", version, @@ -31,15 +30,16 @@ await build({ license: "MIT", repository: { type: "git", - url: "git+https://github.com/thefrontside/clayterm.git", + url: "git+https://github.com/bombshell-dev/clayterm.git", }, bugs: { - url: "https://github.com/thefrontside/clayterm/issues", + url: "https://github.com/bombshell-dev/clayterm/issues", }, engines: { - node: ">= 16", + node: ">= 22", }, sideEffects: false, + type: "module", }, }); diff --git a/test/pack.test.ts b/test/pack.test.ts new file mode 100644 index 0000000..9b3bee8 --- /dev/null +++ b/test/pack.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "./suite.ts"; +import { close, open, pack, text } from "../ops.ts"; + +describe("pack", () => { + it("throws a descriptive RangeError when text exceeds the transfer buffer", () => { + let memory = new ArrayBuffer(64); + let error: unknown; + + try { + pack( + [ + open("root"), + text("x".repeat(128)), + close(), + ], + memory, + 0, + memory.byteLength, + ); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RangeError); + expect((error as Error).message).toMatch( + /transfer buffer|capacity|packing/, + ); + expect((error as Error).message).toContain("text content"); + expect((error as Error).message).not.toBe("offset is out of bounds"); + expect((error as Error).message).toMatch( + /smaller visible slice|reduce frame content/, + ); + }); + + it("throws a descriptive RangeError when an element id exceeds the transfer buffer", () => { + let memory = new ArrayBuffer(16); + let error: unknown; + + try { + pack([open("x".repeat(64)), close()], memory, 0, memory.byteLength); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RangeError); + expect((error as Error).message).toMatch( + /transfer buffer|capacity|packing/, + ); + expect((error as Error).message).toContain("element id"); + expect((error as Error).message).not.toBe("offset is out of bounds"); + }); +}); diff --git a/test/term.test.ts b/test/term.test.ts index 47d8c94..adcd10a 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,15 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { close, fixed, grow, open, rgba, text } from "../ops.ts"; +import { + close, + fixed, + grow, + type Op, + open, + rgba, + snapshot, + text, +} from "../ops.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); @@ -191,6 +200,99 @@ describe("term", () => { }); }); + describe("snapshot", () => { + it("produces identical output to direct ops", async () => { + let ops = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + bg: rgba(0, 0, 128), + }), + open("child", { + layout: { + width: grow(), + padding: { left: 1 }, + direction: "ttb", + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("snapshot test"), + close(), + close(), + ]; + + let direct = await createTerm({ width: 40, height: 10 }); + let snapped = await createTerm({ width: 40, height: 10 }); + + let expected = direct.render(ops, { mode: "line" }).output; + let actual = snapped.render([snapshot(ops)], { mode: "line" }).output; + + expect(decode(actual)).toEqual(decode(expected)); + }); + + it("renders inside another element", async () => { + let child = snapshot([ + open("child", { + layout: { width: grow(), direction: "ttb" }, + }), + text("inner"), + close(), + ]); + + let direct = await createTerm({ width: 20, height: 5 }); + let snapped = await createTerm({ width: 20, height: 5 }); + + let wrapper = (content: Op[]) => [ + open("root", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + ...content, + close(), + ]; + + let expected = direct.render( + wrapper([ + open("child", { + layout: { width: grow(), direction: "ttb" }, + }), + text("inner"), + close(), + ]), + { mode: "line" }, + ).output; + + let actual = snapped.render( + wrapper([child]), + { mode: "line" }, + ).output; + + expect(decode(actual)).toEqual(decode(expected)); + expect(trim(print(decode(actual), 20, 5))).toEqual(` +┌──────────────────┐ +│inner │ +│ │ +│ │ +└──────────────────┘`.trim()); + }); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 }); diff --git a/test/transitions-pack.test.ts b/test/transitions-pack.test.ts index 885a89a..b505fd4 100644 --- a/test/transitions-pack.test.ts +++ b/test/transitions-pack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "./suite.ts"; -import { close, open, pack } from "../mod.ts"; +import { close, open, pack, snapshot } from "../mod.ts"; describe("pack transition", () => { it("encodes a transition without throwing", () => { @@ -39,4 +39,13 @@ describe("pack transition", () => { // The transition block is exactly 8 bytes = 2 words. expect(withLen - withoutLen).toBe(2); }); + + it("includes transition bytes when sizing snapshots", () => { + expect(() => + snapshot([ + open("a", { transition: { duration: 0.2, properties: ["x"] } }), + close(), + ]) + ).not.toThrow(); + }); }); diff --git a/test/validate.test.ts b/test/validate.test.ts index 8db4af9..25a8e0e 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -19,6 +19,20 @@ describe("validate", () => { expect(validate([])).toBe(true); }); + it("accepts transition ops", () => { + expect(validate([ + open("x", { + transition: { + duration: 0.2, + easing: "easeOut", + properties: ["x", "bg"], + interactive: true, + }, + }), + close(), + ])).toBe(true); + }); + it("rejects ops with wrong directive", () => { expect(validate([{ directive: 0xff }])).toBe(false); }); @@ -31,6 +45,23 @@ describe("validate", () => { expect(validate([{ directive: 0x03 }])).toBe(false); }); + it("rejects invalid transition properties", () => { + expect(validate([ + open("x", { + // deno-lint-ignore no-explicit-any + transition: { duration: 0.2, properties: ["opacity" as any] }, + }), + close(), + ])).toBe(false); + }); + + it("rejects negative transition duration", () => { + expect(validate([ + open("x", { transition: { duration: -1, properties: ["x"] } }), + close(), + ])).toBe(false); + }); + it("rejects non-array", () => { expect(validate("garbage")).toBe(false); }); diff --git a/validate.ts b/validate.ts index 248ea48..a18e656 100644 --- a/validate.ts +++ b/validate.ts @@ -89,6 +89,34 @@ const Floating = Type.Object({ zIndex: Type.Optional(u16), }); +const TransitionProperty = Type.Union([ + Type.Literal("x"), + Type.Literal("y"), + Type.Literal("position"), + Type.Literal("width"), + Type.Literal("height"), + Type.Literal("size"), + Type.Literal("bg"), + Type.Literal("overlay"), + Type.Literal("borderColor"), + Type.Literal("borderWidth"), + Type.Literal("all"), +]); + +const Easing = Type.Union([ + Type.Literal("linear"), + Type.Literal("easeIn"), + Type.Literal("easeOut"), + Type.Literal("easeInOut"), +]); + +const Transition = Type.Object({ + duration: Type.Number({ minimum: 0 }), + easing: Type.Optional(Easing), + properties: Type.Array(TransitionProperty), + interactive: Type.Optional(Type.Boolean()), +}); + /* ── Op types (discriminated on `directive`) ──────────────────────── */ const CloseElement = Type.Object({ directive: Type.Literal(0x04) }); @@ -102,6 +130,7 @@ const OpenElement = Type.Object({ border: Type.Optional(Border), clip: Type.Optional(Clip), floating: Type.Optional(Floating), + transition: Type.Optional(Transition), }); const TextOp = Type.Object({