0b61ff2bf4
* refactor(runtime)!: rename AnimationOptions to UseAnimationOptions
Align the useAnimation options type with VueUse's UseXOptions convention, matching its sibling composable options bags (UseInputOptions / UsePasteOptions / UseFocusOptions) and the already-correct UseAnimationReturn. Hard rename, no deprecated alias — done while the package is pre-1.0 (0.0.x), so no stability break.
Recorded under "Public composable naming follows Vue conventions" in .agents/docs/ink-divergences.md. Surfaced by the public-API audit.
BREAKING CHANGE: the exported type AnimationOptions is renamed to UseAnimationOptions; update `import { type AnimationOptions }` to `import { type UseAnimationOptions }`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: tighten public API to Ink + record aria decision & alignment principle
Public-API audit follow-ups. Where vue-tui had drifted from Ink with no real Vue reason, align to Ink; reduce speculative surface; and record decisions in .agents/docs/ink-divergences.md.
- renderToString: drop the public `isScreenReaderEnabled` option (Ink's public renderToString is layout-only). The SR-capable variant moves to `@vue-tui/runtime/internal` as `renderToStringWithScreenReader` for the accessibility test suite; SR output is unchanged.
- useTerminalSize -> useWindowSize: drop the invented name + alias, align to Ink's `useWindowSize`. The reactive ref return shape is unchanged (shallowRef divergence still applies).
- DevState/DevErrorInfo: move from the public barrel to `@vue-tui/runtime/internal` (internal HMR types, no public consumer; Ink exposes no HMR types).
- docs(divergences): add a standing "Why align to Ink — and when not to" principle (alignment is a means to reduce bugs, not an end; Vue idiom + reasonableness outrank parity); record the aria-props camelCase decision with its run-verified type-safety boundary; stamp the rawMode-default and measureElement-$el entries with their KEEP decisions.
BREAKING CHANGE: removed public exports `useTerminalSize`, `DevState`, `DevErrorInfo`, and `renderToString`'s `isScreenReaderEnabled` option. Use `useWindowSize`; import HMR types from `@vue-tui/runtime/internal`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: move renderScreenReaderOutput to /internal-only
The screen-reader linearizer (ported from Ink's internal
`renderNodeToScreenReaderOutput`) was exported from the public barrel, but it
was never usefully public: its only parameter type `TuiNode` and the
node-construction primitives needed to build one are not public, so a public
consumer could not name or construct the argument. Ink keeps its counterpart
module-internal; we match that.
`renderScreenReaderOutput` + `ScreenReaderOptions` now live only in
`@vue-tui/runtime/internal` (already re-exported there). The live SR machinery
(render, the internal renderToStringWithScreenReader, the <Static> channel)
imports from the source module and is unaffected; public SR output is reached
via the mount `isScreenReaderEnabled` option.
public-api.test.ts: drop it from the public-members list; add a runtime guard
(absent from public, present on /internal) plus a compile-time @ts-expect-error
guard that the `ScreenReaderOptions` type cannot be re-added to the public
barrel.
Docs: new .agents/docs/accessibility-api.md (aria + SR design) and
api-contract.md (public surface = exports + their user-consumable types;
/internal is not the contract); resolve the open item and cross-link from
ink-divergences.md.
BREAKING CHANGE: renderScreenReaderOutput and ScreenReaderOptions are no longer
exported from @vue-tui/runtime; import from @vue-tui/runtime/internal if needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(runtime-tests): snapshot the exact public value-export set
Upgrade public-api.test.ts from "documented members present + targeted
negatives" to an exhaustive snapshot of the exact runtime value-export surface
of `@vue-tui/runtime`: adding, removing, or renaming any value export now fails
the test, so every public-surface change must be a deliberate edit to the list.
Type-only exports are erased at runtime and cannot be enumerated, so the type
surface stays guarded individually (the `@ts-expect-error` ScreenReaderOptions
guard); api-contract.md is updated to state this boundary precisely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
205 lines
8.0 KiB
TypeScript
205 lines
8.0 KiB
TypeScript
import {
|
|
shallowRef,
|
|
watch,
|
|
toValue,
|
|
inject,
|
|
onScopeDispose,
|
|
type MaybeRefOrGetter,
|
|
type ShallowRef,
|
|
} from "vue";
|
|
import {
|
|
createAnimationScheduler,
|
|
normalizeInterval,
|
|
type AnimationScheduler,
|
|
} from "../animation-scheduler.ts";
|
|
import { AnimationSchedulerKey } from "../context.ts";
|
|
|
|
export interface UseAnimationOptions {
|
|
/**
|
|
* Time between ticks in milliseconds.
|
|
*
|
|
* Reactive: pass a ref/getter to change the interval on a live animation.
|
|
* While ACTIVE, changing it resets `frame`/`time`/`delta` to `0` and
|
|
* re-subscribes at the new interval (Ink parity — `shouldReset` recomputes
|
|
* `safeInterval` every render and resets when it differs while active).
|
|
* While INACTIVE, the new value is recorded but nothing resets and no timer
|
|
* starts; it takes effect on the next activation. A plain `number` keeps the
|
|
* previous fixed behavior (the type is a strict superset).
|
|
* @default 100
|
|
*/
|
|
interval?: MaybeRefOrGetter<number>;
|
|
|
|
/**
|
|
* Whether the animation is running. When set to `false`, the animation stops.
|
|
* When toggled back to `true`, all values reset to `0`.
|
|
* @default true
|
|
*/
|
|
isActive?: MaybeRefOrGetter<boolean>;
|
|
}
|
|
|
|
export interface UseAnimationReturn {
|
|
/**
|
|
* Discrete counter that increments by 1 each interval.
|
|
* Useful for indexed sequences like spinner frames.
|
|
*/
|
|
readonly frame: Readonly<ShallowRef<number>>;
|
|
|
|
/**
|
|
* Total elapsed time in milliseconds since the animation started or was last reset.
|
|
* Useful for continuous math-based animations like sine waves.
|
|
*/
|
|
readonly time: Readonly<ShallowRef<number>>;
|
|
|
|
/**
|
|
* Time in milliseconds since the previous tick.
|
|
* Accounts for throttled renders. Useful for physics-based or velocity-driven motion.
|
|
*/
|
|
readonly delta: Readonly<ShallowRef<number>>;
|
|
|
|
/**
|
|
* Resets `frame`, `time`, and `delta` to `0` and restarts timing from the
|
|
* current moment. Useful for one-shot animations triggered by events.
|
|
*
|
|
* While the animation is INACTIVE (paused via `isActive`), `reset()` keeps the
|
|
* last frame frozen instead of zeroing immediately; the zeroing is deferred to
|
|
* the next resume (Ink parity — Ink's reset bumps a key consumed only by the
|
|
* isActive-gated effect, so a paused reset zeros on resume, not before).
|
|
*/
|
|
readonly reset: () => void;
|
|
}
|
|
|
|
/**
|
|
* A composable that drives animations. Returns a frame counter, elapsed time,
|
|
* frame delta, and a reset function.
|
|
*
|
|
* @example
|
|
* ```vue
|
|
* <script setup>
|
|
* import { useAnimation, Text } from '@vue-tui/runtime';
|
|
*
|
|
* const { frame } = useAnimation({ interval: 80 });
|
|
* const characters = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
* </script>
|
|
* <template>
|
|
* <Text>{{ characters[frame % characters.length] }}</Text>
|
|
* </template>
|
|
* ```
|
|
*/
|
|
export function useAnimation(options: UseAnimationOptions = {}): UseAnimationReturn {
|
|
const frame = shallowRef(0);
|
|
const time = shallowRef(0);
|
|
const delta = shallowRef(0);
|
|
|
|
// Recomputed whenever the (reactive) interval changes — Ink re-reads
|
|
// safeInterval every render. `tick`/`start` read this current value so a
|
|
// re-subscribe after an interval change uses the new value.
|
|
let interval = normalizeInterval(toValue(options.interval));
|
|
// Fall back to a local standalone scheduler when used outside a vue-tui
|
|
// render tree (graceful degradation, not a silent break).
|
|
const scheduler: AnimationScheduler =
|
|
inject(AnimationSchedulerKey, null) ?? createAnimationScheduler();
|
|
|
|
const renderThrottleMs = scheduler.renderThrottleMs;
|
|
|
|
let handle: { startTime: number; unsubscribe: () => void } | undefined;
|
|
let startTime = 0;
|
|
// Time of the last RENDERED (non-coalesced) tick — delta is measured from
|
|
// here so it accumulates across ticks skipped within the throttle window.
|
|
let lastRenderedTime = 0;
|
|
// Ticks at or after this time are allowed to render; earlier ones coalesce.
|
|
let nextRenderTime = 0;
|
|
|
|
function tick(now: number) {
|
|
// Coalesce intermediate ticks while inside the current render-throttle
|
|
// window (Ink parity — use-animation.ts:102-121). The next allowed tick
|
|
// jumps straight to the latest elapsed values, and delta reports the time
|
|
// since the last rendered tick (accumulated across the skipped ticks) so
|
|
// velocity-driven motion advances at correct wall-clock speed.
|
|
if (renderThrottleMs > 0 && now < nextRenderTime) return;
|
|
|
|
frame.value = Math.floor((now - startTime) / interval);
|
|
time.value = now - startTime;
|
|
delta.value = now - lastRenderedTime;
|
|
lastRenderedTime = now;
|
|
nextRenderTime = now + renderThrottleMs;
|
|
}
|
|
|
|
function start() {
|
|
stop();
|
|
frame.value = 0;
|
|
time.value = 0;
|
|
delta.value = 0;
|
|
handle = scheduler.subscribe(tick, interval);
|
|
startTime = handle.startTime;
|
|
lastRenderedTime = handle.startTime;
|
|
nextRenderTime = handle.startTime + renderThrottleMs;
|
|
}
|
|
|
|
function stop() {
|
|
if (handle) {
|
|
handle.unsubscribe();
|
|
handle = undefined;
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
// Ink parity (use-animation.ts:83-89,138): reset() only bumps a resetKey;
|
|
// the actual zeroing (setAnimState(zeroAnimState)) lives INSIDE the layout
|
|
// effect, which early-returns while !isActive, and `shouldReset` is gated on
|
|
// isActive. So:
|
|
// - ACTIVE: zero + restart timing now. start() already does both (the
|
|
// effect re-runs on resetKey while isActive in Ink).
|
|
// - INACTIVE (paused): do NOT zero — keep the last frame frozen. The next
|
|
// resume runs the isActive watch → start(), which zeros, so the reset
|
|
// lands on resume (Ink defers zeroing the same way).
|
|
if (handle !== undefined) {
|
|
start();
|
|
}
|
|
}
|
|
|
|
// A SINGLE watcher on BOTH (isActive, interval), mirroring Ink's render-time
|
|
// `shouldReset = isActive && (intervalChanged || becameActive)`
|
|
// (use-animation.ts:77-96). Ink derives this once from the FINAL values of a
|
|
// render, so a batch that both changes interval AND flips isActive→false
|
|
// resolves to `shouldReset === false` and the frame FREEZES.
|
|
//
|
|
// Two `flush:"sync"` watchers cannot reproduce that: `sync` fires once PER
|
|
// mutation, so `interval.value = 200; active.value = false` in one batch ran
|
|
// the interval watcher first (while still active) → erroneous start() → frame
|
|
// zeroed, before the isActive watcher could stop(). `flush:"post"` coalesces
|
|
// the batch and fires once with the final values — verified to fire even in
|
|
// the blessed standalone fallback (no mounted component drives the post-flush
|
|
// queue, but a reactive mutation still flushes it). We always record the new
|
|
// normalized `interval` so the next start() uses it.
|
|
const isActive = options.isActive ?? true;
|
|
// immediate handles the initial mount: a single start()/stop(), no double
|
|
// subscribe (the old code split this across an immediate isActive watch + a
|
|
// non-immediate interval watch for exactly that reason).
|
|
watch(
|
|
() => [toValue(isActive), normalizeInterval(toValue(options.interval))] as const,
|
|
([active, nextInterval], prev) => {
|
|
const intervalChanged = prev !== undefined && nextInterval !== prev[1];
|
|
const becameActive = prev === undefined || !prev[0];
|
|
interval = nextInterval;
|
|
if (!active) {
|
|
// Paused (or starting inactive): stop and freeze. Nothing to reset —
|
|
// matching reset-while-paused; the deferred zero lands on the next
|
|
// resume (which hits the `start()` below via becameActive).
|
|
stop();
|
|
return;
|
|
}
|
|
// Active. Ink resets+re-subscribes only when something relevant changed:
|
|
// the animation just became active, or the interval changed while active.
|
|
// (Vue's watch fires only on a real change, so on a pure re-render with an
|
|
// unchanged interval this watcher does not run at all — no reset, matching
|
|
// Ink's "rerender with same interval does not reset".)
|
|
if (becameActive || intervalChanged) start();
|
|
},
|
|
{ immediate: true, flush: "post" },
|
|
);
|
|
|
|
onScopeDispose(stop);
|
|
|
|
return { frame, time, delta, reset };
|
|
}
|