@luan.sh/pi-libtui 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/native-revision +1 -0
- package/package.json +58 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
package/src/motion.ts
ADDED
|
@@ -0,0 +1,1409 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
getTuiAppearance,
|
|
4
|
+
subscribeTuiAppearance,
|
|
5
|
+
type TuiActivityIndicatorStyle,
|
|
6
|
+
type TuiAnimationSmoothness,
|
|
7
|
+
type TuiAnimationSpeed,
|
|
8
|
+
type TuiPulseEffectStyle,
|
|
9
|
+
type TuiTextEffectStyle,
|
|
10
|
+
type TuiTextEffectScope,
|
|
11
|
+
} from "./appearance.ts";
|
|
12
|
+
import type { TuiColor, TuiForegroundPaint, TuiForegroundToken, TuiHue, TuiTheme } from "./color/theme.ts";
|
|
13
|
+
import { sanitizeTuiText } from "./content/terminal-text.ts";
|
|
14
|
+
|
|
15
|
+
/** A host surface that can schedule a TUI render. */
|
|
16
|
+
export interface MotionRenderTarget {
|
|
17
|
+
requestRender(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A timer handle with Node/Bun's optional process-liveness control. */
|
|
21
|
+
export interface MotionTimerHandle {
|
|
22
|
+
unref?(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Idempotent ownership handle returned by the motion scheduler. */
|
|
26
|
+
export interface MotionMount {
|
|
27
|
+
dispose(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Injectable timer and clock boundary used by the shared scheduler. */
|
|
31
|
+
export interface MotionClock {
|
|
32
|
+
now(): number;
|
|
33
|
+
start(callback: () => void, cadenceMs: number): MotionTimerHandle;
|
|
34
|
+
stop(handle: MotionTimerHandle): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Options for one cadence registration. */
|
|
38
|
+
export interface MotionMountOptions {
|
|
39
|
+
/** Frame cadence in milliseconds. Values below 16ms are clamped. */
|
|
40
|
+
cadenceMs: number;
|
|
41
|
+
/** Called on this registration's cadence before the host render request. */
|
|
42
|
+
onFrame?(nowMs: number): void;
|
|
43
|
+
/** Avoid allocating a timer while preserving a disposable mount. */
|
|
44
|
+
reducedMotion?: boolean;
|
|
45
|
+
/** Stop invalidating an abandoned surface after this many milliseconds. */
|
|
46
|
+
maxDurationMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface MotionRegistration {
|
|
50
|
+
id: number;
|
|
51
|
+
onFrame?: (nowMs: number) => void;
|
|
52
|
+
expiresAtMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface TargetRegistration {
|
|
56
|
+
registrations: Map<number, MotionRegistration>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface CadenceBucket {
|
|
60
|
+
timer?: MotionTimerHandle;
|
|
61
|
+
targets: Map<MotionRenderTarget, TargetRegistration>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const systemClock: MotionClock = {
|
|
65
|
+
now: () => performance.now(),
|
|
66
|
+
start(callback, cadenceMs) {
|
|
67
|
+
const handle: MotionTimerHandle & {
|
|
68
|
+
cancelled: boolean;
|
|
69
|
+
nextAtMs: number;
|
|
70
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
71
|
+
unreferenced: boolean;
|
|
72
|
+
} = {
|
|
73
|
+
cancelled: false,
|
|
74
|
+
nextAtMs: performance.now() + cadenceMs,
|
|
75
|
+
unreferenced: false,
|
|
76
|
+
unref() {
|
|
77
|
+
this.unreferenced = true;
|
|
78
|
+
this.timer?.unref?.();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const schedule = (): void => {
|
|
82
|
+
const delayMs = Math.max(0, handle.nextAtMs - performance.now());
|
|
83
|
+
handle.timer = setTimeout(() => {
|
|
84
|
+
if (handle.cancelled) return;
|
|
85
|
+
callback();
|
|
86
|
+
if (handle.cancelled) return;
|
|
87
|
+
const nowMs = performance.now();
|
|
88
|
+
const missed = Math.max(1, Math.floor((nowMs - handle.nextAtMs) / cadenceMs) + 1);
|
|
89
|
+
handle.nextAtMs += missed * cadenceMs;
|
|
90
|
+
schedule();
|
|
91
|
+
}, delayMs);
|
|
92
|
+
if (handle.unreferenced) handle.timer.unref?.();
|
|
93
|
+
};
|
|
94
|
+
schedule();
|
|
95
|
+
return handle;
|
|
96
|
+
},
|
|
97
|
+
stop(handle) {
|
|
98
|
+
const timer = handle as MotionTimerHandle & {
|
|
99
|
+
cancelled: boolean;
|
|
100
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
101
|
+
};
|
|
102
|
+
timer.cancelled = true;
|
|
103
|
+
if (timer.timer) clearTimeout(timer.timer);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Shares one unreferenced timer per cadence across every mounted animation.
|
|
109
|
+
*
|
|
110
|
+
* A target mounted at several cadences is invalidated only by its fastest
|
|
111
|
+
* cadence. Slower callbacks still advance their own state, avoiding duplicate
|
|
112
|
+
* whole-tree renders while retaining independent animation clocks.
|
|
113
|
+
*/
|
|
114
|
+
export class MotionScheduler {
|
|
115
|
+
private readonly buckets = new Map<number, CadenceBucket>();
|
|
116
|
+
private readonly targetsByRequest = new WeakMap<MotionRenderTarget["requestRender"], MotionRenderTarget>();
|
|
117
|
+
private nextRegistrationId = 1;
|
|
118
|
+
private mountCount = 0;
|
|
119
|
+
private paused = false;
|
|
120
|
+
|
|
121
|
+
constructor(private readonly clock: MotionClock = systemClock) {}
|
|
122
|
+
|
|
123
|
+
/** Register a target on a shared cadence bucket. */
|
|
124
|
+
mount(target: MotionRenderTarget, options: MotionMountOptions): MotionMount {
|
|
125
|
+
if (options.reducedMotion) return disposable(() => {});
|
|
126
|
+
target = this.canonicalTarget(target);
|
|
127
|
+
const cadenceMs = Number.isFinite(options.cadenceMs) ? Math.max(16, Math.floor(options.cadenceMs)) : 120;
|
|
128
|
+
let bucket = this.buckets.get(cadenceMs);
|
|
129
|
+
if (!bucket) {
|
|
130
|
+
const targets = new Map<MotionRenderTarget, TargetRegistration>();
|
|
131
|
+
bucket = { targets };
|
|
132
|
+
this.buckets.set(cadenceMs, bucket);
|
|
133
|
+
}
|
|
134
|
+
const targetRegistration = bucket.targets.get(target) ?? { registrations: new Map() };
|
|
135
|
+
const id = this.nextRegistrationId++;
|
|
136
|
+
const maxDurationMs = Number.isFinite(options.maxDurationMs) ? options.maxDurationMs : undefined;
|
|
137
|
+
targetRegistration.registrations.set(id, {
|
|
138
|
+
id,
|
|
139
|
+
onFrame: options.onFrame,
|
|
140
|
+
expiresAtMs: maxDurationMs === undefined ? undefined : this.clock.now() + Math.max(0, maxDurationMs),
|
|
141
|
+
});
|
|
142
|
+
bucket.targets.set(target, targetRegistration);
|
|
143
|
+
if (!this.paused) this.startTimer(cadenceMs, bucket);
|
|
144
|
+
this.mountCount++;
|
|
145
|
+
return disposable(() => {
|
|
146
|
+
const currentBucket = this.buckets.get(cadenceMs);
|
|
147
|
+
const currentTarget = currentBucket?.targets.get(target);
|
|
148
|
+
if (!currentBucket || !currentTarget?.registrations.delete(id)) return;
|
|
149
|
+
this.mountCount--;
|
|
150
|
+
if (currentTarget.registrations.size === 0) currentBucket.targets.delete(target);
|
|
151
|
+
if (currentBucket.targets.size > 0) return;
|
|
152
|
+
if (currentBucket.timer) this.clock.stop(currentBucket.timer);
|
|
153
|
+
this.buckets.delete(cadenceMs);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Suspend timers without discarding registrations, then resume their cadences intact. */
|
|
158
|
+
setPaused(paused: boolean): void {
|
|
159
|
+
if (this.paused === paused) return;
|
|
160
|
+
this.paused = paused;
|
|
161
|
+
for (const [cadenceMs, bucket] of this.buckets) {
|
|
162
|
+
if (paused) {
|
|
163
|
+
if (bucket.timer) this.clock.stop(bucket.timer);
|
|
164
|
+
bucket.timer = undefined;
|
|
165
|
+
} else {
|
|
166
|
+
this.startTimer(cadenceMs, bucket);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private canonicalTarget(target: MotionRenderTarget): MotionRenderTarget {
|
|
172
|
+
// Keep class instances intact: copying a prototype method into a plain
|
|
173
|
+
// object drops the renderer's `this` receiver. Plain callback wrappers are
|
|
174
|
+
// safe to canonicalize because their own requestRender property is already
|
|
175
|
+
// bound or closure-based.
|
|
176
|
+
if (!Object.hasOwn(target, "requestRender")) return target;
|
|
177
|
+
const requestRender = target.requestRender;
|
|
178
|
+
let canonical = this.targetsByRequest.get(requestRender);
|
|
179
|
+
if (!canonical) {
|
|
180
|
+
canonical = { requestRender };
|
|
181
|
+
this.targetsByRequest.set(requestRender, canonical);
|
|
182
|
+
}
|
|
183
|
+
return canonical;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private tick(cadenceMs: number, targets: Map<MotionRenderTarget, TargetRegistration>): void {
|
|
187
|
+
if (this.paused) return;
|
|
188
|
+
const nowMs = this.clock.now();
|
|
189
|
+
for (const [target, registration] of targets) {
|
|
190
|
+
for (const current of registration.registrations.values()) {
|
|
191
|
+
if (current.expiresAtMs === undefined || nowMs < current.expiresAtMs) continue;
|
|
192
|
+
registration.registrations.delete(current.id);
|
|
193
|
+
this.mountCount--;
|
|
194
|
+
}
|
|
195
|
+
if (registration.registrations.size === 0) {
|
|
196
|
+
targets.delete(target);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
for (const current of registration.registrations.values()) {
|
|
200
|
+
try {
|
|
201
|
+
current.onFrame?.(nowMs);
|
|
202
|
+
} catch {
|
|
203
|
+
registration.registrations.delete(current.id);
|
|
204
|
+
this.mountCount--;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (registration.registrations.size === 0) {
|
|
208
|
+
targets.delete(target);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (this.fastestCadence(target) === cadenceMs) {
|
|
212
|
+
try {
|
|
213
|
+
target.requestRender();
|
|
214
|
+
} catch {
|
|
215
|
+
this.mountCount -= registration.registrations.size;
|
|
216
|
+
registration.registrations.clear();
|
|
217
|
+
targets.delete(target);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (targets.size > 0) return;
|
|
222
|
+
const bucket = this.buckets.get(cadenceMs);
|
|
223
|
+
if (!bucket || bucket.targets !== targets) return;
|
|
224
|
+
if (bucket.timer) this.clock.stop(bucket.timer);
|
|
225
|
+
this.buckets.delete(cadenceMs);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private startTimer(cadenceMs: number, bucket: CadenceBucket): void {
|
|
229
|
+
if (bucket.timer || bucket.targets.size === 0) return;
|
|
230
|
+
bucket.timer = this.clock.start(() => this.tick(cadenceMs, bucket.targets), cadenceMs);
|
|
231
|
+
bucket.timer.unref?.();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private fastestCadence(target: MotionRenderTarget): number | undefined {
|
|
235
|
+
let fastest: number | undefined;
|
|
236
|
+
for (const [cadence, bucket] of this.buckets) {
|
|
237
|
+
if (!bucket.targets.has(target)) continue;
|
|
238
|
+
fastest = fastest === undefined ? cadence : Math.min(fastest, cadence);
|
|
239
|
+
}
|
|
240
|
+
return fastest;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Number of live cadence timers. */
|
|
244
|
+
get activeTimerCount(): number {
|
|
245
|
+
return [...this.buckets.values()].filter((bucket) => bucket.timer !== undefined).length;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Number of live mounts, including shared target/cadence registrations. */
|
|
249
|
+
get activeMountCount(): number {
|
|
250
|
+
return this.mountCount;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function disposable(dispose: () => void): MotionMount {
|
|
255
|
+
let active = true;
|
|
256
|
+
return {
|
|
257
|
+
dispose() {
|
|
258
|
+
if (!active) return;
|
|
259
|
+
active = false;
|
|
260
|
+
dispose();
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const SHARED_MOTION_SCHEDULER = Symbol.for("pi-libtui.motionScheduler.v1");
|
|
266
|
+
type MotionGlobal = typeof globalThis & { [SHARED_MOTION_SCHEDULER]?: MotionScheduler };
|
|
267
|
+
|
|
268
|
+
function resolveSharedMotionScheduler(): MotionScheduler {
|
|
269
|
+
const motionGlobal = globalThis as MotionGlobal;
|
|
270
|
+
let scheduler = motionGlobal[SHARED_MOTION_SCHEDULER];
|
|
271
|
+
if (!scheduler) {
|
|
272
|
+
scheduler = new MotionScheduler();
|
|
273
|
+
motionGlobal[SHARED_MOTION_SCHEDULER] = scheduler;
|
|
274
|
+
}
|
|
275
|
+
return scheduler;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
class SharedMotionScheduler extends MotionScheduler {
|
|
279
|
+
override mount(target: MotionRenderTarget, options: MotionMountOptions): MotionMount {
|
|
280
|
+
return resolveSharedMotionScheduler().mount(target, options);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
override get activeTimerCount(): number {
|
|
284
|
+
return resolveSharedMotionScheduler().activeTimerCount;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
override setPaused(paused: boolean): void {
|
|
288
|
+
resolveSharedMotionScheduler().setPaused(paused);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
override get activeMountCount(): number {
|
|
292
|
+
return resolveSharedMotionScheduler().activeMountCount;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Process-wide cadence scheduler, initialized only when animation is used. */
|
|
297
|
+
export const sharedMotionScheduler: MotionScheduler = new SharedMotionScheduler();
|
|
298
|
+
|
|
299
|
+
/** Per-surface choices. Omitted values inherit the process-wide appearance. */
|
|
300
|
+
export interface ActivityAnimationOverrides {
|
|
301
|
+
indicatorStyle?: TuiActivityIndicatorStyle;
|
|
302
|
+
pulseEffectStyle?: TuiPulseEffectStyle;
|
|
303
|
+
textEffectStyle?: TuiTextEffectStyle;
|
|
304
|
+
textEffectScope?: TuiTextEffectScope;
|
|
305
|
+
animationSpeed?: TuiAnimationSpeed;
|
|
306
|
+
animationSmoothness?: TuiAnimationSmoothness;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Repaint cadence selected independently from animation pace. */
|
|
310
|
+
export function configuredAnimationCadenceMs(
|
|
311
|
+
indicator = getTuiAppearance().activityIndicator,
|
|
312
|
+
textEffect = getTuiAppearance().textEffect,
|
|
313
|
+
smoothness: TuiAnimationSmoothness = getTuiAppearance().animationSmoothness,
|
|
314
|
+
speed: TuiAnimationSpeed = getTuiAppearance().animationSpeed,
|
|
315
|
+
pulseEffect: TuiPulseEffectStyle = getTuiAppearance().pulseEffect,
|
|
316
|
+
): number | undefined {
|
|
317
|
+
const intrinsicCadenceMs = activityCadenceMs(indicator, textEffect, pulseEffect);
|
|
318
|
+
if (intrinsicCadenceMs === undefined) return undefined;
|
|
319
|
+
const designedCadenceMs = intrinsicCadenceMs / animationSpeedMultiplier(speed);
|
|
320
|
+
return textEffect === "off"
|
|
321
|
+
? Math.max(animationSmoothnessCadenceMs(smoothness), Math.ceil(designedCadenceMs))
|
|
322
|
+
: animationSmoothnessCadenceMs(smoothness);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Timeline multiplier for one configured animation speed. */
|
|
326
|
+
export function animationSpeedMultiplier(speed: TuiAnimationSpeed = getTuiAppearance().animationSpeed): number {
|
|
327
|
+
return speed === "slow" ? 0.6 : speed === "relaxed" ? 0.8 : speed === "fast" ? 1.35 : speed === "very-fast" ? 1.7 : 1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Shared terminal repaint cadence for one configured smoothness. */
|
|
331
|
+
export function animationSmoothnessCadenceMs(
|
|
332
|
+
smoothness: TuiAnimationSmoothness = getTuiAppearance().animationSmoothness,
|
|
333
|
+
): number {
|
|
334
|
+
return smoothness === "economy" ? 80 : smoothness === "smooth" ? 22 : smoothness === "ultra" ? 16 : 33;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Whether the configured activity presentation animates its text. */
|
|
338
|
+
export function activityAnimatesText(style = getTuiAppearance().textEffect): boolean {
|
|
339
|
+
return style !== "off";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function markerCadenceMs(style: TuiActivityIndicatorStyle): number | undefined {
|
|
343
|
+
return markerAnimation(style)?.cadenceMs;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function shimmerCadenceMs(style: TuiTextEffectStyle): number | undefined {
|
|
347
|
+
return style === "sweep"
|
|
348
|
+
? 90
|
|
349
|
+
: style === "glow"
|
|
350
|
+
? 70
|
|
351
|
+
: style === "rainbow" || style === "rainbow-glow" || style === "aurora"
|
|
352
|
+
? 80
|
|
353
|
+
: style === "lightning" || style === "glitch" || style === "crush"
|
|
354
|
+
? 60
|
|
355
|
+
: undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function activityCadenceMs(
|
|
359
|
+
indicator: TuiActivityIndicatorStyle,
|
|
360
|
+
textEffect: TuiTextEffectStyle,
|
|
361
|
+
pulseEffect: TuiPulseEffectStyle,
|
|
362
|
+
): number | undefined {
|
|
363
|
+
const cadences = [
|
|
364
|
+
markerCadenceMs(indicator),
|
|
365
|
+
shimmerCadenceMs(textEffect),
|
|
366
|
+
pulseEffect !== "off" ? 33 : undefined,
|
|
367
|
+
].filter((cadence): cadence is number => cadence !== undefined);
|
|
368
|
+
return cadences.length > 0 ? Math.min(...cadences) : undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Mount the process-wide configured animation and follow live appearance changes.
|
|
373
|
+
*
|
|
374
|
+
* The returned mount retains the appearance subscription even for the static
|
|
375
|
+
* style so switching animations can start the timer without rebuilding the UI.
|
|
376
|
+
*/
|
|
377
|
+
export function mountConfiguredAnimation(
|
|
378
|
+
target: MotionRenderTarget,
|
|
379
|
+
options: ActivityAnimationOverrides &
|
|
380
|
+
Pick<MotionMountOptions, "onFrame" | "maxDurationMs" | "reducedMotion"> & {
|
|
381
|
+
cadenceMs?: number;
|
|
382
|
+
scheduler?: MotionScheduler;
|
|
383
|
+
} = {},
|
|
384
|
+
): MotionMount {
|
|
385
|
+
let timer: MotionMount | undefined;
|
|
386
|
+
let disposed = false;
|
|
387
|
+
const expiresAtMs =
|
|
388
|
+
options.maxDurationMs === undefined ? undefined : performance.now() + Math.max(0, options.maxDurationMs);
|
|
389
|
+
const sync = (): void => {
|
|
390
|
+
timer?.dispose();
|
|
391
|
+
timer = undefined;
|
|
392
|
+
const cadenceMs = options.reducedMotion
|
|
393
|
+
? undefined
|
|
394
|
+
: (options.cadenceMs ??
|
|
395
|
+
configuredAnimationCadenceMs(
|
|
396
|
+
options.indicatorStyle,
|
|
397
|
+
options.textEffectStyle,
|
|
398
|
+
options.animationSmoothness,
|
|
399
|
+
options.animationSpeed,
|
|
400
|
+
options.pulseEffectStyle,
|
|
401
|
+
));
|
|
402
|
+
const maxDurationMs = expiresAtMs === undefined ? undefined : expiresAtMs - performance.now();
|
|
403
|
+
if (cadenceMs === undefined || disposed || (maxDurationMs !== undefined && maxDurationMs <= 0)) return;
|
|
404
|
+
timer = (options.scheduler ?? sharedMotionScheduler).mount(target, {
|
|
405
|
+
cadenceMs,
|
|
406
|
+
maxDurationMs,
|
|
407
|
+
onFrame: options.onFrame,
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
const unsubscribe = subscribeTuiAppearance(() => {
|
|
411
|
+
sync();
|
|
412
|
+
target.requestRender();
|
|
413
|
+
});
|
|
414
|
+
sync();
|
|
415
|
+
return disposable(() => {
|
|
416
|
+
disposed = true;
|
|
417
|
+
unsubscribe();
|
|
418
|
+
timer?.dispose();
|
|
419
|
+
timer = undefined;
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Portable braille spinner frames. */
|
|
424
|
+
export const SPINNER_FRAMES = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const);
|
|
425
|
+
|
|
426
|
+
/** Curated portable single-cell animation frames. */
|
|
427
|
+
export const LINE_FRAMES = Object.freeze(["-", "\\", "|", "/"] as const);
|
|
428
|
+
export const ARC_FRAMES = Object.freeze(["◜", "◠", "◝", "◞", "◡", "◟"] as const);
|
|
429
|
+
export const NERD_PROGRESS_FRAMES = Object.freeze(["", "", "", "", "", ""] as const);
|
|
430
|
+
export const PIPE_FRAMES = Object.freeze(["┤", "┘", "┴", "└", "├", "┌", "┬", "┐"] as const);
|
|
431
|
+
export const GROW_VERTICAL_FRAMES = Object.freeze(["▁", "▃", "▄", "▅", "▆", "▇", "▆", "▅", "▄", "▃"] as const);
|
|
432
|
+
export const GROW_HORIZONTAL_FRAMES = Object.freeze([
|
|
433
|
+
"▏",
|
|
434
|
+
"▎",
|
|
435
|
+
"▍",
|
|
436
|
+
"▌",
|
|
437
|
+
"▋",
|
|
438
|
+
"▊",
|
|
439
|
+
"▉",
|
|
440
|
+
"▊",
|
|
441
|
+
"▋",
|
|
442
|
+
"▌",
|
|
443
|
+
"▍",
|
|
444
|
+
"▎",
|
|
445
|
+
] as const);
|
|
446
|
+
export const TRIANGLE_FRAMES = Object.freeze(["◢", "◣", "◤", "◥"] as const);
|
|
447
|
+
export const CIRCLE_QUARTER_FRAMES = Object.freeze(["◴", "◷", "◶", "◵"] as const);
|
|
448
|
+
export const CIRCLE_HALF_FRAMES = Object.freeze(["◐", "◓", "◑", "◒"] as const);
|
|
449
|
+
export const BRACKET_SPIN_FRAMES = Object.freeze(["⊏", "⊓", "⊐", "⊔"] as const);
|
|
450
|
+
export const DOT_FRAMES = Object.freeze(["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"] as const);
|
|
451
|
+
export const QUADRANT_FRAMES = Object.freeze(["▖", "▘", "▝", "▗"] as const);
|
|
452
|
+
export const SPARKLE_FRAMES = Object.freeze(["✦", "✧"] as const);
|
|
453
|
+
export const BRAILLE_WAVE_FRAMES = Object.freeze([
|
|
454
|
+
"⠁⠂⠄⡀",
|
|
455
|
+
"⠂⠄⡀⢀",
|
|
456
|
+
"⠄⡀⢀⠠",
|
|
457
|
+
"⡀⢀⠠⠐",
|
|
458
|
+
"⢀⠠⠐⠈",
|
|
459
|
+
"⠠⠐⠈⠁",
|
|
460
|
+
"⠐⠈⠁⠂",
|
|
461
|
+
"⠈⠁⠂⠄",
|
|
462
|
+
] as const);
|
|
463
|
+
export const BRAILLE_DNA_FRAMES = Object.freeze([
|
|
464
|
+
"⠋⠉⠙⠚",
|
|
465
|
+
"⠉⠙⠚⠒",
|
|
466
|
+
"⠙⠚⠒⠂",
|
|
467
|
+
"⠚⠒⠂⠂",
|
|
468
|
+
"⠒⠂⠂⠒",
|
|
469
|
+
"⠂⠂⠒⠲",
|
|
470
|
+
"⠂⠒⠲⠴",
|
|
471
|
+
"⠒⠲⠴⠤",
|
|
472
|
+
"⠲⠴⠤⠄",
|
|
473
|
+
"⠴⠤⠄⠋",
|
|
474
|
+
"⠤⠄⠋⠉",
|
|
475
|
+
"⠄⠋⠉⠙",
|
|
476
|
+
] as const);
|
|
477
|
+
export const BRAILLE_SCAN_FRAMES = Object.freeze([
|
|
478
|
+
"⠀⠀⠀⠀",
|
|
479
|
+
"⡇⠀⠀⠀",
|
|
480
|
+
"⣿⠀⠀⠀",
|
|
481
|
+
"⢸⡇⠀⠀",
|
|
482
|
+
"⠀⣿⠀⠀",
|
|
483
|
+
"⠀⢸⡇⠀",
|
|
484
|
+
"⠀⠀⣿⠀",
|
|
485
|
+
"⠀⠀⢸⡇",
|
|
486
|
+
"⠀⠀⠀⣿",
|
|
487
|
+
"⠀⠀⠀⢸",
|
|
488
|
+
] as const);
|
|
489
|
+
export const BRAILLE_RAIN_FRAMES = Object.freeze([
|
|
490
|
+
"⢁⠂⠔⠈",
|
|
491
|
+
"⠂⠌⡠⠐",
|
|
492
|
+
"⠄⡐⢀⠡",
|
|
493
|
+
"⡈⠠⠀⢂",
|
|
494
|
+
"⠐⢀⠁⠄",
|
|
495
|
+
"⠠⠁⠊⡀",
|
|
496
|
+
"⢁⠂⠔⠈",
|
|
497
|
+
"⠂⠌⡠⠐",
|
|
498
|
+
"⠄⡐⢀⠡",
|
|
499
|
+
"⡈⠠⠀⢂",
|
|
500
|
+
"⠐⢀⠁⠄",
|
|
501
|
+
"⠠⠁⠊⡀",
|
|
502
|
+
] as const);
|
|
503
|
+
export const BRAILLE_SCANLINE_FRAMES = Object.freeze(["⠉⠉⠉", "⠓⠓⠓", "⠦⠦⠦", "⣄⣄⣄", "⠦⠦⠦", "⠓⠓⠓"] as const);
|
|
504
|
+
export const BRAILLE_PULSE_FRAMES = Object.freeze(["⠀⠶⠀", "⠰⣿⠆", "⢾⣉⡷", "⣏⠀⣹", "⡁⠀⢈"] as const);
|
|
505
|
+
export const BRAILLE_SPARKLE_FRAMES = Object.freeze(["⡡⠊⢔⠡", "⠊⡰⡡⡘", "⢔⢅⠈⢢", "⡁⢂⠆⡍", "⢔⠨⢑⢐", "⠨⡑⡠⠊"] as const);
|
|
506
|
+
export const BRAILLE_CASCADE_FRAMES = Object.freeze([
|
|
507
|
+
"⠀⠀⠀⠀",
|
|
508
|
+
"⠀⠀⠀⠀",
|
|
509
|
+
"⠁⠀⠀⠀",
|
|
510
|
+
"⠋⠀⠀⠀",
|
|
511
|
+
"⠞⠁⠀⠀",
|
|
512
|
+
"⡴⠋⠀⠀",
|
|
513
|
+
"⣠⠞⠁⠀",
|
|
514
|
+
"⢀⡴⠋⠀",
|
|
515
|
+
"⠀⣠⠞⠁",
|
|
516
|
+
"⠀⢀⡴⠋",
|
|
517
|
+
"⠀⠀⣠⠞",
|
|
518
|
+
"⠀⠀⢀⡴",
|
|
519
|
+
"⠀⠀⠀⣠",
|
|
520
|
+
"⠀⠀⠀⢀",
|
|
521
|
+
] as const);
|
|
522
|
+
export const BRAILLE_COLUMNS_FRAMES = Object.freeze([
|
|
523
|
+
"⡀⠀⠀",
|
|
524
|
+
"⡄⠀⠀",
|
|
525
|
+
"⡆⠀⠀",
|
|
526
|
+
"⡇⠀⠀",
|
|
527
|
+
"⣇⠀⠀",
|
|
528
|
+
"⣧⠀⠀",
|
|
529
|
+
"⣷⠀⠀",
|
|
530
|
+
"⣿⠀⠀",
|
|
531
|
+
"⣿⡀⠀",
|
|
532
|
+
"⣿⡄⠀",
|
|
533
|
+
"⣿⡆⠀",
|
|
534
|
+
"⣿⡇⠀",
|
|
535
|
+
"⣿⣇⠀",
|
|
536
|
+
"⣿⣧⠀",
|
|
537
|
+
"⣿⣷⠀",
|
|
538
|
+
"⣿⣿⠀",
|
|
539
|
+
"⣿⣿⡀",
|
|
540
|
+
"⣿⣿⡄",
|
|
541
|
+
"⣿⣿⡆",
|
|
542
|
+
"⣿⣿⡇",
|
|
543
|
+
"⣿⣿⣇",
|
|
544
|
+
"⣿⣿⣧",
|
|
545
|
+
"⣿⣿⣷",
|
|
546
|
+
"⣿⣿⣿",
|
|
547
|
+
"⣿⣿⣿",
|
|
548
|
+
"⠀⠀⠀",
|
|
549
|
+
] as const);
|
|
550
|
+
export const BRAILLE_ORBIT_FRAMES = Object.freeze(["⠃", "⠉", "⠘", "⠰", "⢠", "⣀", "⡄", "⠆"] as const);
|
|
551
|
+
export const BRAILLE_BREATHE_FRAMES = Object.freeze([
|
|
552
|
+
"⠀",
|
|
553
|
+
"⠂",
|
|
554
|
+
"⠌",
|
|
555
|
+
"⡑",
|
|
556
|
+
"⢕",
|
|
557
|
+
"⢝",
|
|
558
|
+
"⣫",
|
|
559
|
+
"⣟",
|
|
560
|
+
"⣿",
|
|
561
|
+
"⣟",
|
|
562
|
+
"⣫",
|
|
563
|
+
"⢝",
|
|
564
|
+
"⢕",
|
|
565
|
+
"⡑",
|
|
566
|
+
"⠌",
|
|
567
|
+
"⠂",
|
|
568
|
+
"⠀",
|
|
569
|
+
] as const);
|
|
570
|
+
export const BRAILLE_WAVE_ROWS_FRAMES = Object.freeze([
|
|
571
|
+
"⠖⠉⠉⠑",
|
|
572
|
+
"⡠⠖⠉⠉",
|
|
573
|
+
"⣠⡠⠖⠉",
|
|
574
|
+
"⣄⣠⡠⠖",
|
|
575
|
+
"⠢⣄⣠⡠",
|
|
576
|
+
"⠙⠢⣄⣠",
|
|
577
|
+
"⠉⠙⠢⣄",
|
|
578
|
+
"⠊⠉⠙⠢",
|
|
579
|
+
"⠜⠊⠉⠙",
|
|
580
|
+
"⡤⠜⠊⠉",
|
|
581
|
+
"⣀⡤⠜⠊",
|
|
582
|
+
"⢤⣀⡤⠜",
|
|
583
|
+
"⠣⢤⣀⡤",
|
|
584
|
+
"⠑⠣⢤⣀",
|
|
585
|
+
"⠉⠑⠣⢤",
|
|
586
|
+
"⠋⠉⠑⠣",
|
|
587
|
+
] as const);
|
|
588
|
+
export const BRAILLE_CHECKERBOARD_FRAMES = Object.freeze(["⢕⢕⢕", "⡪⡪⡪", "⢊⠔⡡", "⡡⢊⠔"] as const);
|
|
589
|
+
export const BRAILLE_HELIX_FRAMES = Object.freeze([
|
|
590
|
+
"⢌⣉⢎⣉",
|
|
591
|
+
"⣉⡱⣉⡱",
|
|
592
|
+
"⣉⢎⣉⢎",
|
|
593
|
+
"⡱⣉⡱⣉",
|
|
594
|
+
"⢎⣉⢎⣉",
|
|
595
|
+
"⣉⡱⣉⡱",
|
|
596
|
+
"⣉⢎⣉⢎",
|
|
597
|
+
"⡱⣉⡱⣉",
|
|
598
|
+
"⢎⣉⢎⣉",
|
|
599
|
+
"⣉⡱⣉⡱",
|
|
600
|
+
"⣉⢎⣉⢎",
|
|
601
|
+
"⡱⣉⡱⣉",
|
|
602
|
+
"⢎⣉⢎⣉",
|
|
603
|
+
"⣉⡱⣉⡱",
|
|
604
|
+
"⣉⢎⣉⢎",
|
|
605
|
+
"⡱⣉⡱⣉",
|
|
606
|
+
] as const);
|
|
607
|
+
export const SCANLINE_FRAMES = Object.freeze(["⠉⠉⠉", "⠛⠛⠛", "⠿⠿⠿", "⣿⣿⣿", "⣶⣶⣶", "⣤⣤⣤", "⣀⣀⣀", "⠀⠀⠀"] as const);
|
|
608
|
+
export const SNAKE_FRAMES = Object.freeze([
|
|
609
|
+
"⣁⡀",
|
|
610
|
+
"⣉⠀",
|
|
611
|
+
"⡉⠁",
|
|
612
|
+
"⠉⠉",
|
|
613
|
+
"⠈⠙",
|
|
614
|
+
"⠀⠛",
|
|
615
|
+
"⠐⠚",
|
|
616
|
+
"⠒⠒",
|
|
617
|
+
"⠖⠂",
|
|
618
|
+
"⠶⠀",
|
|
619
|
+
"⠦⠄",
|
|
620
|
+
"⠤⠤",
|
|
621
|
+
"⠠⢤",
|
|
622
|
+
"⠀⣤",
|
|
623
|
+
"⢀⣠",
|
|
624
|
+
"⣀⣀",
|
|
625
|
+
] as const);
|
|
626
|
+
export const FILL_SWEEP_FRAMES = Object.freeze([
|
|
627
|
+
"⣀⣀",
|
|
628
|
+
"⣤⣤",
|
|
629
|
+
"⣶⣶",
|
|
630
|
+
"⣿⣿",
|
|
631
|
+
"⣿⣿",
|
|
632
|
+
"⣿⣿",
|
|
633
|
+
"⣶⣶",
|
|
634
|
+
"⣤⣤",
|
|
635
|
+
"⣀⣀",
|
|
636
|
+
"⠀⠀",
|
|
637
|
+
"⠀⠀",
|
|
638
|
+
] as const);
|
|
639
|
+
export const DIAGONAL_SWIPE_FRAMES = Object.freeze([
|
|
640
|
+
"⠁⠀",
|
|
641
|
+
"⠋⠀",
|
|
642
|
+
"⠟⠁",
|
|
643
|
+
"⡿⠋",
|
|
644
|
+
"⣿⠟",
|
|
645
|
+
"⣿⡿",
|
|
646
|
+
"⣿⣿",
|
|
647
|
+
"⣿⣿",
|
|
648
|
+
"⣾⣿",
|
|
649
|
+
"⣴⣿",
|
|
650
|
+
"⣠⣾",
|
|
651
|
+
"⢀⣴",
|
|
652
|
+
"⠀⣠",
|
|
653
|
+
"⠀⢀",
|
|
654
|
+
"⠀⠀",
|
|
655
|
+
"⠀⠀",
|
|
656
|
+
] as const);
|
|
657
|
+
export const DNA_FRAMES = Object.freeze(["⠋⠉⠙", "⠙⠒⠚", "⠚⠤⠴", "⠴⠤⠦", "⠦⠒⠋"] as const);
|
|
658
|
+
export const RADAR_FRAMES = Object.freeze(["⠁ ", " ⠂ ", " ⠄", " ⠂ "] as const);
|
|
659
|
+
export const BOUNCE_FRAMES = Object.freeze(["✦ ", " ✦ ", " ✦", " ✦ "] as const);
|
|
660
|
+
export const ORBIT_FRAMES = Object.freeze(["◜✦ ", "◠✦ ", "◝✦ ", " ✦◞", " ✦◡", " ✦◟"] as const);
|
|
661
|
+
export const CONVEYOR_FRAMES = Object.freeze(["▰▱▱", "▱▰▱", "▱▱▰", "▱▰▱"] as const);
|
|
662
|
+
export const HEARTBEAT_FRAMES = Object.freeze([" . ", " . ", " - ", "=|=", " - ", " . "] as const);
|
|
663
|
+
export const NERD_MORPH_FRAMES = Object.freeze([
|
|
664
|
+
"\uf0eb",
|
|
665
|
+
"\uf013",
|
|
666
|
+
"\uf0e7",
|
|
667
|
+
"\uf135",
|
|
668
|
+
"\uf005",
|
|
669
|
+
"\uf06d",
|
|
670
|
+
"\uf0ac",
|
|
671
|
+
"\uf004",
|
|
672
|
+
] as const);
|
|
673
|
+
export const NERD_PIPELINE_FRAMES = Object.freeze([
|
|
674
|
+
"\uf0e7--",
|
|
675
|
+
"-\uf013-",
|
|
676
|
+
"--\uf121",
|
|
677
|
+
"-\uf0ad-",
|
|
678
|
+
"\uf00c--",
|
|
679
|
+
] as const);
|
|
680
|
+
export const NERD_PI_ORBIT_FRAMES = Object.freeze(["*\ue22c.", " \ue22c*", ".\ue22c*", "*\ue22c "] as const);
|
|
681
|
+
const DOUBLE_LINE_FRAMES = Object.freeze(["| ", " /", " -", "\\ "] as const);
|
|
682
|
+
const TRIPLE_LINE_FRAMES = Object.freeze(["| ", " / ", " -", " \\ "] as const);
|
|
683
|
+
const QUADRUPLE_LINE_FRAMES = Object.freeze(["| ", " / ", " - ", " \\"] as const);
|
|
684
|
+
|
|
685
|
+
type MarkerWidth = 1 | 2 | 3 | 4;
|
|
686
|
+
|
|
687
|
+
interface MarkerAnimation {
|
|
688
|
+
readonly frames: readonly string[];
|
|
689
|
+
readonly cadenceMs: number;
|
|
690
|
+
readonly width: MarkerWidth;
|
|
691
|
+
readonly nerdFonts?: true;
|
|
692
|
+
readonly nerdFrames?: readonly string[];
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
type AnimatedMarkerStyle = Exclude<TuiActivityIndicatorStyle, "off" | "static">;
|
|
696
|
+
|
|
697
|
+
const MARKER_ANIMATIONS = Object.freeze({
|
|
698
|
+
spinner: { frames: SPINNER_FRAMES, cadenceMs: 80, width: 1 },
|
|
699
|
+
line: { frames: LINE_FRAMES, cadenceMs: 100, width: 1 },
|
|
700
|
+
arc: { frames: ARC_FRAMES, cadenceMs: 90, width: 1 },
|
|
701
|
+
pipe: { frames: PIPE_FRAMES, cadenceMs: 100, width: 1 },
|
|
702
|
+
"grow-vertical": { frames: GROW_VERTICAL_FRAMES, cadenceMs: 100, width: 1 },
|
|
703
|
+
"grow-horizontal": { frames: GROW_HORIZONTAL_FRAMES, cadenceMs: 100, width: 1 },
|
|
704
|
+
triangle: { frames: TRIANGLE_FRAMES, cadenceMs: 120, width: 1 },
|
|
705
|
+
"circle-quarters": { frames: CIRCLE_QUARTER_FRAMES, cadenceMs: 120, width: 1 },
|
|
706
|
+
"circle-halves": { frames: CIRCLE_HALF_FRAMES, cadenceMs: 120, width: 1 },
|
|
707
|
+
"bracket-spin": { frames: BRACKET_SPIN_FRAMES, cadenceMs: 120, width: 1 },
|
|
708
|
+
dots: { frames: DOT_FRAMES, cadenceMs: 80, width: 1 },
|
|
709
|
+
quadrants: { frames: QUADRANT_FRAMES, cadenceMs: 100, width: 1 },
|
|
710
|
+
sparkle: { frames: SPARKLE_FRAMES, cadenceMs: 240, width: 1 },
|
|
711
|
+
"braille-wave": { frames: BRAILLE_WAVE_FRAMES, cadenceMs: 100, width: 4 },
|
|
712
|
+
"braille-dna": { frames: BRAILLE_DNA_FRAMES, cadenceMs: 80, width: 4 },
|
|
713
|
+
"braille-scan": { frames: BRAILLE_SCAN_FRAMES, cadenceMs: 70, width: 4 },
|
|
714
|
+
"braille-rain": { frames: BRAILLE_RAIN_FRAMES, cadenceMs: 100, width: 4 },
|
|
715
|
+
"braille-scanline": { frames: BRAILLE_SCANLINE_FRAMES, cadenceMs: 120, width: 3 },
|
|
716
|
+
"braille-pulse": { frames: BRAILLE_PULSE_FRAMES, cadenceMs: 180, width: 3 },
|
|
717
|
+
"braille-sparkle": { frames: BRAILLE_SPARKLE_FRAMES, cadenceMs: 150, width: 4 },
|
|
718
|
+
"braille-cascade": { frames: BRAILLE_CASCADE_FRAMES, cadenceMs: 60, width: 4 },
|
|
719
|
+
"braille-columns": { frames: BRAILLE_COLUMNS_FRAMES, cadenceMs: 60, width: 3 },
|
|
720
|
+
"braille-orbit": { frames: BRAILLE_ORBIT_FRAMES, cadenceMs: 100, width: 1 },
|
|
721
|
+
"braille-breathe": { frames: BRAILLE_BREATHE_FRAMES, cadenceMs: 100, width: 1 },
|
|
722
|
+
"braille-wave-rows": { frames: BRAILLE_WAVE_ROWS_FRAMES, cadenceMs: 90, width: 4 },
|
|
723
|
+
"braille-checkerboard": { frames: BRAILLE_CHECKERBOARD_FRAMES, cadenceMs: 250, width: 3 },
|
|
724
|
+
"braille-helix": { frames: BRAILLE_HELIX_FRAMES, cadenceMs: 80, width: 4 },
|
|
725
|
+
scanline: { frames: SCANLINE_FRAMES, cadenceMs: 120, width: 3 },
|
|
726
|
+
snake: { frames: SNAKE_FRAMES, cadenceMs: 80, width: 2 },
|
|
727
|
+
"fill-sweep": { frames: FILL_SWEEP_FRAMES, cadenceMs: 100, width: 2 },
|
|
728
|
+
"diagonal-swipe": { frames: DIAGONAL_SWIPE_FRAMES, cadenceMs: 60, width: 2 },
|
|
729
|
+
dna: { frames: DNA_FRAMES, cadenceMs: 90, width: 3 },
|
|
730
|
+
radar: { frames: RADAR_FRAMES, cadenceMs: 100, width: 3 },
|
|
731
|
+
bounce: { frames: BOUNCE_FRAMES, cadenceMs: 110, width: 3 },
|
|
732
|
+
orbit: { frames: ORBIT_FRAMES, cadenceMs: 100, width: 3 },
|
|
733
|
+
conveyor: { frames: CONVEYOR_FRAMES, cadenceMs: 120, width: 3 },
|
|
734
|
+
heartbeat: { frames: HEARTBEAT_FRAMES, cadenceMs: 110, width: 3 },
|
|
735
|
+
"nerd-progress": { frames: NERD_PROGRESS_FRAMES, cadenceMs: 90, width: 1, nerdFonts: true },
|
|
736
|
+
"nerd-morph": { frames: NERD_MORPH_FRAMES, cadenceMs: 180, width: 1, nerdFonts: true },
|
|
737
|
+
"nerd-pipeline": { frames: NERD_PIPELINE_FRAMES, cadenceMs: 160, width: 3, nerdFonts: true },
|
|
738
|
+
"nerd-pi-orbit": { frames: NERD_PI_ORBIT_FRAMES, cadenceMs: 140, width: 3, nerdFonts: true },
|
|
739
|
+
} satisfies Record<AnimatedMarkerStyle, MarkerAnimation>);
|
|
740
|
+
|
|
741
|
+
function markerAnimation(style: TuiActivityIndicatorStyle): MarkerAnimation | undefined {
|
|
742
|
+
if (style === "off" || style === "static") return undefined;
|
|
743
|
+
return MARKER_ANIMATIONS[style];
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Resolve a cyclic spinner frame without retaining animation state. */
|
|
747
|
+
export function spinnerFrame(
|
|
748
|
+
elapsedMs: number,
|
|
749
|
+
options: { frames?: readonly string[]; cadenceMs?: number; reducedMotion?: boolean } = {},
|
|
750
|
+
): string {
|
|
751
|
+
return glyphFrame(options.frames ?? SPINNER_FRAMES, elapsedMs, options.cadenceMs ?? 80, options.reducedMotion);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Independently rendered activity marker and text. */
|
|
755
|
+
export interface ActivityFrame {
|
|
756
|
+
marker: string;
|
|
757
|
+
text: string;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Render the configured activity, optionally shimmering marker and text as one unit. */
|
|
761
|
+
export function activityFrame(
|
|
762
|
+
colors: TuiTheme,
|
|
763
|
+
text: string,
|
|
764
|
+
elapsedMs: number,
|
|
765
|
+
options: ActivityAnimationOverrides & {
|
|
766
|
+
cadenceMs?: number;
|
|
767
|
+
frames?: readonly string[];
|
|
768
|
+
textTone?: TuiForegroundToken;
|
|
769
|
+
highlightTone?: TuiForegroundToken;
|
|
770
|
+
reducedMotion?: boolean;
|
|
771
|
+
} = {},
|
|
772
|
+
): ActivityFrame {
|
|
773
|
+
const appearance = getTuiAppearance();
|
|
774
|
+
const configuredMarker = options.indicatorStyle ?? appearance.activityIndicator;
|
|
775
|
+
const indicatorStyle = options.reducedMotion && configuredMarker !== "off" ? "static" : configuredMarker;
|
|
776
|
+
const textEffectStyle = options.reducedMotion ? "off" : (options.textEffectStyle ?? appearance.textEffect);
|
|
777
|
+
const pulseEffectStyle = options.reducedMotion ? "off" : (options.pulseEffectStyle ?? appearance.pulseEffect);
|
|
778
|
+
const textEffectScope = options.textEffectScope ?? appearance.textEffectScope;
|
|
779
|
+
const speedMultiplier = animationSpeedMultiplier(options.animationSpeed ?? appearance.animationSpeed);
|
|
780
|
+
const animationElapsedMs = elapsedMs * speedMultiplier;
|
|
781
|
+
const sampleCadenceMs =
|
|
782
|
+
animationSmoothnessCadenceMs(options.animationSmoothness ?? appearance.animationSmoothness) * speedMultiplier;
|
|
783
|
+
const textTone = options.textTone ?? (textEffectStyle === "off" ? "text.primary" : "text.muted");
|
|
784
|
+
const paint =
|
|
785
|
+
pulseEffectStyle === "pulse"
|
|
786
|
+
? brightnessPulsingTheme(colors, animationElapsedMs)
|
|
787
|
+
: pulseEffectStyle === "color"
|
|
788
|
+
? colorPulsingTheme(colors, animationElapsedMs)
|
|
789
|
+
: colors;
|
|
790
|
+
let markerGlyph: string;
|
|
791
|
+
let marker: string;
|
|
792
|
+
switch (indicatorStyle) {
|
|
793
|
+
case "off":
|
|
794
|
+
markerGlyph = "";
|
|
795
|
+
marker = "";
|
|
796
|
+
break;
|
|
797
|
+
case "static":
|
|
798
|
+
markerGlyph = "●";
|
|
799
|
+
marker = paint.fg("accent", markerGlyph);
|
|
800
|
+
break;
|
|
801
|
+
default: {
|
|
802
|
+
const animation = markerAnimation(indicatorStyle);
|
|
803
|
+
if (!animation) {
|
|
804
|
+
markerGlyph = "";
|
|
805
|
+
marker = "";
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
const configuredFrames = indicatorStyle === "spinner" ? (options.frames ?? animation.frames) : animation.frames;
|
|
809
|
+
const frames =
|
|
810
|
+
animation.nerdFrames && appearance.iconPack === "nerd-fonts"
|
|
811
|
+
? animation.nerdFrames
|
|
812
|
+
: animation.nerdFonts && appearance.iconPack !== "nerd-fonts"
|
|
813
|
+
? fallbackFrames(animation.width)
|
|
814
|
+
: configuredFrames;
|
|
815
|
+
markerGlyph = activityGlyph(
|
|
816
|
+
glyphFrame(frames, animationElapsedMs, options.cadenceMs ?? animation.cadenceMs),
|
|
817
|
+
"-".repeat(animation.width),
|
|
818
|
+
animation.width,
|
|
819
|
+
);
|
|
820
|
+
marker = paint.fg("accent", markerGlyph);
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const markerLength = textGraphemes(markerGlyph).length;
|
|
825
|
+
const textLength = textGraphemes(text).length;
|
|
826
|
+
const shimmerLength = markerLength + 1 + textLength;
|
|
827
|
+
if (markerGlyph && textEffectScope === "inline" && textEffectStyle !== "off") {
|
|
828
|
+
marker = paintShimmer(paint, markerGlyph, animationElapsedMs, textEffectStyle, {
|
|
829
|
+
cadenceMs: options.cadenceMs,
|
|
830
|
+
baseTone: textTone,
|
|
831
|
+
highlightTone: options.highlightTone,
|
|
832
|
+
variantAscii: true,
|
|
833
|
+
sampleCadenceMs,
|
|
834
|
+
offset: 0,
|
|
835
|
+
totalLength: shimmerLength,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
const textOutput = paintShimmer(paint, text, animationElapsedMs, textEffectStyle, {
|
|
839
|
+
cadenceMs: options.cadenceMs,
|
|
840
|
+
baseTone: textTone,
|
|
841
|
+
highlightTone: options.highlightTone,
|
|
842
|
+
variantAscii: true,
|
|
843
|
+
sampleCadenceMs,
|
|
844
|
+
offset: markerGlyph && textEffectScope === "inline" ? markerLength + 1 : 0,
|
|
845
|
+
totalLength: markerGlyph && textEffectScope === "inline" ? shimmerLength : textLength,
|
|
846
|
+
});
|
|
847
|
+
return { marker, text: textOutput };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function paintShimmer(
|
|
851
|
+
colors: TuiTheme,
|
|
852
|
+
text: string,
|
|
853
|
+
elapsedMs: number,
|
|
854
|
+
style: TuiTextEffectStyle,
|
|
855
|
+
options: {
|
|
856
|
+
cadenceMs?: number;
|
|
857
|
+
baseTone: TuiForegroundToken;
|
|
858
|
+
highlightTone?: TuiForegroundToken;
|
|
859
|
+
variantAscii: boolean;
|
|
860
|
+
sampleCadenceMs: number;
|
|
861
|
+
offset: number;
|
|
862
|
+
totalLength: number;
|
|
863
|
+
},
|
|
864
|
+
): string {
|
|
865
|
+
const cadenceMs = options.cadenceMs ?? shimmerCadenceMs(style);
|
|
866
|
+
switch (style) {
|
|
867
|
+
case "off":
|
|
868
|
+
return colors.fg(options.baseTone, text);
|
|
869
|
+
case "rainbow":
|
|
870
|
+
return rainbowShimmerFrame(colors, text, elapsedMs, {
|
|
871
|
+
cadenceMs,
|
|
872
|
+
baseTone: options.baseTone,
|
|
873
|
+
offset: options.offset,
|
|
874
|
+
});
|
|
875
|
+
case "rainbow-glow":
|
|
876
|
+
return rainbowGlowShimmerFrame(colors, text, elapsedMs, {
|
|
877
|
+
cadenceMs,
|
|
878
|
+
baseTone: options.baseTone,
|
|
879
|
+
offset: options.offset,
|
|
880
|
+
totalLength: options.totalLength,
|
|
881
|
+
});
|
|
882
|
+
case "lightning":
|
|
883
|
+
return lightningShimmerFrame(colors, text, elapsedMs, {
|
|
884
|
+
cadenceMs,
|
|
885
|
+
baseTone: options.baseTone,
|
|
886
|
+
variantAscii: options.variantAscii,
|
|
887
|
+
variantCadenceMs: options.sampleCadenceMs,
|
|
888
|
+
offset: options.offset,
|
|
889
|
+
totalLength: options.totalLength,
|
|
890
|
+
});
|
|
891
|
+
case "aurora":
|
|
892
|
+
return auroraTextEffectFrame(colors, text, elapsedMs, {
|
|
893
|
+
cadenceMs,
|
|
894
|
+
baseTone: options.baseTone,
|
|
895
|
+
offset: options.offset,
|
|
896
|
+
});
|
|
897
|
+
case "glitch":
|
|
898
|
+
return glitchTextEffectFrame(colors, text, elapsedMs, {
|
|
899
|
+
cadenceMs,
|
|
900
|
+
baseTone: options.baseTone,
|
|
901
|
+
offset: options.offset,
|
|
902
|
+
});
|
|
903
|
+
case "crush":
|
|
904
|
+
return crushTextEffectFrame(colors, text, elapsedMs, {
|
|
905
|
+
cadenceMs,
|
|
906
|
+
baseTone: options.baseTone,
|
|
907
|
+
offset: options.offset,
|
|
908
|
+
totalLength: options.totalLength,
|
|
909
|
+
});
|
|
910
|
+
case "sweep":
|
|
911
|
+
return shimmerFrame(colors, text, elapsedMs, {
|
|
912
|
+
cadenceMs,
|
|
913
|
+
width: 1,
|
|
914
|
+
baseTone: options.baseTone,
|
|
915
|
+
highlightTone: options.highlightTone,
|
|
916
|
+
offset: options.offset,
|
|
917
|
+
totalLength: options.totalLength,
|
|
918
|
+
profile: "linear",
|
|
919
|
+
});
|
|
920
|
+
case "glow":
|
|
921
|
+
return shimmerFrame(colors, text, elapsedMs, {
|
|
922
|
+
baseTone: options.baseTone,
|
|
923
|
+
highlightTone: options.highlightTone,
|
|
924
|
+
offset: options.offset,
|
|
925
|
+
totalLength: options.totalLength,
|
|
926
|
+
profile: "cosine",
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function activityGlyph(value: string, fallback: string, width: MarkerWidth): string {
|
|
932
|
+
const safe = sanitizeTuiText(value);
|
|
933
|
+
return safe === value && safe.trim().length > 0 && visibleWidth(safe) === width ? safe : fallback;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function fallbackFrames(width: MarkerWidth): readonly string[] {
|
|
937
|
+
return width === 1
|
|
938
|
+
? LINE_FRAMES
|
|
939
|
+
: width === 2
|
|
940
|
+
? DOUBLE_LINE_FRAMES
|
|
941
|
+
: width === 3
|
|
942
|
+
? TRIPLE_LINE_FRAMES
|
|
943
|
+
: QUADRUPLE_LINE_FRAMES;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Resolve a cyclic glyph frame without retaining animation state. */
|
|
947
|
+
export function glyphFrame(
|
|
948
|
+
frames: readonly string[],
|
|
949
|
+
elapsedMs: number,
|
|
950
|
+
cadenceMs = 120,
|
|
951
|
+
reducedMotion = false,
|
|
952
|
+
): string {
|
|
953
|
+
if (frames.length === 0) return "";
|
|
954
|
+
if (reducedMotion) return frames[0] ?? "";
|
|
955
|
+
const cadence = Math.max(1, cadenceMs);
|
|
956
|
+
return frames[Math.floor(Math.max(0, elapsedMs) / cadence) % frames.length] ?? "";
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Resolve a cosine-eased pulse value between caller-provided bounds. */
|
|
960
|
+
export function pulseFrame(
|
|
961
|
+
elapsedMs: number,
|
|
962
|
+
options: { periodMs?: number; low?: number; high?: number } = {},
|
|
963
|
+
): number {
|
|
964
|
+
const period = Math.max(1, options.periodMs ?? 1_200);
|
|
965
|
+
const low = options.low ?? 0;
|
|
966
|
+
const high = options.high ?? 1;
|
|
967
|
+
const phase = (Math.max(0, elapsedMs) % period) / period;
|
|
968
|
+
return low + (0.5 - 0.5 * Math.cos(phase * Math.PI * 2)) * (high - low);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const EFFECT_CONTRAST_CANDIDATES = Object.freeze([
|
|
972
|
+
"accent",
|
|
973
|
+
"highlight",
|
|
974
|
+
"info",
|
|
975
|
+
"positive",
|
|
976
|
+
"warning",
|
|
977
|
+
"negative",
|
|
978
|
+
"text.primary",
|
|
979
|
+
] as const satisfies readonly TuiForegroundPaint[]);
|
|
980
|
+
|
|
981
|
+
function adaptiveEffectHighlight(colors: TuiTheme, base: TuiForegroundPaint): TuiColor {
|
|
982
|
+
return colors.strongestForegroundContrast(base, EFFECT_CONTRAST_CANDIDATES);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function transformedForegroundTheme(colors: TuiTheme, transform: (paint: TuiForegroundPaint) => TuiColor): TuiTheme {
|
|
986
|
+
return {
|
|
987
|
+
color: colors.color,
|
|
988
|
+
mixForeground: colors.mixForeground,
|
|
989
|
+
adjustForegroundBrightness: colors.adjustForegroundBrightness,
|
|
990
|
+
fg: (paint, text) => colors.fg(transform(paint), text),
|
|
991
|
+
bg: colors.bg,
|
|
992
|
+
fgAnsi: (paint) => colors.fgAnsi(transform(paint)),
|
|
993
|
+
bgAnsi: colors.bgAnsi,
|
|
994
|
+
contrastBackground: colors.contrastBackground,
|
|
995
|
+
strongestForegroundContrast: colors.strongestForegroundContrast,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Compose a cosine dim-to-bright pulse over every foreground painted by another activity effect. */
|
|
1000
|
+
function brightnessPulsingTheme(colors: TuiTheme, elapsedMs: number): TuiTheme {
|
|
1001
|
+
const amount = pulseFrame(elapsedMs, { low: -0.4, high: 0.18 });
|
|
1002
|
+
return transformedForegroundTheme(colors, (paint) => colors.adjustForegroundBrightness(paint, amount));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Compose a cosine color pulse over every foreground painted by another activity effect. */
|
|
1006
|
+
function colorPulsingTheme(colors: TuiTheme, elapsedMs: number): TuiTheme {
|
|
1007
|
+
const amount = pulseFrame(elapsedMs, { low: 0.05, high: 0.55 });
|
|
1008
|
+
return transformedForegroundTheme(colors, (paint) =>
|
|
1009
|
+
colors.mixForeground(paint, adaptiveEffectHighlight(colors, paint), amount),
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Paint a glyph with a semantic/harmonious glow according to a pure pulse frame. */
|
|
1014
|
+
export function pulseGlyphFrame(
|
|
1015
|
+
colors: TuiTheme,
|
|
1016
|
+
glyph: string,
|
|
1017
|
+
elapsedMs: number,
|
|
1018
|
+
options: {
|
|
1019
|
+
periodMs?: number;
|
|
1020
|
+
baseTone?: TuiForegroundToken;
|
|
1021
|
+
highlightTone?: TuiForegroundToken;
|
|
1022
|
+
reducedMotion?: boolean;
|
|
1023
|
+
} = {},
|
|
1024
|
+
): string {
|
|
1025
|
+
const baseTone = options.baseTone ?? "text.muted";
|
|
1026
|
+
if (options.reducedMotion) return colors.fg(baseTone, glyph);
|
|
1027
|
+
const highlightTone = options.highlightTone ?? adaptiveEffectHighlight(colors, baseTone);
|
|
1028
|
+
return colors.fg(
|
|
1029
|
+
colors.mixForeground(baseTone, highlightTone, pulseFrame(elapsedMs, { periodMs: options.periodMs })),
|
|
1030
|
+
glyph,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Paint a moving semantic glow over text, grouping adjacent runs for low ANSI overhead. */
|
|
1035
|
+
export function shimmerFrame(
|
|
1036
|
+
colors: TuiTheme,
|
|
1037
|
+
text: string,
|
|
1038
|
+
elapsedMs: number,
|
|
1039
|
+
options: {
|
|
1040
|
+
cadenceMs?: number;
|
|
1041
|
+
width?: number;
|
|
1042
|
+
profile?: "cosine" | "linear";
|
|
1043
|
+
baseTone?: TuiForegroundPaint;
|
|
1044
|
+
highlightTone?: TuiForegroundPaint;
|
|
1045
|
+
reducedMotion?: boolean;
|
|
1046
|
+
offset?: number;
|
|
1047
|
+
totalLength?: number;
|
|
1048
|
+
} = {},
|
|
1049
|
+
): string {
|
|
1050
|
+
const characters = textGraphemes(text);
|
|
1051
|
+
if (characters.length === 0) return "";
|
|
1052
|
+
const baseTone = options.baseTone ?? "text.secondary";
|
|
1053
|
+
const highlightTone = options.highlightTone ?? adaptiveEffectHighlight(colors, baseTone);
|
|
1054
|
+
if (options.reducedMotion) return colors.fg(baseTone, text);
|
|
1055
|
+
if ((options.profile ?? "cosine") === "cosine")
|
|
1056
|
+
return cosineShimmerFrame(colors, characters, elapsedMs, {
|
|
1057
|
+
baseTone,
|
|
1058
|
+
highlightTone,
|
|
1059
|
+
offset: options.offset,
|
|
1060
|
+
totalLength: options.totalLength,
|
|
1061
|
+
});
|
|
1062
|
+
const shineWidth = Math.min(5, Math.max(1, Math.floor(options.width ?? 3)));
|
|
1063
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
1064
|
+
const totalLength = Math.max(offset + characters.length, Math.floor(options.totalLength ?? characters.length));
|
|
1065
|
+
const cycle = totalLength + shineWidth;
|
|
1066
|
+
const position = (Math.max(0, elapsedMs) / Math.max(1, options.cadenceMs ?? 70)) % cycle;
|
|
1067
|
+
const glow = glowPalette(colors, baseTone, highlightTone);
|
|
1068
|
+
let currentTone: TuiForegroundPaint | undefined;
|
|
1069
|
+
let run = "";
|
|
1070
|
+
let output = "";
|
|
1071
|
+
for (const [index, character] of characters.entries()) {
|
|
1072
|
+
const distance = Math.abs(index + offset - position);
|
|
1073
|
+
const glowIndex = Math.min(glow.length - 1, Math.floor((distance / shineWidth) * glow.length));
|
|
1074
|
+
const tone = distance <= shineWidth ? (glow[glowIndex] ?? baseTone) : baseTone;
|
|
1075
|
+
if (currentTone !== undefined && tone !== currentTone) {
|
|
1076
|
+
output += colors.fg(currentTone, run);
|
|
1077
|
+
run = "";
|
|
1078
|
+
}
|
|
1079
|
+
currentTone = tone;
|
|
1080
|
+
run += character;
|
|
1081
|
+
}
|
|
1082
|
+
return `${output}${colors.fg(currentTone ?? baseTone, run)}`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const COSINE_SHIMMER_SPEED_CELLS_PER_SECOND = 30;
|
|
1086
|
+
const COSINE_SHIMMER_PADDING = 10;
|
|
1087
|
+
const COSINE_SHIMMER_HALF_WIDTH = 6;
|
|
1088
|
+
|
|
1089
|
+
/** Port oh-my-pi's fixed-velocity cosine shimmer over one continuous activity unit. */
|
|
1090
|
+
function cosineShimmerFrame(
|
|
1091
|
+
colors: TuiTheme,
|
|
1092
|
+
characters: readonly string[],
|
|
1093
|
+
elapsedMs: number,
|
|
1094
|
+
options: {
|
|
1095
|
+
baseTone: TuiForegroundPaint;
|
|
1096
|
+
highlightTone: TuiForegroundPaint;
|
|
1097
|
+
offset?: number;
|
|
1098
|
+
totalLength?: number;
|
|
1099
|
+
},
|
|
1100
|
+
): string {
|
|
1101
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
1102
|
+
const totalLength = Math.max(offset + characters.length, Math.floor(options.totalLength ?? characters.length));
|
|
1103
|
+
const period = totalLength + COSINE_SHIMMER_PADDING * 2;
|
|
1104
|
+
const position = ((Math.max(0, elapsedMs) / 1_000) * COSINE_SHIMMER_SPEED_CELLS_PER_SECOND) % period;
|
|
1105
|
+
let run = "";
|
|
1106
|
+
let runColor: TuiColor | undefined;
|
|
1107
|
+
let runBold = false;
|
|
1108
|
+
let output = "";
|
|
1109
|
+
const flush = (): void => {
|
|
1110
|
+
if (!run || runColor === undefined) return;
|
|
1111
|
+
const painted = colors.fg(runColor, run);
|
|
1112
|
+
output += runBold ? `\x1b[1m${painted}\x1b[22m` : painted;
|
|
1113
|
+
run = "";
|
|
1114
|
+
};
|
|
1115
|
+
for (const [index, character] of characters.entries()) {
|
|
1116
|
+
const distance = Math.abs(index + offset + COSINE_SHIMMER_PADDING - position);
|
|
1117
|
+
const intensity =
|
|
1118
|
+
distance >= COSINE_SHIMMER_HALF_WIDTH
|
|
1119
|
+
? 0
|
|
1120
|
+
: 0.5 * (1 + Math.cos((Math.PI * distance) / COSINE_SHIMMER_HALF_WIDTH));
|
|
1121
|
+
const color = colors.mixForeground(options.baseTone, options.highlightTone, intensity);
|
|
1122
|
+
const bold = intensity >= 0.65;
|
|
1123
|
+
if (runColor !== undefined && (colors.fgAnsi(runColor) !== colors.fgAnsi(color) || runBold !== bold)) flush();
|
|
1124
|
+
runColor = color;
|
|
1125
|
+
runBold = bold;
|
|
1126
|
+
run += character;
|
|
1127
|
+
}
|
|
1128
|
+
flush();
|
|
1129
|
+
return output;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Paint a moving semantic rainbow wave over text, adapted from pi-animations' shimmer. */
|
|
1133
|
+
export function rainbowShimmerFrame(
|
|
1134
|
+
colors: TuiTheme,
|
|
1135
|
+
text: string,
|
|
1136
|
+
elapsedMs: number,
|
|
1137
|
+
options: { cadenceMs?: number; baseTone?: TuiForegroundToken; reducedMotion?: boolean; offset?: number } = {},
|
|
1138
|
+
): string {
|
|
1139
|
+
const characters = textGraphemes(text);
|
|
1140
|
+
if (characters.length === 0) return "";
|
|
1141
|
+
const baseTone = options.baseTone ?? "text.secondary";
|
|
1142
|
+
if (options.reducedMotion) return colors.fg(baseTone, text);
|
|
1143
|
+
const frame = Math.max(0, elapsedMs) / Math.max(1, options.cadenceMs ?? 80);
|
|
1144
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
1145
|
+
const hues: readonly TuiHue[] = ["magenta", "blue", "cyan", "green", "yellow", "red"];
|
|
1146
|
+
let output = "";
|
|
1147
|
+
for (const [index, character] of characters.entries()) {
|
|
1148
|
+
const globalIndex = index + offset;
|
|
1149
|
+
const wave = Math.sin((globalIndex - frame * 0.5) * 0.8);
|
|
1150
|
+
if (wave <= 0.3) {
|
|
1151
|
+
output += colors.fg(baseTone, character);
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const intensity = (wave - 0.3) / 0.7;
|
|
1155
|
+
const hue = hues[(globalIndex + Math.floor(frame / 2)) % hues.length] ?? "blue";
|
|
1156
|
+
const shade = Math.min(5, Math.max(2, Math.round(2 + intensity * 3))) as 2 | 3 | 4 | 5;
|
|
1157
|
+
output += colors.fg({ hue, shade }, character);
|
|
1158
|
+
}
|
|
1159
|
+
return output;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Paint a broad moving rainbow glow while retaining a subdued color trail. */
|
|
1163
|
+
export function rainbowGlowShimmerFrame(
|
|
1164
|
+
colors: TuiTheme,
|
|
1165
|
+
text: string,
|
|
1166
|
+
elapsedMs: number,
|
|
1167
|
+
options: {
|
|
1168
|
+
cadenceMs?: number;
|
|
1169
|
+
baseTone?: TuiForegroundToken;
|
|
1170
|
+
reducedMotion?: boolean;
|
|
1171
|
+
offset?: number;
|
|
1172
|
+
totalLength?: number;
|
|
1173
|
+
} = {},
|
|
1174
|
+
): string {
|
|
1175
|
+
const characters = textGraphemes(text);
|
|
1176
|
+
if (characters.length === 0) return "";
|
|
1177
|
+
const baseTone = options.baseTone ?? "text.secondary";
|
|
1178
|
+
if (options.reducedMotion) return colors.fg(baseTone, text);
|
|
1179
|
+
const frame = Math.max(0, elapsedMs) / Math.max(1, options.cadenceMs ?? 80);
|
|
1180
|
+
const hues: readonly TuiHue[] = ["magenta", "blue", "cyan", "green", "yellow", "red"];
|
|
1181
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
1182
|
+
const totalLength = Math.max(offset + characters.length, Math.floor(options.totalLength ?? characters.length));
|
|
1183
|
+
const position = frame % (totalLength + 5);
|
|
1184
|
+
return characters
|
|
1185
|
+
.map((character, index) => {
|
|
1186
|
+
const globalIndex = index + offset;
|
|
1187
|
+
const distance = Math.abs(globalIndex - position);
|
|
1188
|
+
const hue = hues[(globalIndex + Math.floor(frame / 2)) % hues.length] ?? "blue";
|
|
1189
|
+
const shade = distance === 0 ? 5 : distance === 1 ? 4 : distance <= 3 ? 3 : 2;
|
|
1190
|
+
return colors.fg({ hue, shade }, character);
|
|
1191
|
+
})
|
|
1192
|
+
.join("");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Paint the distinct magenta-blue-cyan wave formerly bundled as a status-row replacement. */
|
|
1196
|
+
export function auroraTextEffectFrame(
|
|
1197
|
+
colors: TuiTheme,
|
|
1198
|
+
text: string,
|
|
1199
|
+
elapsedMs: number,
|
|
1200
|
+
options: { cadenceMs?: number; baseTone?: TuiForegroundToken; reducedMotion?: boolean; offset?: number } = {},
|
|
1201
|
+
): string {
|
|
1202
|
+
const characters = textGraphemes(text);
|
|
1203
|
+
if (characters.length === 0) return "";
|
|
1204
|
+
const baseTone = options.baseTone ?? "text.secondary";
|
|
1205
|
+
if (options.reducedMotion) return colors.fg(baseTone, text);
|
|
1206
|
+
const frame = Math.max(0, elapsedMs) / Math.max(1, options.cadenceMs ?? 80);
|
|
1207
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
1208
|
+
const hues: readonly TuiHue[] = ["magenta", "blue", "cyan"];
|
|
1209
|
+
return characters
|
|
1210
|
+
.map((character, index) => {
|
|
1211
|
+
const globalIndex = index + offset;
|
|
1212
|
+
const wave = Math.sin((globalIndex - frame * 0.3) * 0.8);
|
|
1213
|
+
if (wave <= 0.3) return colors.fg(baseTone, character);
|
|
1214
|
+
const hue = hues[Math.floor(globalIndex + frame * 0.5) % hues.length] ?? "blue";
|
|
1215
|
+
const painted = colors.fg(
|
|
1216
|
+
{ hue, shade: Math.max(3, Math.min(5, Math.round(3 + wave * 2))) as 3 | 4 | 5 },
|
|
1217
|
+
character,
|
|
1218
|
+
);
|
|
1219
|
+
return `\x1b[1m${painted}\x1b[22m`;
|
|
1220
|
+
})
|
|
1221
|
+
.join("");
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const GLITCH_GLYPHS = "█▓▒░╳╱╲¥£€$#@!?&%~*";
|
|
1225
|
+
|
|
1226
|
+
/** Briefly replace isolated message cells with deterministic cyberpunk artifacts. */
|
|
1227
|
+
export function glitchTextEffectFrame(
|
|
1228
|
+
colors: TuiTheme,
|
|
1229
|
+
text: string,
|
|
1230
|
+
elapsedMs: number,
|
|
1231
|
+
options: { cadenceMs?: number; baseTone?: TuiForegroundToken; reducedMotion?: boolean; offset?: number } = {},
|
|
1232
|
+
): string {
|
|
1233
|
+
const characters = textGraphemes(text);
|
|
1234
|
+
if (characters.length === 0) return "";
|
|
1235
|
+
const baseTone = options.baseTone ?? "text.secondary";
|
|
1236
|
+
if (options.reducedMotion) return colors.fg(baseTone, text);
|
|
1237
|
+
const frame = Math.floor(Math.max(0, elapsedMs) / Math.max(1, options.cadenceMs ?? 60));
|
|
1238
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
1239
|
+
return characters
|
|
1240
|
+
.map((character, index) => {
|
|
1241
|
+
const globalIndex = index + offset;
|
|
1242
|
+
const roll = deterministicUnit(globalIndex * 3 + frame, Math.floor(frame / 2));
|
|
1243
|
+
if (roll >= 0.18) return colors.fg(baseTone, character);
|
|
1244
|
+
const glyph = GLITCH_GLYPHS[(globalIndex + frame) % GLITCH_GLYPHS.length] ?? character;
|
|
1245
|
+
return colors.fg(
|
|
1246
|
+
roll < 0.12 ? { hue: (["cyan", "magenta", "blue", "yellow"] as const)[globalIndex % 4]!, shade: 5 } : "accent",
|
|
1247
|
+
glyph,
|
|
1248
|
+
);
|
|
1249
|
+
})
|
|
1250
|
+
.join("");
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const CRUSH_GLYPHS = "0123456789abcdefABCDEF~!@#$£€%^&*()+=_";
|
|
1254
|
+
|
|
1255
|
+
/** Resolve a moving field of artifact cells back into the original message. */
|
|
1256
|
+
export function crushTextEffectFrame(
|
|
1257
|
+
colors: TuiTheme,
|
|
1258
|
+
text: string,
|
|
1259
|
+
elapsedMs: number,
|
|
1260
|
+
options: {
|
|
1261
|
+
cadenceMs?: number;
|
|
1262
|
+
baseTone?: TuiForegroundToken;
|
|
1263
|
+
reducedMotion?: boolean;
|
|
1264
|
+
offset?: number;
|
|
1265
|
+
totalLength?: number;
|
|
1266
|
+
} = {},
|
|
1267
|
+
): string {
|
|
1268
|
+
const characters = textGraphemes(text);
|
|
1269
|
+
if (characters.length === 0) return "";
|
|
1270
|
+
const baseTone = options.baseTone ?? "text.secondary";
|
|
1271
|
+
if (options.reducedMotion) return colors.fg(baseTone, text);
|
|
1272
|
+
const frame = Math.floor(Math.max(0, elapsedMs) / Math.max(1, options.cadenceMs ?? 60));
|
|
1273
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
1274
|
+
const totalLength = Math.max(offset + characters.length, Math.floor(options.totalLength ?? characters.length));
|
|
1275
|
+
const head = frame % (totalLength + 8);
|
|
1276
|
+
return characters
|
|
1277
|
+
.map((character, index) => {
|
|
1278
|
+
const globalIndex = index + offset;
|
|
1279
|
+
if (globalIndex < head - 2) return colors.fg(baseTone, character);
|
|
1280
|
+
const glyph = CRUSH_GLYPHS[(globalIndex + frame) % CRUSH_GLYPHS.length] ?? character;
|
|
1281
|
+
return colors.fg({ hue: (["magenta", "blue", "cyan"] as const)[globalIndex % 3]!, shade: 4 }, glyph);
|
|
1282
|
+
})
|
|
1283
|
+
.join("");
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const ASCII_ARTIFACT_MARKS = Object.freeze([
|
|
1287
|
+
"\u0307",
|
|
1288
|
+
"\u030c",
|
|
1289
|
+
"\u0302",
|
|
1290
|
+
"\u0304",
|
|
1291
|
+
"\u0323",
|
|
1292
|
+
"\u0327",
|
|
1293
|
+
"\u0303",
|
|
1294
|
+
"\u030a",
|
|
1295
|
+
"\u0338",
|
|
1296
|
+
] as const);
|
|
1297
|
+
|
|
1298
|
+
const MAIN_ZIPPING_VARIANTS = Object.freeze({
|
|
1299
|
+
z: Object.freeze(["ż", "ž", "ẑ", "ẕ", "ẓ", "z̧", "z̃", "z̊", "z̸"]),
|
|
1300
|
+
i: Object.freeze(["i", "ǐ", "î", "ī", "ị", "į", "ĩ", "i̊", "i̸"]),
|
|
1301
|
+
n: Object.freeze(["ṅ", "ň", "n̂", "ṉ", "ṇ", "ņ", "ñ", "n̊", "n̸"]),
|
|
1302
|
+
g: Object.freeze(["ġ", "ǧ", "ĝ", "ḡ", "g̣", "ģ", "g̃", "g̊", "g̸"]),
|
|
1303
|
+
} satisfies Readonly<Record<string, readonly string[]>>);
|
|
1304
|
+
|
|
1305
|
+
const ASCII_ARTIFACT_VARIANTS: Readonly<Record<string, readonly string[]>> = Object.freeze(
|
|
1306
|
+
Object.fromEntries(
|
|
1307
|
+
Array.from({ length: 95 }, (_, offset) => {
|
|
1308
|
+
const character = String.fromCharCode(0x20 + offset);
|
|
1309
|
+
const exact = MAIN_ZIPPING_VARIANTS[character as keyof typeof MAIN_ZIPPING_VARIANTS];
|
|
1310
|
+
const variants = exact ?? ASCII_ARTIFACT_MARKS.map((mark) => `${character}${mark}`.normalize("NFC"));
|
|
1311
|
+
return [character, Object.freeze(variants)] as const;
|
|
1312
|
+
}),
|
|
1313
|
+
),
|
|
1314
|
+
);
|
|
1315
|
+
|
|
1316
|
+
function artifactAsciiCharacter(character: string, frame: number): string {
|
|
1317
|
+
const variants = ASCII_ARTIFACT_VARIANTS[character];
|
|
1318
|
+
return variants?.[frame % variants.length] ?? character;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Adapt main's fast-mode reverse strike and artifact variants to arbitrary text.
|
|
1323
|
+
* Every printable ASCII cell has nine fixed-width variants; main's z/i/n/g
|
|
1324
|
+
* table is retained exactly and the remaining cells use the same artifact families.
|
|
1325
|
+
*/
|
|
1326
|
+
export function lightningShimmerFrame(
|
|
1327
|
+
colors: TuiTheme,
|
|
1328
|
+
text: string,
|
|
1329
|
+
elapsedMs: number,
|
|
1330
|
+
options: {
|
|
1331
|
+
cadenceMs?: number;
|
|
1332
|
+
baseTone?: TuiForegroundToken;
|
|
1333
|
+
reducedMotion?: boolean;
|
|
1334
|
+
variantAscii?: boolean;
|
|
1335
|
+
variantCadenceMs?: number;
|
|
1336
|
+
offset?: number;
|
|
1337
|
+
totalLength?: number;
|
|
1338
|
+
} = {},
|
|
1339
|
+
): string {
|
|
1340
|
+
const characters = textGraphemes(text);
|
|
1341
|
+
if (characters.length === 0) return "";
|
|
1342
|
+
const baseTone = options.baseTone ?? "text.secondary";
|
|
1343
|
+
if (options.reducedMotion) return colors.fg(baseTone, text);
|
|
1344
|
+
const cadenceMs = Math.max(1, options.cadenceMs ?? 60);
|
|
1345
|
+
const timeline = Math.max(0, elapsedMs) / cadenceMs;
|
|
1346
|
+
const frame = Math.floor(timeline);
|
|
1347
|
+
const artifactFrame = Math.round(Math.max(0, elapsedMs) / Math.max(1, options.variantCadenceMs ?? cadenceMs));
|
|
1348
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
1349
|
+
const totalLength = Math.max(offset + characters.length, Math.floor(options.totalLength ?? characters.length));
|
|
1350
|
+
const popIndex = totalLength - 1 - (Math.floor(frame / 2) % totalLength);
|
|
1351
|
+
const leftIndex = (popIndex + totalLength - 1) % totalLength;
|
|
1352
|
+
const rightIndex = (popIndex + 1) % totalLength;
|
|
1353
|
+
const shinePosition = (Math.max(0, elapsedMs) / 80) % (totalLength + 3);
|
|
1354
|
+
return characters
|
|
1355
|
+
.map((character, index) => {
|
|
1356
|
+
const globalIndex = index + offset;
|
|
1357
|
+
const varied = options.variantAscii === false ? character : artifactAsciiCharacter(character, artifactFrame);
|
|
1358
|
+
if (globalIndex === popIndex) return `\x1b[1;9m${colors.fg("warning", varied)}\x1b[22;29m`;
|
|
1359
|
+
if (globalIndex === leftIndex || globalIndex === rightIndex)
|
|
1360
|
+
return `\x1b[9m${colors.fg({ hue: "yellow", shade: 2 }, varied)}\x1b[29m`;
|
|
1361
|
+
const distance = shinePosition - globalIndex;
|
|
1362
|
+
if (distance > 0 && distance < 3) return colors.fg({ hue: glowHue(baseTone), shade: 4 }, varied);
|
|
1363
|
+
return colors.fg(baseTone, varied);
|
|
1364
|
+
})
|
|
1365
|
+
.join("");
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
1369
|
+
|
|
1370
|
+
function textGraphemes(text: string): string[] {
|
|
1371
|
+
return Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function glowPalette(
|
|
1375
|
+
colors: TuiTheme,
|
|
1376
|
+
baseTone: TuiForegroundPaint,
|
|
1377
|
+
highlightTone: TuiForegroundPaint,
|
|
1378
|
+
): readonly TuiForegroundPaint[] {
|
|
1379
|
+
return [
|
|
1380
|
+
highlightTone,
|
|
1381
|
+
colors.mixForeground(baseTone, highlightTone, 0.8),
|
|
1382
|
+
colors.mixForeground(baseTone, highlightTone, 0.6),
|
|
1383
|
+
colors.mixForeground(baseTone, highlightTone, 0.4),
|
|
1384
|
+
colors.mixForeground(baseTone, highlightTone, 0.2),
|
|
1385
|
+
];
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function glowHue(tone: TuiForegroundToken): TuiHue {
|
|
1389
|
+
switch (tone) {
|
|
1390
|
+
case "positive":
|
|
1391
|
+
return "green";
|
|
1392
|
+
case "negative":
|
|
1393
|
+
return "red";
|
|
1394
|
+
case "warning":
|
|
1395
|
+
case "highlight":
|
|
1396
|
+
return "yellow";
|
|
1397
|
+
case "info":
|
|
1398
|
+
return "cyan";
|
|
1399
|
+
case "accent":
|
|
1400
|
+
return "blue";
|
|
1401
|
+
default:
|
|
1402
|
+
return "gray";
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function deterministicUnit(seed: number, frame: number): number {
|
|
1407
|
+
const value = Math.sin(seed * 12.9898 + frame * 78.233) * 43_758.5453;
|
|
1408
|
+
return value - Math.floor(value);
|
|
1409
|
+
}
|