@anthodev/pi-tools-style 0.1.3 → 0.2.0

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.
@@ -1,111 +1,94 @@
1
1
  import type { IconMode } from "./tool-icon.js";
2
2
 
3
- interface SpinnerUi {
4
- requestRender?: () => void;
3
+ /** A public render-context state and its invalidation, never a Pi component. */
4
+ export interface ToolSpinnerTarget {
5
+ state: object;
6
+ invalidate: () => void;
5
7
  }
6
-
7
- interface ToolSpinnerTarget {
8
- executionStarted?: unknown;
9
- isPartial?: unknown;
10
- ui?: SpinnerUi;
11
- }
12
-
13
8
  interface SpinnerRuntime {
14
- frameIndex: number;
15
- targets: Map<object, number>;
16
- timer: NodeJS.Timeout | undefined;
9
+ frameIndex: number;
10
+ targets: Set<WeakRef<ToolSpinnerTarget>>;
11
+ references: WeakMap<object, WeakRef<ToolSpinnerTarget>>;
12
+ timer: NodeJS.Timeout | undefined;
17
13
  }
18
-
19
14
  const SPINNER_RUNTIME_KEY = Symbol.for("pi-tools-style:tool-spinner");
20
15
  const FRAME_INTERVAL_MS = 80;
21
- const STALE_TARGET_MS = 2_000;
22
16
  const ASCII_FRAMES = ["|", "/", "-", "\\"] as const;
23
17
  const NERD_FONT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
24
18
 
25
- export function toolSpinnerFrame(
26
- target: object,
27
- mode: IconMode,
28
- ): string | undefined {
29
- const runtime = getRuntime();
30
- if (!isExecuting(target)) {
31
- runtime.targets.delete(target);
32
- stopTickerIfIdle(runtime);
33
- return undefined;
34
- }
35
-
36
- runtime.targets.set(target, Date.now());
37
- ensureTicker(runtime);
38
- const frames = mode === "nerd-font" ? NERD_FONT_FRAMES : ASCII_FRAMES;
39
- return frames[runtime.frameIndex % frames.length];
40
- }
41
-
42
- export function clearToolSpinners(): void {
43
- const runtime = getRuntime();
44
- if (runtime.timer) clearInterval(runtime.timer);
45
- runtime.timer = undefined;
46
- runtime.frameIndex = 0;
47
- runtime.targets.clear();
19
+ /** Public tools use their gated ticker; shell may supply its native-loader clock index. */
20
+ export function getToolSpinnerFrame(mode: IconMode, frameIndex = getRuntime().frameIndex): string {
21
+ const frames = mode === "nerd-font" ? NERD_FONT_FRAMES : ASCII_FRAMES;
22
+ return frames[frameIndex % frames.length]!;
48
23
  }
49
24
 
50
- function ensureTicker(runtime: SpinnerRuntime): void {
51
- if (runtime.timer) return;
52
-
53
- runtime.timer = setInterval(() => tick(runtime), FRAME_INTERVAL_MS);
54
- runtime.timer.unref?.();
25
+ /** Unconfirmed replay/export states use a static initial frame. */
26
+ export function toolSpinnerFrame(state: object, mode: IconMode): string {
27
+ const current = getRuntime();
28
+ const ref = current.references.get(state);
29
+ if (ref && current.targets.has(ref) && ref.deref()) return getToolSpinnerFrame(mode);
30
+ return mode === "nerd-font" ? NERD_FONT_FRAMES[0] : ASCII_FRAMES[0];
55
31
  }
56
32
 
57
- function tick(runtime: SpinnerRuntime): void {
58
- runtime.frameIndex += 1;
59
- const now = Date.now();
60
- const activeUis = new Set<SpinnerUi>();
61
-
62
- for (const [target, lastSeenAt] of runtime.targets) {
63
- if (!isExecuting(target) || now - lastSeenAt > STALE_TARGET_MS) {
64
- runtime.targets.delete(target);
65
- continue;
66
- }
67
- const ui = readTarget(target).ui;
68
- if (ui) activeUis.add(ui);
69
- }
70
-
71
- for (const ui of activeUis) {
72
- try {
73
- ui.requestRender?.();
74
- } catch {
75
- // Presentation failure must never affect tool execution.
76
- }
77
- }
78
-
79
- stopTickerIfIdle(runtime);
33
+ export function setToolSpinnerActive(target: ToolSpinnerTarget, active: boolean): void {
34
+ const current = getRuntime();
35
+ let ref = current.references.get(target.state);
36
+ if (!active) {
37
+ if (ref?.deref() === target) {
38
+ current.targets.delete(ref);
39
+ current.references.delete(target.state);
40
+ }
41
+ stopTickerIfIdle(current);
42
+ return;
43
+ }
44
+ if (!ref || ref.deref() !== target) {
45
+ if (ref) current.targets.delete(ref);
46
+ ref = new WeakRef(target);
47
+ current.references.set(target.state, ref);
48
+ }
49
+ current.targets.add(ref);
50
+ if (!current.timer) {
51
+ current.timer = setInterval(() => tick(current), FRAME_INTERVAL_MS);
52
+ current.timer.unref?.();
53
+ }
80
54
  }
81
55
 
82
- function stopTickerIfIdle(runtime: SpinnerRuntime): void {
83
- if (runtime.targets.size > 0 || !runtime.timer) return;
84
- clearInterval(runtime.timer);
85
- runtime.timer = undefined;
86
- runtime.frameIndex = 0;
56
+ export function clearToolSpinners(): void {
57
+ const current = getRuntime();
58
+ clearInterval(current.timer);
59
+ current.timer = undefined;
60
+ current.frameIndex = 0;
61
+ current.targets.clear();
62
+ current.references = new WeakMap();
87
63
  }
88
64
 
89
- function isExecuting(target: object): boolean {
90
- const fields = readTarget(target);
91
- return fields.executionStarted === true && fields.isPartial === true;
65
+ function tick(current: SpinnerRuntime): void {
66
+ current.frameIndex++;
67
+ for (const ref of current.targets) {
68
+ const target = ref.deref();
69
+ if (!target) { current.targets.delete(ref); continue; }
70
+ try {
71
+ target.invalidate();
72
+ } catch {
73
+ // Detached TUI contexts cannot keep a presentation ticker alive.
74
+ current.targets.delete(ref);
75
+ if (current.references.get(target.state) === ref) current.references.delete(target.state);
76
+ }
77
+ }
78
+ stopTickerIfIdle(current);
92
79
  }
93
-
94
- function readTarget(target: object): ToolSpinnerTarget {
95
- return target as ToolSpinnerTarget;
80
+ function stopTickerIfIdle(current: SpinnerRuntime): void {
81
+ if (current.targets.size > 0 || !current.timer) return;
82
+ clearInterval(current.timer);
83
+ current.timer = undefined;
84
+ current.frameIndex = 0;
96
85
  }
97
-
98
86
  function getRuntime(): SpinnerRuntime {
99
- const current = Reflect.get(globalThis, SPINNER_RUNTIME_KEY) as
100
- | SpinnerRuntime
101
- | undefined;
102
- if (current) return current;
103
-
104
- const runtime: SpinnerRuntime = {
105
- frameIndex: 0,
106
- targets: new Map(),
107
- timer: undefined,
108
- };
109
- Reflect.set(globalThis, SPINNER_RUNTIME_KEY, runtime);
110
- return runtime;
87
+ const current = Reflect.get(globalThis, SPINNER_RUNTIME_KEY) as SpinnerRuntime | undefined;
88
+ if (current?.targets instanceof Set) return current;
89
+ // Reload replaces the old private-component ticker, not a second clock.
90
+ clearInterval(current?.timer);
91
+ const next: SpinnerRuntime = { frameIndex: 0, targets: new Set(), references: new WeakMap(), timer: undefined };
92
+ Reflect.set(globalThis, SPINNER_RUNTIME_KEY, next);
93
+ return next;
111
94
  }