From 3c5fee2c91dd45bc29a5ab03ee27853143b5e066 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 21 Apr 2026 21:32:22 -0500 Subject: [PATCH 01/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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 () => {