pi-voicekit 0.1.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.
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/extensions/voice/config.ts +395 -0
- package/extensions/voice/deepgram.ts +33 -0
- package/extensions/voice/device.ts +382 -0
- package/extensions/voice/hold-to-talk.ts +69 -0
- package/extensions/voice/local.ts +1143 -0
- package/extensions/voice/model-download.ts +636 -0
- package/extensions/voice/onboarding.ts +739 -0
- package/extensions/voice/release-controller.ts +55 -0
- package/extensions/voice/settings-panel.ts +1602 -0
- package/extensions/voice/sherpa-engine.ts +464 -0
- package/extensions/voice/sherpa-loader.ts +143 -0
- package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
- package/extensions/voice/speak.ts +430 -0
- package/extensions/voice/tts-deepgram.ts +454 -0
- package/extensions/voice/tts-engine.ts +653 -0
- package/extensions/voice/tts-install-progress.ts +257 -0
- package/extensions/voice/tts-local-models.ts +1255 -0
- package/extensions/voice/tts-onboarding-overlay.ts +186 -0
- package/extensions/voice/tts-onboarding.ts +87 -0
- package/extensions/voice/tts-playback-indicator.ts +127 -0
- package/extensions/voice/tts-playback.ts +675 -0
- package/extensions/voice/tts-text-filter.ts +404 -0
- package/extensions/voice/ui-aura.ts +272 -0
- package/extensions/voice/ui-help-overlay.ts +161 -0
- package/extensions/voice/ui-icons.ts +124 -0
- package/extensions/voice/ui-locale-labels.ts +110 -0
- package/extensions/voice/ui-picker.ts +209 -0
- package/extensions/voice/ui-render-ticker.ts +171 -0
- package/extensions/voice/ui-widget-base.ts +219 -0
- package/extensions/voice/ui-width.ts +112 -0
- package/extensions/voice.ts +3644 -0
- package/package.json +75 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single shared 10 Hz frame coalescer for animated widgets — §2 of the
|
|
3
|
+
* v7.1 plan.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: three independent `setInterval(…, 100ms)` widgets
|
|
6
|
+
* drift relative to each other and each call `ctx.ui.setWidget()`
|
|
7
|
+
* separately, causing 20-30 partial re-renders per second instead of 10
|
|
8
|
+
* cohesive ones. With this ticker, every animated widget subscribes to
|
|
9
|
+
* a single `setInterval`; all `setWidget` calls land in the same JS
|
|
10
|
+
* turn so the Pi TUI sees one batch of slot updates per tick.
|
|
11
|
+
*
|
|
12
|
+
* Failure isolation (Codex v4 #2 + Gemini v3 #2):
|
|
13
|
+
* - Subscriber `tick()` is wrapped in try/catch — one throwing widget
|
|
14
|
+
* cannot crash the loop.
|
|
15
|
+
* - 3 consecutive throws auto-unsubscribe the widget. The ticker
|
|
16
|
+
* then calls `sub.dispose?.()` to evict its slot — but the
|
|
17
|
+
* `dispose()` invocation is ITSELF wrapped in try/catch, so a
|
|
18
|
+
* widget whose state is corrupted enough to throw on render
|
|
19
|
+
* (likely also throws on dispose) does not crash the ticker.
|
|
20
|
+
*
|
|
21
|
+
* Lifetime:
|
|
22
|
+
* - Lazy start: `setInterval` is created on first subscriber.
|
|
23
|
+
* - Lazy stop: `setInterval` is cleared on last unsubscribe.
|
|
24
|
+
* - `dispose()` clears all subscribers + the interval. Idempotent.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
|
|
31
|
+
const VOICE_DEBUG = !!process.env.PI_VOICE_DEBUG;
|
|
32
|
+
const VOICE_LOG_FILE = path.join(os.tmpdir(), "pi-voice-debug.log");
|
|
33
|
+
|
|
34
|
+
function debug(...args: unknown[]) {
|
|
35
|
+
if (!VOICE_DEBUG) return;
|
|
36
|
+
const ts = new Date().toISOString().split("T")[1];
|
|
37
|
+
const line = `[voice-ticker ${ts}] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}\n`;
|
|
38
|
+
try {
|
|
39
|
+
fs.appendFileSync(VOICE_LOG_FILE, line);
|
|
40
|
+
} catch {
|
|
41
|
+
/* best-effort */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tick frequency — 10 Hz keeps animation smooth without burning CPU. */
|
|
46
|
+
const TICK_INTERVAL_MS = 100;
|
|
47
|
+
|
|
48
|
+
/** Throws-in-a-row threshold before auto-eviction. */
|
|
49
|
+
const THROW_THRESHOLD = 3;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Subscriber descriptor — explicit ownership (Codex v3 #2). Pass
|
|
53
|
+
* `dispose` only when the ticker should evict this subscriber's slot
|
|
54
|
+
* after auto-unsubscribe; ad-hoc test subscribers can omit it.
|
|
55
|
+
*/
|
|
56
|
+
export interface TickerSubscriber {
|
|
57
|
+
/** Called once per tick (10 Hz). May throw; the ticker isolates. */
|
|
58
|
+
readonly tick: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Optional ownership hook. If present and the subscriber is
|
|
61
|
+
* auto-unsubscribed after `THROW_THRESHOLD` consecutive throws,
|
|
62
|
+
* the ticker calls `dispose()` to tear down the broken widget's
|
|
63
|
+
* slot. The call is wrapped in `try/catch` so a corrupted widget
|
|
64
|
+
* cannot crash the ticker.
|
|
65
|
+
*/
|
|
66
|
+
readonly dispose?: () => void;
|
|
67
|
+
/** Optional debug label, surfaced in `voiceDebug()` output. */
|
|
68
|
+
readonly label?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RenderTicker {
|
|
72
|
+
/** Subscribe. Returns an unsubscribe fn — calling it is idempotent. */
|
|
73
|
+
subscribe(subscriber: TickerSubscriber): () => void;
|
|
74
|
+
/** Active subscriber count. Test/debug. */
|
|
75
|
+
refCount(): number;
|
|
76
|
+
/** Tear down the ticker — clears interval + subscribers. Idempotent. */
|
|
77
|
+
dispose(): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface SubEntry {
|
|
81
|
+
readonly sub: TickerSubscriber;
|
|
82
|
+
throwsInARow: number;
|
|
83
|
+
unsubscribed: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class RenderTickerImpl implements RenderTicker {
|
|
87
|
+
private readonly entries = new Set<SubEntry>();
|
|
88
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
89
|
+
private disposed = false;
|
|
90
|
+
|
|
91
|
+
subscribe(subscriber: TickerSubscriber): () => void {
|
|
92
|
+
if (this.disposed) {
|
|
93
|
+
// Late subscriber after dispose — give them a no-op
|
|
94
|
+
// unsubscriber and don't start the timer.
|
|
95
|
+
return () => {};
|
|
96
|
+
}
|
|
97
|
+
const entry: SubEntry = { sub: subscriber, throwsInARow: 0, unsubscribed: false };
|
|
98
|
+
this.entries.add(entry);
|
|
99
|
+
if (this.timer == null) this.startTimer();
|
|
100
|
+
return () => {
|
|
101
|
+
if (entry.unsubscribed) return;
|
|
102
|
+
entry.unsubscribed = true;
|
|
103
|
+
this.entries.delete(entry);
|
|
104
|
+
if (this.entries.size === 0) this.stopTimer();
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
refCount(): number {
|
|
109
|
+
return this.entries.size;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dispose(): void {
|
|
113
|
+
if (this.disposed) return;
|
|
114
|
+
this.disposed = true;
|
|
115
|
+
this.stopTimer();
|
|
116
|
+
// Mark all entries unsubscribed so any retained closure that
|
|
117
|
+
// still calls its unsubscribe fn is a no-op.
|
|
118
|
+
for (const entry of this.entries) entry.unsubscribed = true;
|
|
119
|
+
this.entries.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private startTimer(): void {
|
|
123
|
+
this.timer = setInterval(() => this.runTick(), TICK_INTERVAL_MS);
|
|
124
|
+
// Don't keep the event loop alive just for animation.
|
|
125
|
+
(this.timer as any)?.unref?.();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private stopTimer(): void {
|
|
129
|
+
if (this.timer != null) {
|
|
130
|
+
clearInterval(this.timer);
|
|
131
|
+
this.timer = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private runTick(): void {
|
|
136
|
+
// Snapshot entries before iterating — auto-eviction during the
|
|
137
|
+
// loop would otherwise mutate the live Set. Snapshot also lets
|
|
138
|
+
// us safely call dispose() on evicted entries after the loop.
|
|
139
|
+
const snapshot = Array.from(this.entries);
|
|
140
|
+
const evict: SubEntry[] = [];
|
|
141
|
+
for (const entry of snapshot) {
|
|
142
|
+
if (entry.unsubscribed) continue;
|
|
143
|
+
try {
|
|
144
|
+
entry.sub.tick();
|
|
145
|
+
entry.throwsInARow = 0;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
entry.throwsInARow++;
|
|
148
|
+
debug("tick threw", entry.sub.label ?? "(unlabeled)", entry.throwsInARow, "/", THROW_THRESHOLD, String(err));
|
|
149
|
+
if (entry.throwsInARow >= THROW_THRESHOLD) evict.push(entry);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const entry of evict) {
|
|
153
|
+
if (entry.unsubscribed) continue;
|
|
154
|
+
entry.unsubscribed = true;
|
|
155
|
+
this.entries.delete(entry);
|
|
156
|
+
// Eviction dispose() — Gemini v3 #2: try/catch around
|
|
157
|
+
// dispose itself so a broken widget can't crash the ticker.
|
|
158
|
+
try {
|
|
159
|
+
entry.sub.dispose?.();
|
|
160
|
+
} catch (err) {
|
|
161
|
+
debug("eviction dispose threw", entry.sub.label ?? "(unlabeled)", String(err));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (this.entries.size === 0) this.stopTimer();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Construct a fresh ticker. One per session is the expected shape. */
|
|
169
|
+
export function makeRenderTicker(): RenderTicker {
|
|
170
|
+
return new RenderTickerImpl();
|
|
171
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget ownership model for the v7.1 Settings UI redesign — §1 of the
|
|
3
|
+
* approved plan, hardened across v3/v4/v5 reviews.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: `ctx.ui.setWidget(key, content)` writes to a key-slotted
|
|
6
|
+
* store. Two widgets with different keys coexist; two widgets with the
|
|
7
|
+
* same key overwrite. v7.1 introduces install + playback widgets that
|
|
8
|
+
* must coexist with the recording widget, so we need a registry that
|
|
9
|
+
* (a) prevents same-key collision, (b) lets completed widgets evict
|
|
10
|
+
* themselves so the registry doesn't leak across long sessions, and
|
|
11
|
+
* (c) survives misbehaving widgets that throw on disposal.
|
|
12
|
+
*
|
|
13
|
+
* The contract was reviewed and SHIP'd by both Codex and Gemini at v5.
|
|
14
|
+
* Six load-bearing properties of the implementation:
|
|
15
|
+
*
|
|
16
|
+
* 1. `register(w)` synchronously calls `existing.dispose()` before
|
|
17
|
+
* swapping in `w`, so a same-key handover never races.
|
|
18
|
+
* 2. `unregister(key, owner)` is OWNER-CHECKED — only deletes the Map
|
|
19
|
+
* entry if it currently points to `owner`. A stale dispose path
|
|
20
|
+
* (e.g. an old install widget's queued cleanup running after a
|
|
21
|
+
* newer same-key widget was registered) cannot evict the
|
|
22
|
+
* successor.
|
|
23
|
+
* 3. `disposeAll()` iterates a CLONED snapshot of values, because
|
|
24
|
+
* each widget's `dispose()` will synchronously call back into
|
|
25
|
+
* `unregister(key, this)`, mutating the underlying Map.
|
|
26
|
+
* 4. Each `dispose()` call inside `disposeAll()` is wrapped in
|
|
27
|
+
* `try/catch` so one throwing widget cannot abort cleanup for
|
|
28
|
+
* the rest.
|
|
29
|
+
* 5. `installWidgetKey(modelId)` produces per-model-id keys so two
|
|
30
|
+
* installs for different models occupy different slots.
|
|
31
|
+
* 6. The base class's `dispose()` ordering — set `disposed` true
|
|
32
|
+
* before any cleanup work — is enforced via `BaseDisposableWidget`,
|
|
33
|
+
* so subclasses cannot accidentally clear timers / slots before
|
|
34
|
+
* flipping the flag.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import * as fs from "node:fs";
|
|
38
|
+
import * as os from "node:os";
|
|
39
|
+
import * as path from "node:path";
|
|
40
|
+
|
|
41
|
+
const VOICE_DEBUG = !!process.env.PI_VOICE_DEBUG;
|
|
42
|
+
const VOICE_LOG_FILE = path.join(os.tmpdir(), "pi-voice-debug.log");
|
|
43
|
+
|
|
44
|
+
function debug(...args: unknown[]) {
|
|
45
|
+
if (!VOICE_DEBUG) return;
|
|
46
|
+
const ts = new Date().toISOString().split("T")[1];
|
|
47
|
+
const line = `[voice-ui ${ts}] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}\n`;
|
|
48
|
+
try {
|
|
49
|
+
fs.appendFileSync(VOICE_LOG_FILE, line);
|
|
50
|
+
} catch {
|
|
51
|
+
/* best-effort */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A widget that owns one slot key and can be torn down. */
|
|
56
|
+
export interface DisposableWidget {
|
|
57
|
+
/**
|
|
58
|
+
* Stable widget-key the slot writes to. Install widgets use
|
|
59
|
+
* `installWidgetKey(modelId)`; recording uses `"voice-recording"`;
|
|
60
|
+
* playback uses `"voice-tts-playback"`.
|
|
61
|
+
*/
|
|
62
|
+
readonly key: string;
|
|
63
|
+
/** Tear down the widget. MUST be idempotent (re-entry returns early). */
|
|
64
|
+
dispose(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Registry that owns lifetime of every active widget. One per session,
|
|
69
|
+
* created in `session_start` and drained in `voiceCleanup`.
|
|
70
|
+
*/
|
|
71
|
+
export interface WidgetRegistry {
|
|
72
|
+
/**
|
|
73
|
+
* Register a widget under its `key`. If a widget with the same
|
|
74
|
+
* key is already registered, that incumbent's `dispose()` is
|
|
75
|
+
* called synchronously BEFORE `w` takes the slot — preventing
|
|
76
|
+
* same-key collision.
|
|
77
|
+
*/
|
|
78
|
+
register(w: DisposableWidget): void;
|
|
79
|
+
/**
|
|
80
|
+
* Owner-checked eviction (Codex v4 #3). Removes the Map entry for
|
|
81
|
+
* `key` ONLY if the current entry is `owner` (identity-equal). If
|
|
82
|
+
* a stale dispose path runs after a NEW widget has taken the same
|
|
83
|
+
* key, the unregister is a no-op and the new widget stays bound.
|
|
84
|
+
* Calls with absent `key` or mismatched owner are no-ops.
|
|
85
|
+
*/
|
|
86
|
+
unregister(key: string, owner: DisposableWidget): void;
|
|
87
|
+
/**
|
|
88
|
+
* Drains the registry. Iterates a CLONED snapshot of values
|
|
89
|
+
* (Gemini v4 implementation note) because each `dispose()`
|
|
90
|
+
* re-enters `unregister`. Each `dispose()` is wrapped in
|
|
91
|
+
* `try/catch` so one throwing widget cannot abort cleanup for
|
|
92
|
+
* the rest. Idempotent — safe to call twice.
|
|
93
|
+
*/
|
|
94
|
+
disposeAll(): void;
|
|
95
|
+
/** Active widget count. Test/debug only. */
|
|
96
|
+
size(): number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class WidgetRegistryImpl implements WidgetRegistry {
|
|
100
|
+
private readonly entries = new Map<string, DisposableWidget>();
|
|
101
|
+
|
|
102
|
+
register(w: DisposableWidget): void {
|
|
103
|
+
const existing = this.entries.get(w.key);
|
|
104
|
+
if (existing && existing !== w) {
|
|
105
|
+
// Synchronous dispose of incumbent before swap — same-key
|
|
106
|
+
// handover never races. Wrap in try/catch so a throwing
|
|
107
|
+
// incumbent cannot prevent the new widget from registering.
|
|
108
|
+
try {
|
|
109
|
+
existing.dispose();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
debug("register: incumbent dispose threw", w.key, String(err));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
this.entries.set(w.key, w);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
unregister(key: string, owner: DisposableWidget): void {
|
|
118
|
+
const cur = this.entries.get(key);
|
|
119
|
+
if (cur === owner) this.entries.delete(key);
|
|
120
|
+
// else: stale call — successor already took the slot, do nothing.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
disposeAll(): void {
|
|
124
|
+
// Cloned snapshot — each dispose() re-enters unregister() and
|
|
125
|
+
// mutates the underlying Map. Iterating a snapshot guarantees
|
|
126
|
+
// no widget is skipped during the cascade.
|
|
127
|
+
const snapshot = Array.from(this.entries.values());
|
|
128
|
+
for (const w of snapshot) {
|
|
129
|
+
try {
|
|
130
|
+
w.dispose();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
debug("disposeAll: dispose threw", w.key, String(err));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Defensive: any widget that didn't unregister itself (buggy
|
|
136
|
+
// dispose) is force-evicted so a later disposeAll() is truly idempotent.
|
|
137
|
+
this.entries.clear();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
size(): number {
|
|
141
|
+
return this.entries.size;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Construct a fresh registry. One per session is the expected shape. */
|
|
146
|
+
export function makeWidgetRegistry(): WidgetRegistry {
|
|
147
|
+
return new WidgetRegistryImpl();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Per-instance install widget key. Concurrent installs of different
|
|
152
|
+
* model ids occupy different slots (`voice-tts-install:kitten-…` vs
|
|
153
|
+
* `voice-tts-install:kokoro-…`). Same-id installs are already
|
|
154
|
+
* serialized by `ensureTtsModelInstalled`'s in-flight Map.
|
|
155
|
+
*/
|
|
156
|
+
export function installWidgetKey(modelId: string): string {
|
|
157
|
+
return `voice-tts-install:${modelId}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Stable widget keys for non-install widgets. */
|
|
161
|
+
export const WIDGET_KEY = {
|
|
162
|
+
recording: "voice-recording",
|
|
163
|
+
ttsPlayback: "voice-tts-playback",
|
|
164
|
+
} as const;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Optional base class enforcing the §1 dispose ordering. Subclasses
|
|
168
|
+
* implement `onDispose()` for their type-specific cleanup; the base
|
|
169
|
+
* runs the universal sequence around it.
|
|
170
|
+
*
|
|
171
|
+
* Order (Codex v4 #2 + Codex v5 nit):
|
|
172
|
+
* (a) check `if (this.disposed) return;` — idempotency on PRIOR state
|
|
173
|
+
* (b) set `this.disposed = true` BEFORE any cleanup work
|
|
174
|
+
* (c) call `this.unsubTicker?.()` so ticker refcount drops
|
|
175
|
+
* (d) `onDispose()` for subclass-specific cleanup (timers, etc.)
|
|
176
|
+
* (e) clear the slot via the injected `clearSlot()` callback
|
|
177
|
+
* (f) `registry.unregister(this.key, this)` — owner-checked eviction
|
|
178
|
+
*/
|
|
179
|
+
export abstract class BaseDisposableWidget implements DisposableWidget {
|
|
180
|
+
abstract readonly key: string;
|
|
181
|
+
protected disposed = false;
|
|
182
|
+
protected unsubTicker: (() => void) | null = null;
|
|
183
|
+
protected readonly registry: WidgetRegistry;
|
|
184
|
+
protected readonly clearSlot: () => void;
|
|
185
|
+
|
|
186
|
+
constructor(registry: WidgetRegistry, clearSlot: () => void) {
|
|
187
|
+
this.registry = registry;
|
|
188
|
+
this.clearSlot = clearSlot;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Subclass cleanup hook. Called between unsubTicker and clearSlot. */
|
|
192
|
+
protected onDispose(): void {}
|
|
193
|
+
|
|
194
|
+
dispose(): void {
|
|
195
|
+
if (this.disposed) return;
|
|
196
|
+
this.disposed = true;
|
|
197
|
+
try {
|
|
198
|
+
this.unsubTicker?.();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
debug("dispose: unsubTicker threw", this.key, String(err));
|
|
201
|
+
}
|
|
202
|
+
this.unsubTicker = null;
|
|
203
|
+
try {
|
|
204
|
+
this.onDispose();
|
|
205
|
+
} catch (err) {
|
|
206
|
+
debug("dispose: onDispose threw", this.key, String(err));
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
this.clearSlot();
|
|
210
|
+
} catch (err) {
|
|
211
|
+
debug("dispose: clearSlot threw", this.key, String(err));
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
this.registry.unregister(this.key, this);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
debug("dispose: unregister threw", this.key, String(err));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual-width and width-tier helpers for the v7.1 Settings UI.
|
|
3
|
+
*
|
|
4
|
+
* `visualWidth(s)` returns terminal column count for a string, treating
|
|
5
|
+
* East-Asian Wide / Fullwidth codepoints as 2 columns and everything else
|
|
6
|
+
* as 1. Iteration is done in UTF-32 code points (via `for…of`) so surrogate
|
|
7
|
+
* pairs (`String.length 2`, single code point) count as one wide unit, not
|
|
8
|
+
* two halves of nothing.
|
|
9
|
+
*
|
|
10
|
+
* Hand-curated EAW Wide/Fullwidth ranges from Unicode 15.1 EastAsianWidth.txt
|
|
11
|
+
* — only the blocks pi-listen actually ships labels for (CJK, Hangul,
|
|
12
|
+
* Hiragana/Katakana, fullwidth ASCII). Hindi/Devanagari and Arabic
|
|
13
|
+
* intentionally NOT covered: per the v7.1 plan their voices are rendered
|
|
14
|
+
* with romanized labels, so a precise width here is unnecessary and a
|
|
15
|
+
* partial width table would silently mis-align their rows. Keeping the
|
|
16
|
+
* table small also keeps the zero-dependency promise.
|
|
17
|
+
*
|
|
18
|
+
* Width tiers (§10): three buckets — "wide" (≥80), "mid" (60..79), "narrow"
|
|
19
|
+
* (<60 — hard block at the panel level).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Hand-curated EAW Wide / Fullwidth ranges, sorted ascending by `lo` so the
|
|
23
|
+
// scanner can early-exit on `cp < lo`. Comments label the Unicode block.
|
|
24
|
+
const EAW_WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
25
|
+
[0x1100, 0x11ff], // Hangul Jamo
|
|
26
|
+
[0x3000, 0x30ff], // CJK Symbols & Punctuation, Hiragana, Katakana
|
|
27
|
+
[0x3100, 0x312f], // Bopomofo
|
|
28
|
+
[0x3130, 0x318f], // Hangul Compatibility Jamo
|
|
29
|
+
[0x31a0, 0x31bf], // Bopomofo Extended
|
|
30
|
+
[0x31c0, 0x31ef], // CJK Strokes
|
|
31
|
+
[0x31f0, 0x31ff], // Katakana Phonetic Extensions
|
|
32
|
+
[0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
|
|
33
|
+
[0x4e00, 0x9fff], // CJK Unified Ideographs (main block)
|
|
34
|
+
[0xac00, 0xd7a3], // Hangul Syllables (Korean)
|
|
35
|
+
[0xf900, 0xfaff], // CJK Compatibility Ideographs
|
|
36
|
+
[0xff01, 0xff60], // Fullwidth ASCII
|
|
37
|
+
[0xffe0, 0xffe6], // Fullwidth signs
|
|
38
|
+
[0x20000, 0x2fffd], // CJK Unified Ideographs Extension B–F
|
|
39
|
+
[0x30000, 0x3fffd], // CJK Unified Ideographs Extension G
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function isWide(cp: number): boolean {
|
|
43
|
+
for (const [lo, hi] of EAW_WIDE_RANGES) {
|
|
44
|
+
if (cp >= lo && cp <= hi) return true;
|
|
45
|
+
if (cp < lo) return false; // ranges are sorted
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Visual width of `s` in terminal columns. Surrogate pairs count as one
|
|
52
|
+
* code point. EAW Wide/Fullwidth code points count as 2; everything else
|
|
53
|
+
* as 1. Combining marks are NOT subtracted (Devanagari/Arabic are out of
|
|
54
|
+
* scope per §8) — pass romanized labels for those scripts.
|
|
55
|
+
*/
|
|
56
|
+
export function visualWidth(s: string): number {
|
|
57
|
+
let w = 0;
|
|
58
|
+
for (const ch of s) {
|
|
59
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
60
|
+
w += isWide(cp) ? 2 : 1;
|
|
61
|
+
}
|
|
62
|
+
return w;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Truncate `s` to fit within `max` columns, appending `…` (1 column) when
|
|
67
|
+
* truncation occurs. Uses code-point iteration so surrogate pairs and
|
|
68
|
+
* wide characters are never sliced mid-glyph.
|
|
69
|
+
*/
|
|
70
|
+
export function truncateToVisualWidth(s: string, max: number): string {
|
|
71
|
+
if (max <= 0) return "";
|
|
72
|
+
if (visualWidth(s) <= max) return s;
|
|
73
|
+
if (max === 1) return "…";
|
|
74
|
+
let w = 0;
|
|
75
|
+
let out = "";
|
|
76
|
+
for (const ch of s) {
|
|
77
|
+
const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
78
|
+
if (w + cw > max - 1) break; // reserve 1 col for ellipsis
|
|
79
|
+
out += ch;
|
|
80
|
+
w += cw;
|
|
81
|
+
}
|
|
82
|
+
return out + "…";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Pad `s` on the right (with spaces) to occupy exactly `width` columns. */
|
|
86
|
+
export function padRightVisual(s: string, width: number): string {
|
|
87
|
+
const w = visualWidth(s);
|
|
88
|
+
if (w >= width) return s;
|
|
89
|
+
return s + " ".repeat(width - w);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Pad `s` on the left (with spaces) to occupy exactly `width` columns. */
|
|
93
|
+
export function padLeftVisual(s: string, width: number): string {
|
|
94
|
+
const w = visualWidth(s);
|
|
95
|
+
if (w >= width) return s;
|
|
96
|
+
return " ".repeat(width - w) + s;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Width tier labels — see §10 of the v7.1 plan. */
|
|
100
|
+
export type WidthTier = "wide" | "mid" | "narrow";
|
|
101
|
+
|
|
102
|
+
/** Map a column count to a tier. ≥80 wide, 60..79 mid, <60 narrow (block). */
|
|
103
|
+
export function widthTier(cols: number): WidthTier {
|
|
104
|
+
if (cols >= 80) return "wide";
|
|
105
|
+
if (cols >= 60) return "mid";
|
|
106
|
+
return "narrow";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when the panel should hard-block rendering (§10). */
|
|
110
|
+
export function isPanelTooNarrow(cols: number): boolean {
|
|
111
|
+
return widthTier(cols) === "narrow";
|
|
112
|
+
}
|