react-pebble 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/dist/lib/compiler.cjs +3 -0
- package/dist/lib/compiler.cjs.map +1 -0
- package/dist/lib/compiler.js +54 -0
- package/dist/lib/compiler.js.map +1 -0
- package/dist/lib/components.cjs +2 -0
- package/dist/lib/components.cjs.map +1 -0
- package/dist/lib/components.js +80 -0
- package/dist/lib/components.js.map +1 -0
- package/dist/lib/hooks.cjs +2 -0
- package/dist/lib/hooks.cjs.map +1 -0
- package/dist/lib/hooks.js +99 -0
- package/dist/lib/hooks.js.map +1 -0
- package/dist/lib/index.cjs +2 -0
- package/dist/lib/index.cjs.map +1 -0
- package/dist/lib/index.js +585 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/platform.cjs +2 -0
- package/dist/lib/platform.cjs.map +1 -0
- package/dist/lib/platform.js +52 -0
- package/dist/lib/platform.js.map +1 -0
- package/dist/lib/plugin.cjs +60 -0
- package/dist/lib/plugin.cjs.map +1 -0
- package/dist/lib/plugin.js +102 -0
- package/dist/lib/plugin.js.map +1 -0
- package/dist/lib/src/compiler/index.d.ts +40 -0
- package/dist/lib/src/components/index.d.ts +129 -0
- package/dist/lib/src/hooks/index.d.ts +75 -0
- package/dist/lib/src/index.d.ts +36 -0
- package/dist/lib/src/pebble-dom-shim.d.ts +45 -0
- package/dist/lib/src/pebble-dom.d.ts +59 -0
- package/dist/lib/src/pebble-output.d.ts +44 -0
- package/dist/lib/src/pebble-reconciler.d.ts +16 -0
- package/dist/lib/src/pebble-render.d.ts +31 -0
- package/dist/lib/src/platform.d.ts +30 -0
- package/dist/lib/src/plugin/index.d.ts +20 -0
- package/package.json +90 -0
- package/scripts/compile-to-piu.ts +1794 -0
- package/scripts/deploy.sh +46 -0
- package/src/compiler/index.ts +114 -0
- package/src/components/index.tsx +280 -0
- package/src/hooks/index.ts +311 -0
- package/src/index.ts +126 -0
- package/src/pebble-dom-shim.ts +266 -0
- package/src/pebble-dom.ts +190 -0
- package/src/pebble-output.ts +310 -0
- package/src/pebble-reconciler.ts +54 -0
- package/src/pebble-render.ts +311 -0
- package/src/platform.ts +50 -0
- package/src/plugin/index.ts +274 -0
- package/src/types/moddable.d.ts +156 -0
|
@@ -0,0 +1,1794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/compile-to-piu.ts — "React → piu" compiler.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1: renders the component in mock mode, snapshots the pebble-dom
|
|
5
|
+
* tree, emits piu Application.template JS code.
|
|
6
|
+
*
|
|
7
|
+
* Phase 2: renders at TWO different mock times, diffs the label texts to
|
|
8
|
+
* identify time-dependent labels, and emits a piu Behavior that updates
|
|
9
|
+
* them every second via `onTimeChanged`.
|
|
10
|
+
*
|
|
11
|
+
* Phase 3 (this version): intercepts useState and useButton to detect
|
|
12
|
+
* state-dependent labels and button bindings, emits a unified AppBehavior
|
|
13
|
+
* that handles both time ticks and state reactivity (counter patterns).
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* EXAMPLE=watchface npx tsx scripts/compile-to-piu.ts > pebble-spike/src/embeddedjs/main.js
|
|
17
|
+
* EXAMPLE=counter npx tsx scripts/compile-to-piu.ts > pebble-spike/src/embeddedjs/main.js
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import ts from 'typescript';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, resolve } from 'node:path';
|
|
25
|
+
import { render } from '../src/index.js';
|
|
26
|
+
import type { DOMElement, AnyNode } from '../src/pebble-dom.js';
|
|
27
|
+
import { getTextContent } from '../src/pebble-dom.js';
|
|
28
|
+
import { COLOR_PALETTE } from '../src/pebble-output.js';
|
|
29
|
+
import { _setUseStateImpl, _restoreUseState } from '../src/hooks/index.js';
|
|
30
|
+
import { useState as realUseState } from 'preact/hooks';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Dynamic example import
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const exampleName = process.env.EXAMPLE ?? 'watchface';
|
|
40
|
+
const settleMs = Number(process.env.SETTLE_MS ?? '0');
|
|
41
|
+
const platform = process.env.PEBBLE_PLATFORM ?? 'emery';
|
|
42
|
+
const settle = () =>
|
|
43
|
+
settleMs > 0 ? new Promise<void>((r) => setTimeout(r, settleMs)) : Promise.resolve();
|
|
44
|
+
|
|
45
|
+
// Set platform screen dimensions before importing the example
|
|
46
|
+
// (so SCREEN.width/height are correct when the component renders)
|
|
47
|
+
import { _setPlatform, SCREEN } from '../src/platform.js';
|
|
48
|
+
_setPlatform(platform);
|
|
49
|
+
|
|
50
|
+
const exampleMod = await import(`../examples/${exampleName}.js`);
|
|
51
|
+
const exampleMain: (...args: unknown[]) => ReturnType<typeof render> =
|
|
52
|
+
exampleMod.main ?? exampleMod.default;
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function colorToHex(name: string): string {
|
|
59
|
+
const rgb = COLOR_PALETTE[name];
|
|
60
|
+
if (!rgb) return name;
|
|
61
|
+
const r = rgb.r.toString(16).padStart(2, '0');
|
|
62
|
+
const g = rgb.g.toString(16).padStart(2, '0');
|
|
63
|
+
const b = rgb.b.toString(16).padStart(2, '0');
|
|
64
|
+
return `#${r}${g}${b}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const FONT_TO_PIU: Record<string, string> = {
|
|
68
|
+
gothic14: '14px Gothic',
|
|
69
|
+
gothic14Bold: 'bold 14px Gothic',
|
|
70
|
+
gothic18: '18px Gothic',
|
|
71
|
+
gothic18Bold: 'bold 18px Gothic',
|
|
72
|
+
gothic24: '24px Gothic',
|
|
73
|
+
gothic24Bold: 'bold 24px Gothic',
|
|
74
|
+
gothic28: '28px Gothic',
|
|
75
|
+
gothic28Bold: 'bold 28px Gothic',
|
|
76
|
+
bitham30Black: 'black 30px Bitham',
|
|
77
|
+
bitham42Bold: 'bold 42px Bitham',
|
|
78
|
+
bitham42Light: 'light 42px Bitham',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function fontToPiu(name: string | undefined): string {
|
|
82
|
+
if (!name) return '18px Gothic';
|
|
83
|
+
return FONT_TO_PIU[name] ?? '18px Gothic';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function num(p: Record<string, unknown>, key: string): number {
|
|
87
|
+
const v = p[key];
|
|
88
|
+
return typeof v === 'number' ? v : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function str(p: Record<string, unknown>, key: string): string | undefined {
|
|
92
|
+
const v = p[key];
|
|
93
|
+
return typeof v === 'string' ? v : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pad2(n: number): string {
|
|
97
|
+
return n.toString().padStart(2, '0');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// useState interception
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
interface StateSlot {
|
|
105
|
+
index: number;
|
|
106
|
+
initialValue: unknown;
|
|
107
|
+
setter: (v: unknown) => void;
|
|
108
|
+
currentValue: unknown;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const stateSlots: StateSlot[] = [];
|
|
112
|
+
const forcedStateValues: Map<number, unknown> = new Map();
|
|
113
|
+
let stateCallCounter = 0;
|
|
114
|
+
|
|
115
|
+
function resetStateTracking() {
|
|
116
|
+
stateCallCounter = 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function installUseStateInterceptor() {
|
|
120
|
+
// Swap our hooks module's useState implementation via the exposed
|
|
121
|
+
// _setUseStateImpl setter. This works because counter.tsx imports
|
|
122
|
+
// useState from src/hooks/index.ts, which delegates to a mutable
|
|
123
|
+
// internal reference.
|
|
124
|
+
_setUseStateImpl(function interceptedUseState<T>(
|
|
125
|
+
init: T | (() => T),
|
|
126
|
+
): [T, (v: T | ((prev: T) => T)) => void] {
|
|
127
|
+
const idx = stateCallCounter++;
|
|
128
|
+
const [realVal, realSetter] = realUseState(init);
|
|
129
|
+
|
|
130
|
+
// First render — record the slot
|
|
131
|
+
if (idx >= stateSlots.length) {
|
|
132
|
+
const initialValue = typeof init === 'function' ? (init as () => T)() : init;
|
|
133
|
+
stateSlots.push({
|
|
134
|
+
index: idx,
|
|
135
|
+
initialValue,
|
|
136
|
+
setter: realSetter as (v: unknown) => void,
|
|
137
|
+
currentValue: realVal,
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
stateSlots[idx]!.currentValue = realVal;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// If we're in a perturbed render, override the value
|
|
144
|
+
if (forcedStateValues.has(idx)) {
|
|
145
|
+
const forced = forcedStateValues.get(idx) as T;
|
|
146
|
+
return [forced, realSetter];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return [realVal, realSetter];
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// useButton interception
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
interface ButtonBinding {
|
|
158
|
+
button: string;
|
|
159
|
+
handlerSource: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const buttonBindings: ButtonBinding[] = [];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse an example source file into a TypeScript AST SourceFile.
|
|
166
|
+
*/
|
|
167
|
+
function parseExampleSource(exName: string): ts.SourceFile | null {
|
|
168
|
+
for (const ext of ['.tsx', '.ts', '.jsx']) {
|
|
169
|
+
const srcPath = resolve(__dirname, '..', 'examples', `${exName}${ext}`);
|
|
170
|
+
try {
|
|
171
|
+
const source = readFileSync(srcPath, 'utf-8');
|
|
172
|
+
return ts.createSourceFile(srcPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
173
|
+
} catch { continue; }
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Walk all nodes in a TypeScript AST.
|
|
180
|
+
*/
|
|
181
|
+
function walkAST(node: ts.Node, visitor: (node: ts.Node) => void): void {
|
|
182
|
+
visitor(node);
|
|
183
|
+
ts.forEachChild(node, child => walkAST(child, visitor));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Statically analyze the example source file to extract useButton calls
|
|
188
|
+
* using TypeScript AST parsing.
|
|
189
|
+
*/
|
|
190
|
+
function extractButtonBindingsFromSource(exName: string): void {
|
|
191
|
+
const sf = parseExampleSource(exName);
|
|
192
|
+
if (!sf) return;
|
|
193
|
+
|
|
194
|
+
walkAST(sf, (node) => {
|
|
195
|
+
if (!ts.isCallExpression(node)) return;
|
|
196
|
+
// Check callee is identifier `useButton`
|
|
197
|
+
if (!ts.isIdentifier(node.expression) || node.expression.text !== 'useButton') return;
|
|
198
|
+
if (node.arguments.length < 2) return;
|
|
199
|
+
const firstArg = node.arguments[0]!;
|
|
200
|
+
if (!ts.isStringLiteral(firstArg)) return;
|
|
201
|
+
const button = firstArg.text;
|
|
202
|
+
const handlerNode = node.arguments[1]!;
|
|
203
|
+
const handlerSource = handlerNode.getText(sf);
|
|
204
|
+
if (!buttonBindings.some((b) => b.button === button)) {
|
|
205
|
+
buttonBindings.push({ button, handlerSource });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Button handler analysis
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
interface HandlerAction {
|
|
215
|
+
type: 'increment' | 'decrement' | 'reset' | 'toggle' | 'set_string';
|
|
216
|
+
slotIndex: number;
|
|
217
|
+
value: number;
|
|
218
|
+
stringValue?: string; // for set_string
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Extract a setter→slot index map from source by walking the AST for
|
|
223
|
+
* `const [name, setName] = useState(...)` variable declarations.
|
|
224
|
+
*/
|
|
225
|
+
function buildSetterSlotMap(exName: string): Map<string, number> {
|
|
226
|
+
const map = new Map<string, number>();
|
|
227
|
+
const sf = parseExampleSource(exName);
|
|
228
|
+
if (!sf) return map;
|
|
229
|
+
|
|
230
|
+
let idx = 0;
|
|
231
|
+
walkAST(sf, (node) => {
|
|
232
|
+
if (!ts.isVariableDeclaration(node)) return;
|
|
233
|
+
// Must have an initializer that calls useState
|
|
234
|
+
if (!node.initializer || !ts.isCallExpression(node.initializer)) return;
|
|
235
|
+
const callee = node.initializer.expression;
|
|
236
|
+
if (!ts.isIdentifier(callee) || callee.text !== 'useState') return;
|
|
237
|
+
// Must be an array binding pattern with 2 elements: [value, setter]
|
|
238
|
+
if (!ts.isArrayBindingPattern(node.name)) return;
|
|
239
|
+
const elements = node.name.elements;
|
|
240
|
+
if (elements.length < 2) return;
|
|
241
|
+
const setterElement = elements[1]!;
|
|
242
|
+
if (ts.isOmittedExpression(setterElement)) return;
|
|
243
|
+
const setterName = setterElement.name;
|
|
244
|
+
if (ts.isIdentifier(setterName)) {
|
|
245
|
+
map.set(setterName.text, idx++);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return map;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const setterSlotMap = buildSetterSlotMap(exampleName);
|
|
252
|
+
const listInfo = detectListPatterns(exampleName);
|
|
253
|
+
if (listInfo) {
|
|
254
|
+
process.stderr.write(`List detected: array="${listInfo.dataArrayName}" visible=${listInfo.visibleCount} labelsPerItem=${listInfo.labelsPerItem}\n`);
|
|
255
|
+
if (listInfo.dataArrayValues) process.stderr.write(` values: ${JSON.stringify(listInfo.dataArrayValues)}\n`);
|
|
256
|
+
if (listInfo.scrollSetterName) process.stderr.write(` scroll setter: ${listInfo.scrollSetterName}\n`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// useMessage detection — runtime async data loading
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
interface MessageInfo {
|
|
264
|
+
key: string; // Message key name (e.g., "items")
|
|
265
|
+
mockDataArrayName: string | null; // Variable name of mockData
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function detectUseMessage(exName: string): MessageInfo | null {
|
|
269
|
+
const sf = parseExampleSource(exName);
|
|
270
|
+
if (!sf) return null;
|
|
271
|
+
|
|
272
|
+
let key: string | null = null;
|
|
273
|
+
let mockDataArrayName: string | null = null;
|
|
274
|
+
|
|
275
|
+
walkAST(sf, (node) => {
|
|
276
|
+
// Find: useMessage({ key: "...", mockData: ... })
|
|
277
|
+
if (
|
|
278
|
+
ts.isCallExpression(node) &&
|
|
279
|
+
ts.isIdentifier(node.expression) &&
|
|
280
|
+
node.expression.text === 'useMessage' &&
|
|
281
|
+
node.arguments.length > 0 &&
|
|
282
|
+
ts.isObjectLiteralExpression(node.arguments[0]!)
|
|
283
|
+
) {
|
|
284
|
+
const objLit = node.arguments[0]!;
|
|
285
|
+
for (const prop of (objLit as ts.ObjectLiteralExpression).properties) {
|
|
286
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
|
|
287
|
+
if (prop.name.text === 'key' && ts.isStringLiteral(prop.initializer)) {
|
|
288
|
+
key = prop.initializer.text;
|
|
289
|
+
}
|
|
290
|
+
if (prop.name.text === 'mockData' && ts.isIdentifier(prop.initializer)) {
|
|
291
|
+
mockDataArrayName = prop.initializer.text;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (!key) return null;
|
|
298
|
+
return { key, mockDataArrayName };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const messageInfo = detectUseMessage(exampleName);
|
|
302
|
+
if (messageInfo) {
|
|
303
|
+
process.stderr.write(`useMessage detected: key="${messageInfo.key}"${messageInfo.mockDataArrayName ? ` mockData=${messageInfo.mockDataArrayName}` : ''}\n`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Module-level map collecting string enum values per slot from handler analysis */
|
|
307
|
+
const stringEnumValues = new Map<number, Set<string>>();
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Analyze a button handler AST node (or source string) to determine
|
|
311
|
+
* what action it performs (increment, decrement, reset, toggle, set_string).
|
|
312
|
+
*/
|
|
313
|
+
function analyzeButtonHandler(source: string): HandlerAction | null {
|
|
314
|
+
// Parse the handler source as an expression statement
|
|
315
|
+
const wrapper = `(${source});`;
|
|
316
|
+
const sf = ts.createSourceFile('__handler__.ts', wrapper, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
317
|
+
const stmt = sf.statements[0];
|
|
318
|
+
if (!stmt || !ts.isExpressionStatement(stmt)) return null;
|
|
319
|
+
const expr = ts.isParenthesizedExpression(stmt.expression) ? stmt.expression.expression : stmt.expression;
|
|
320
|
+
return analyzeHandlerNode(expr, sf);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function analyzeHandlerNode(node: ts.Node, sf: ts.SourceFile): HandlerAction | null {
|
|
324
|
+
// We expect an arrow function: () => setXxx(...) or (param => expr)
|
|
325
|
+
if (ts.isArrowFunction(node)) {
|
|
326
|
+
const body = node.body;
|
|
327
|
+
// () => setXxx(...) — body is a call expression
|
|
328
|
+
if (ts.isCallExpression(body)) {
|
|
329
|
+
return analyzeSetterCall(body, sf);
|
|
330
|
+
}
|
|
331
|
+
// () => someExpr — try recursing
|
|
332
|
+
if (ts.isBlock(body)) {
|
|
333
|
+
// Walk statements for a setter call
|
|
334
|
+
for (const stmt of body.statements) {
|
|
335
|
+
if (ts.isExpressionStatement(stmt) && ts.isCallExpression(stmt.expression)) {
|
|
336
|
+
const result = analyzeSetterCall(stmt.expression, sf);
|
|
337
|
+
if (result) return result;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Maybe it's a bare setter call expression (unlikely but handle it)
|
|
343
|
+
if (ts.isCallExpression(node)) {
|
|
344
|
+
return analyzeSetterCall(node, sf);
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function analyzeSetterCall(call: ts.CallExpression, sf: ts.SourceFile): HandlerAction | null {
|
|
350
|
+
// Get setter name to look up slot index
|
|
351
|
+
if (!ts.isIdentifier(call.expression)) return null;
|
|
352
|
+
const setterName = call.expression.text;
|
|
353
|
+
const slotIndex = setterSlotMap.has(setterName) ? setterSlotMap.get(setterName)! : 0;
|
|
354
|
+
|
|
355
|
+
if (call.arguments.length !== 1) return null;
|
|
356
|
+
const arg = call.arguments[0]!;
|
|
357
|
+
|
|
358
|
+
// setXxx(numericLiteral) — reset
|
|
359
|
+
if (ts.isNumericLiteral(arg)) {
|
|
360
|
+
return { type: 'reset', slotIndex, value: Number(arg.text) };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// setXxx('stringLiteral') — set_string
|
|
364
|
+
if (ts.isStringLiteral(arg)) {
|
|
365
|
+
// Collect for string enum extraction
|
|
366
|
+
if (!stringEnumValues.has(slotIndex)) stringEnumValues.set(slotIndex, new Set());
|
|
367
|
+
stringEnumValues.get(slotIndex)!.add(arg.text);
|
|
368
|
+
return { type: 'set_string', slotIndex, value: 0, stringValue: arg.text };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// setXxx(arrow function) — functional update
|
|
372
|
+
if (ts.isArrowFunction(arg)) {
|
|
373
|
+
const body = arg.body;
|
|
374
|
+
// param => param + N (increment)
|
|
375
|
+
// param => param - N (decrement)
|
|
376
|
+
if (ts.isBinaryExpression(body)) {
|
|
377
|
+
if (body.operatorToken.kind === ts.SyntaxKind.PlusToken && ts.isNumericLiteral(body.right)) {
|
|
378
|
+
return { type: 'increment', slotIndex, value: Number(body.right.text) };
|
|
379
|
+
}
|
|
380
|
+
if (body.operatorToken.kind === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(body.right)) {
|
|
381
|
+
return { type: 'decrement', slotIndex, value: Number(body.right.text) };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// param => !param (toggle)
|
|
385
|
+
if (ts.isPrefixUnaryExpression(body) && body.operator === ts.SyntaxKind.ExclamationToken) {
|
|
386
|
+
return { type: 'toggle', slotIndex, value: 0 };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// List (.map()) detection via AST
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
interface ListInfo {
|
|
398
|
+
dataArrayName: string;
|
|
399
|
+
dataArrayValues: string[] | null;
|
|
400
|
+
/** For object arrays: array of plain objects with string values */
|
|
401
|
+
dataArrayObjects: Record<string, string>[] | null;
|
|
402
|
+
/** Property names in the order they appear as labels per item */
|
|
403
|
+
propertyOrder: string[] | null;
|
|
404
|
+
visibleCount: number;
|
|
405
|
+
scrollSetterName: string | null;
|
|
406
|
+
labelsPerItem: number;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function detectListPatterns(exName: string): ListInfo | null {
|
|
410
|
+
const sf = parseExampleSource(exName);
|
|
411
|
+
if (!sf) return null;
|
|
412
|
+
|
|
413
|
+
let mapCallFound = false;
|
|
414
|
+
let dataArrayName: string | null = null;
|
|
415
|
+
let visibleCount = 3;
|
|
416
|
+
let scrollSetterName: string | null = null;
|
|
417
|
+
let labelsPerItem = 1;
|
|
418
|
+
let dataArrayValues: string[] | null = null;
|
|
419
|
+
let dataArrayObjects: Record<string, string>[] | null = null;
|
|
420
|
+
|
|
421
|
+
// First pass: find array literals to know data values
|
|
422
|
+
const arrayLiterals = new Map<string, string[]>();
|
|
423
|
+
const objectArrayLiterals = new Map<string, Record<string, string>[]>();
|
|
424
|
+
walkAST(sf, (node) => {
|
|
425
|
+
if (
|
|
426
|
+
ts.isVariableDeclaration(node) &&
|
|
427
|
+
ts.isIdentifier(node.name) &&
|
|
428
|
+
node.initializer &&
|
|
429
|
+
ts.isArrayLiteralExpression(node.initializer)
|
|
430
|
+
) {
|
|
431
|
+
// Try string array first
|
|
432
|
+
const strValues: string[] = [];
|
|
433
|
+
let allStrings = true;
|
|
434
|
+
for (const el of node.initializer.elements) {
|
|
435
|
+
if (ts.isStringLiteral(el)) strValues.push(el.text);
|
|
436
|
+
else { allStrings = false; break; }
|
|
437
|
+
}
|
|
438
|
+
if (allStrings && strValues.length > 0) {
|
|
439
|
+
arrayLiterals.set(node.name.text, strValues);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Try object array
|
|
444
|
+
const objValues: Record<string, string>[] = [];
|
|
445
|
+
let allObjects = true;
|
|
446
|
+
for (const el of node.initializer.elements) {
|
|
447
|
+
if (ts.isObjectLiteralExpression(el)) {
|
|
448
|
+
const obj: Record<string, string> = {};
|
|
449
|
+
for (const prop of el.properties) {
|
|
450
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && ts.isStringLiteral(prop.initializer)) {
|
|
451
|
+
obj[prop.name.text] = prop.initializer.text;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (Object.keys(obj).length > 0) objValues.push(obj);
|
|
455
|
+
else { allObjects = false; break; }
|
|
456
|
+
} else {
|
|
457
|
+
allObjects = false;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (allObjects && objValues.length > 0) {
|
|
462
|
+
objectArrayLiterals.set(node.name.text, objValues);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Second pass: find .map() and .slice()
|
|
468
|
+
walkAST(sf, (node) => {
|
|
469
|
+
// Find .map() call
|
|
470
|
+
if (
|
|
471
|
+
ts.isCallExpression(node) &&
|
|
472
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
473
|
+
node.expression.name.text === 'map'
|
|
474
|
+
) {
|
|
475
|
+
mapCallFound = true;
|
|
476
|
+
const obj = node.expression.expression;
|
|
477
|
+
if (ts.isIdentifier(obj) && !dataArrayName) {
|
|
478
|
+
// Only set if .slice() hasn't already identified the real source
|
|
479
|
+
dataArrayName = obj.text;
|
|
480
|
+
dataArrayValues = arrayLiterals.get(obj.text) ?? null;
|
|
481
|
+
dataArrayObjects = objectArrayLiterals.get(obj.text) ?? null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Count Text elements in callback
|
|
485
|
+
if (node.arguments.length > 0) {
|
|
486
|
+
let textCount = 0;
|
|
487
|
+
walkAST(node.arguments[0]!, (n) => {
|
|
488
|
+
if (ts.isJsxSelfClosingElement(n) && ts.isIdentifier(n.tagName) && n.tagName.text === 'Text') textCount++;
|
|
489
|
+
if (ts.isJsxElement(n) && ts.isIdentifier(n.openingElement.tagName) && n.openingElement.tagName.text === 'Text') textCount++;
|
|
490
|
+
});
|
|
491
|
+
if (textCount > 0) labelsPerItem = textCount;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Find .slice(index, index + N)
|
|
496
|
+
if (
|
|
497
|
+
ts.isVariableDeclaration(node) &&
|
|
498
|
+
node.initializer &&
|
|
499
|
+
ts.isCallExpression(node.initializer) &&
|
|
500
|
+
ts.isPropertyAccessExpression(node.initializer.expression) &&
|
|
501
|
+
node.initializer.expression.name.text === 'slice'
|
|
502
|
+
) {
|
|
503
|
+
const sliceArgs = node.initializer.arguments;
|
|
504
|
+
if (sliceArgs.length >= 2) {
|
|
505
|
+
const secondArg = sliceArgs[1]!;
|
|
506
|
+
if (
|
|
507
|
+
ts.isBinaryExpression(secondArg) &&
|
|
508
|
+
secondArg.operatorToken.kind === ts.SyntaxKind.PlusToken &&
|
|
509
|
+
ts.isNumericLiteral(secondArg.right)
|
|
510
|
+
) {
|
|
511
|
+
visibleCount = Number(secondArg.right.text);
|
|
512
|
+
}
|
|
513
|
+
// Find scroll setter from the index variable
|
|
514
|
+
const firstArg = sliceArgs[0]!;
|
|
515
|
+
if (ts.isIdentifier(firstArg)) {
|
|
516
|
+
const indexVarName = firstArg.text;
|
|
517
|
+
walkAST(sf, (n) => {
|
|
518
|
+
if (
|
|
519
|
+
ts.isVariableDeclaration(n) &&
|
|
520
|
+
ts.isArrayBindingPattern(n.name) &&
|
|
521
|
+
n.name.elements.length >= 2
|
|
522
|
+
) {
|
|
523
|
+
const first = n.name.elements[0]!;
|
|
524
|
+
if (
|
|
525
|
+
!ts.isOmittedExpression(first) &&
|
|
526
|
+
ts.isIdentifier(first.name) &&
|
|
527
|
+
first.name.text === indexVarName
|
|
528
|
+
) {
|
|
529
|
+
const setter = n.name.elements[1]!;
|
|
530
|
+
if (!ts.isOmittedExpression(setter) && ts.isIdentifier(setter.name)) {
|
|
531
|
+
scrollSetterName = setter.name.text;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Trace the source array from: visible = SOURCE.slice(...)
|
|
540
|
+
// This overrides .map()'s target since the slice source is the real data.
|
|
541
|
+
const sliceObj = node.initializer.expression.expression;
|
|
542
|
+
if (ts.isIdentifier(sliceObj)) {
|
|
543
|
+
dataArrayName = sliceObj.text;
|
|
544
|
+
dataArrayValues = arrayLiterals.get(sliceObj.text) ?? null;
|
|
545
|
+
dataArrayObjects = objectArrayLiterals.get(sliceObj.text) ?? null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
if (!mapCallFound || !dataArrayName) return null;
|
|
551
|
+
|
|
552
|
+
// Determine property order by matching baseline label texts to first item's properties
|
|
553
|
+
let propertyOrder: string[] | null = null;
|
|
554
|
+
if (dataArrayObjects && dataArrayObjects.length > 0 && labelsPerItem > 1) {
|
|
555
|
+
const firstItem = dataArrayObjects[0]!;
|
|
556
|
+
// We'll match during the main pipeline after rendering
|
|
557
|
+
propertyOrder = Object.keys(firstItem).slice(0, labelsPerItem);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { dataArrayName, dataArrayValues, dataArrayObjects, propertyOrder, visibleCount, scrollSetterName, labelsPerItem };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Emit context
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
interface EmitContext {
|
|
568
|
+
skins: Map<string, string>;
|
|
569
|
+
styles: Map<string, string>;
|
|
570
|
+
declarations: string[];
|
|
571
|
+
skinIdx: number;
|
|
572
|
+
styleIdx: number;
|
|
573
|
+
labelIdx: number;
|
|
574
|
+
rectIdx: number;
|
|
575
|
+
/** Map from label sequential index → its text */
|
|
576
|
+
labelTexts: Map<number, string>;
|
|
577
|
+
/** Map from rect sequential index → its fill color name */
|
|
578
|
+
rectFills: Map<number, string>;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function ensureSkin(ctx: EmitContext, fill: string): string {
|
|
582
|
+
const hex = colorToHex(fill);
|
|
583
|
+
const existing = ctx.skins.get(hex);
|
|
584
|
+
if (existing) return existing;
|
|
585
|
+
const name = `sk${ctx.skinIdx++}`;
|
|
586
|
+
ctx.skins.set(hex, name);
|
|
587
|
+
ctx.declarations.push(`const ${name} = new Skin({ fill: "${hex}" });`);
|
|
588
|
+
return name;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function ensureStyle(ctx: EmitContext, font: string, color: string): string {
|
|
592
|
+
const key = `${font}|${color}`;
|
|
593
|
+
const existing = ctx.styles.get(key);
|
|
594
|
+
if (existing) return existing;
|
|
595
|
+
const name = `st${ctx.styleIdx++}`;
|
|
596
|
+
ctx.styles.set(key, name);
|
|
597
|
+
const hex = colorToHex(color);
|
|
598
|
+
ctx.declarations.push(
|
|
599
|
+
`const ${name} = new Style({ font: "${fontToPiu(font)}", color: "${hex}" });`,
|
|
600
|
+
);
|
|
601
|
+
return name;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// Per-subtree conditional tracking (hoisted for emitNode access)
|
|
606
|
+
interface ConditionalChild {
|
|
607
|
+
stateSlot: number;
|
|
608
|
+
childIndex: number;
|
|
609
|
+
type: 'removed' | 'added';
|
|
610
|
+
}
|
|
611
|
+
const conditionalChildren: ConditionalChild[] = [];
|
|
612
|
+
let emitConditionals = false; // Only wrap conditionals during final emit
|
|
613
|
+
let conditionalDepth = 0; // Track nesting — only wrap at depth 1 (root Group's children)
|
|
614
|
+
|
|
615
|
+
// Emit piu tree
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
function emitNode(
|
|
619
|
+
node: AnyNode,
|
|
620
|
+
ctx: EmitContext,
|
|
621
|
+
indent: string,
|
|
622
|
+
dynamicLabels: Set<number> | null,
|
|
623
|
+
stateDeps: Map<number, { slotIndex: number; formatExpr: string }> | null,
|
|
624
|
+
skinDeps?: Map<number, { slotIndex: number; skins: string[] }> | null,
|
|
625
|
+
): string | null {
|
|
626
|
+
if (node.type === '#text') return null;
|
|
627
|
+
|
|
628
|
+
const el = node as DOMElement;
|
|
629
|
+
const p = el.props;
|
|
630
|
+
|
|
631
|
+
switch (el.type) {
|
|
632
|
+
case 'pbl-root':
|
|
633
|
+
case 'pbl-group': {
|
|
634
|
+
if (el.type === 'pbl-group') conditionalDepth++;
|
|
635
|
+
const kids = el.children
|
|
636
|
+
.map((c, childIdx) => {
|
|
637
|
+
const emitted = emitNode(c, ctx, indent + ' ', dynamicLabels, stateDeps, skinDeps);
|
|
638
|
+
if (!emitted) return null;
|
|
639
|
+
|
|
640
|
+
// Check if this child is a per-subtree conditional
|
|
641
|
+
// (only applies to children of the root Group, which is child 0 of pbl-root)
|
|
642
|
+
// Only wrap at the root Group level (depth 1), not nested Groups
|
|
643
|
+
if (el.type === 'pbl-group' && emitConditionals && conditionalDepth === 1 && conditionalChildren.length > 0) {
|
|
644
|
+
const cond = conditionalChildren.find(
|
|
645
|
+
cc => cc.childIndex === childIdx && cc.type === 'removed'
|
|
646
|
+
);
|
|
647
|
+
if (cond) {
|
|
648
|
+
const name = `cv_s${cond.stateSlot}_${childIdx}`;
|
|
649
|
+
return `${indent} new Container(null, { name: "${name}", visible: true, left: 0, right: 0, top: 0, bottom: 0, contents: [\n${emitted}\n${indent} ] })`;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return emitted;
|
|
654
|
+
})
|
|
655
|
+
.filter(Boolean);
|
|
656
|
+
if (el.type === 'pbl-root') return kids.join(',\n');
|
|
657
|
+
const x = num(p, 'x');
|
|
658
|
+
const y = num(p, 'y');
|
|
659
|
+
// Groups without explicit position need full-size layout constraints
|
|
660
|
+
// so piu actually sizes them (otherwise they get zero size).
|
|
661
|
+
const layout = (x === 0 && y === 0)
|
|
662
|
+
? 'left: 0, right: 0, top: 0, bottom: 0, '
|
|
663
|
+
: `left: ${x}, right: 0, top: ${y}, `;
|
|
664
|
+
|
|
665
|
+
// If this Group is a DIRECT parent of list slot labels (not a grandparent),
|
|
666
|
+
// give it a name so the Behavior can find it.
|
|
667
|
+
let groupNameProp = '';
|
|
668
|
+
if (listInfo && listInfo.labelsPerItem > 1 && listSlotLabels.size > 0) {
|
|
669
|
+
// Check if a DIRECT child (not nested) is a named list label
|
|
670
|
+
const directListLabels = kids.filter(k => k?.includes('name: "ls') && !k?.includes('contents:'));
|
|
671
|
+
if (directListLabels.length > 0) {
|
|
672
|
+
const m = directListLabels[0]!.match(/name: "ls(\d+)_/);
|
|
673
|
+
if (m) {
|
|
674
|
+
groupNameProp = `name: "lg${m[1]}", `;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (el.type === 'pbl-group') conditionalDepth--;
|
|
680
|
+
return `${indent}new Container(null, { ${groupNameProp}${layout}contents: [\n${kids.join(',\n')}\n${indent}] })`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
case 'pbl-rect': {
|
|
684
|
+
const fill = str(p, 'fill');
|
|
685
|
+
if (!fill) return null;
|
|
686
|
+
const w = num(p, 'w') || num(p, 'width');
|
|
687
|
+
const h = num(p, 'h') || num(p, 'height');
|
|
688
|
+
const x = num(p, 'x');
|
|
689
|
+
const y = num(p, 'y');
|
|
690
|
+
const skinVar = ensureSkin(ctx, fill);
|
|
691
|
+
|
|
692
|
+
// Track rect fill for skin reactivity detection
|
|
693
|
+
const rectIdx = ctx.rectIdx++;
|
|
694
|
+
ctx.rectFills.set(rectIdx, fill);
|
|
695
|
+
|
|
696
|
+
// If this rect has a dynamic skin, give it a name
|
|
697
|
+
const isSkinDynamic = skinDeps?.has(rectIdx) ?? false;
|
|
698
|
+
const nameProp = isSkinDynamic ? `, name: "sr${rectIdx}"` : '';
|
|
699
|
+
|
|
700
|
+
// Use constraint-based layout when dimensions match screen size
|
|
701
|
+
// (so the output adapts to any screen at runtime)
|
|
702
|
+
const sizeProps = buildSizeProps(x, y, w, h);
|
|
703
|
+
|
|
704
|
+
const kids = el.children
|
|
705
|
+
.map((c) => emitNode(c, ctx, indent + ' ', dynamicLabels, stateDeps, skinDeps))
|
|
706
|
+
.filter(Boolean);
|
|
707
|
+
if (kids.length > 0) {
|
|
708
|
+
return `${indent}new Container(null, { ${sizeProps}, skin: ${skinVar}${nameProp}, contents: [\n${kids.join(',\n')}\n${indent}] })`;
|
|
709
|
+
}
|
|
710
|
+
return `${indent}new Content(null, { ${sizeProps}, skin: ${skinVar}${nameProp} })`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
case 'pbl-text': {
|
|
714
|
+
const text = getTextContent(el);
|
|
715
|
+
if (!text) return null;
|
|
716
|
+
const font = str(p, 'font');
|
|
717
|
+
const color = str(p, 'color') ?? 'white';
|
|
718
|
+
const align = str(p, 'align') ?? 'left';
|
|
719
|
+
const w = num(p, 'w') || num(p, 'width');
|
|
720
|
+
const x = num(p, 'x');
|
|
721
|
+
const y = num(p, 'y');
|
|
722
|
+
|
|
723
|
+
const idx = ctx.labelIdx++;
|
|
724
|
+
ctx.labelTexts.set(idx, text);
|
|
725
|
+
|
|
726
|
+
const styleVar = ensureStyle(ctx, font ?? 'gothic18', color);
|
|
727
|
+
const posProps: string[] = [`top: ${y}`];
|
|
728
|
+
posProps.push(`left: ${x}`);
|
|
729
|
+
if (w > 0) posProps.push(`width: ${w}`);
|
|
730
|
+
const horizProp = align !== 'left' ? `, horizontal: "${align}"` : '';
|
|
731
|
+
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
732
|
+
|
|
733
|
+
// Determine label naming: state-dep → "sl{idx}", time-dep → "tl{idx}"
|
|
734
|
+
const isTimeDynamic = dynamicLabels?.has(idx) ?? false;
|
|
735
|
+
const isStateDynamic = stateDeps?.has(idx) ?? false;
|
|
736
|
+
let nameProp = '';
|
|
737
|
+
if (listSlotLabels.has(idx)) {
|
|
738
|
+
const flatIdx = [...listSlotLabels].indexOf(idx);
|
|
739
|
+
const lpi = listInfo?.labelsPerItem ?? 1;
|
|
740
|
+
const itemIdx = Math.floor(flatIdx / lpi);
|
|
741
|
+
const labelIdx = flatIdx % lpi;
|
|
742
|
+
const lsName = lpi > 1 ? `ls${itemIdx}_${labelIdx}` : `ls${flatIdx}`;
|
|
743
|
+
nameProp = `, name: "${lsName}"`;
|
|
744
|
+
} else if (isStateDynamic) {
|
|
745
|
+
nameProp = `, name: "sl${idx}"`;
|
|
746
|
+
} else if (isTimeDynamic) {
|
|
747
|
+
nameProp = `, name: "tl${idx}"`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return `${indent}new Label(null, { ${posProps.join(', ')}, style: ${styleVar}${horizProp}${nameProp}, string: "${escaped}" })`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
case 'pbl-line': {
|
|
754
|
+
const x1 = num(p, 'x');
|
|
755
|
+
const y1 = num(p, 'y');
|
|
756
|
+
const x2 = num(p, 'x2');
|
|
757
|
+
const y2 = num(p, 'y2');
|
|
758
|
+
const color = str(p, 'color') ?? str(p, 'stroke') ?? 'white';
|
|
759
|
+
const skinVar = ensureSkin(ctx, color);
|
|
760
|
+
const sw = num(p, 'strokeWidth') || 1;
|
|
761
|
+
if (y1 === y2) {
|
|
762
|
+
const left = Math.min(x1, x2);
|
|
763
|
+
const w = Math.abs(x2 - x1) || 1;
|
|
764
|
+
return `${indent}new Content(null, { left: ${left}, top: ${y1}, width: ${w}, height: ${sw}, skin: ${skinVar} })`;
|
|
765
|
+
} else if (x1 === x2) {
|
|
766
|
+
const top = Math.min(y1, y2);
|
|
767
|
+
const h = Math.abs(y2 - y1) || 1;
|
|
768
|
+
return `${indent}new Content(null, { left: ${x1}, top: ${top}, width: ${sw}, height: ${h}, skin: ${skinVar} })`;
|
|
769
|
+
}
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
case 'pbl-circle': {
|
|
774
|
+
const r = num(p, 'r') || num(p, 'radius');
|
|
775
|
+
const cx = num(p, 'x');
|
|
776
|
+
const cy = num(p, 'y');
|
|
777
|
+
const fill = str(p, 'fill');
|
|
778
|
+
if (!fill || r <= 0) return null;
|
|
779
|
+
const skinVar = ensureSkin(ctx, fill);
|
|
780
|
+
// piu RoundRect with radius = r draws a circle when width = height = 2*r
|
|
781
|
+
const size = r * 2;
|
|
782
|
+
return `${indent}new RoundRect(null, { left: ${cx}, top: ${cy}, width: ${size}, height: ${size}, radius: ${r}, skin: ${skinVar} })`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
default:
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function buildPos(p: Record<string, unknown>): string {
|
|
791
|
+
const parts: string[] = [];
|
|
792
|
+
const x = num(p, 'x');
|
|
793
|
+
const y = num(p, 'y');
|
|
794
|
+
if (x) parts.push(`left: ${x}`);
|
|
795
|
+
if (y) parts.push(`top: ${y}`);
|
|
796
|
+
return parts.length > 0 ? parts.join(', ') + ', ' : '';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Build size props using constraint layout when dimensions match screen.
|
|
801
|
+
* full-width (w === SCREEN.width) → left: 0, right: 0
|
|
802
|
+
* full-height (h === SCREEN.height) → top: 0, bottom: 0
|
|
803
|
+
* Otherwise: absolute left/top/width/height
|
|
804
|
+
*/
|
|
805
|
+
function buildSizeProps(x: number, y: number, w: number, h: number): string {
|
|
806
|
+
const parts: string[] = [];
|
|
807
|
+
|
|
808
|
+
if (w >= SCREEN.width && x === 0) {
|
|
809
|
+
parts.push('left: 0', 'right: 0');
|
|
810
|
+
} else {
|
|
811
|
+
if (x !== 0) parts.push(`left: ${x}`);
|
|
812
|
+
else parts.push('left: 0');
|
|
813
|
+
parts.push(`width: ${w}`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (h >= SCREEN.height && y === 0) {
|
|
817
|
+
parts.push('top: 0', 'bottom: 0');
|
|
818
|
+
} else {
|
|
819
|
+
parts.push(`top: ${y}`);
|
|
820
|
+
parts.push(`height: ${h}`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return parts.join(', ');
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// Time format inference
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
type TimeFormat = 'HHMM' | 'SS' | 'DATE';
|
|
831
|
+
|
|
832
|
+
function inferTimeFormat(textAtT1: string, t1: Date): TimeFormat | null {
|
|
833
|
+
const hh = pad2(t1.getHours());
|
|
834
|
+
const mm = pad2(t1.getMinutes());
|
|
835
|
+
const ss = pad2(t1.getSeconds());
|
|
836
|
+
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
837
|
+
const months = [
|
|
838
|
+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
839
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
840
|
+
];
|
|
841
|
+
|
|
842
|
+
if (textAtT1 === `${hh}:${mm}`) return 'HHMM';
|
|
843
|
+
if (textAtT1 === ss) return 'SS';
|
|
844
|
+
if (
|
|
845
|
+
textAtT1.includes(days[t1.getDay()]!) &&
|
|
846
|
+
textAtT1.includes(months[t1.getMonth()]!)
|
|
847
|
+
) {
|
|
848
|
+
return 'DATE';
|
|
849
|
+
}
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function emitTimeExpr(fmt: TimeFormat): string {
|
|
854
|
+
switch (fmt) {
|
|
855
|
+
case 'HHMM':
|
|
856
|
+
return 'pad(d.getHours()) + ":" + pad(d.getMinutes())';
|
|
857
|
+
case 'SS':
|
|
858
|
+
return 'pad(d.getSeconds())';
|
|
859
|
+
case 'DATE':
|
|
860
|
+
return 'days[d.getDay()] + " " + months[d.getMonth()] + " " + d.getDate()';
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
// Main
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
|
|
868
|
+
const origLog = console.log;
|
|
869
|
+
const silence = () => {
|
|
870
|
+
console.log = () => {};
|
|
871
|
+
};
|
|
872
|
+
const restore = () => {
|
|
873
|
+
console.log = origLog;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
// Hoisted declarations (populated during detection phases below)
|
|
877
|
+
const listSlotLabels = new Set<number>();
|
|
878
|
+
let listScrollSlotIndex = -1;
|
|
879
|
+
|
|
880
|
+
// Install interceptors BEFORE any rendering
|
|
881
|
+
installUseStateInterceptor();
|
|
882
|
+
extractButtonBindingsFromSource(exampleName);
|
|
883
|
+
|
|
884
|
+
// Create BOTH test dates with the REAL Date before any mocking.
|
|
885
|
+
const OrigDate = globalThis.Date;
|
|
886
|
+
const T1 = new OrigDate(2026, 0, 15, 9, 7, 3);
|
|
887
|
+
const T2 = new OrigDate(2026, 5, 20, 14, 52, 48);
|
|
888
|
+
(globalThis as unknown as { Date: unknown }).Date = class MockDate extends OrigDate {
|
|
889
|
+
constructor() {
|
|
890
|
+
super();
|
|
891
|
+
return T1;
|
|
892
|
+
}
|
|
893
|
+
static now() {
|
|
894
|
+
return T1.getTime();
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// --- Render at T1 (baseline) ---
|
|
899
|
+
resetStateTracking();
|
|
900
|
+
silence();
|
|
901
|
+
const app1 = exampleMain();
|
|
902
|
+
restore();
|
|
903
|
+
await settle(); // Let async effects (useEffect + setTimeout) fire
|
|
904
|
+
|
|
905
|
+
if (!app1) {
|
|
906
|
+
process.stderr.write('Failed to render at T1\n');
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Collect label texts from T1 render
|
|
911
|
+
const ctx1: EmitContext = {
|
|
912
|
+
skins: new Map(),
|
|
913
|
+
styles: new Map(),
|
|
914
|
+
declarations: [],
|
|
915
|
+
skinIdx: 0,
|
|
916
|
+
styleIdx: 0,
|
|
917
|
+
labelIdx: 0,
|
|
918
|
+
labelTexts: new Map(),
|
|
919
|
+
rectIdx: 0,
|
|
920
|
+
rectFills: new Map(),
|
|
921
|
+
};
|
|
922
|
+
emitNode(app1._root, ctx1, '', null, null);
|
|
923
|
+
const t1Texts = new Map(ctx1.labelTexts);
|
|
924
|
+
// Don't unmount app1 yet — we need its _root for per-subtree tree diff
|
|
925
|
+
|
|
926
|
+
process.stderr.write(`State slots discovered: ${stateSlots.length}\n`);
|
|
927
|
+
process.stderr.write(`Button bindings discovered: ${buttonBindings.length}\n`);
|
|
928
|
+
for (const b of buttonBindings) {
|
|
929
|
+
process.stderr.write(` button="${b.button}" handler=${b.handlerSource}\n`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
// Perturbation pipeline — discover state-dependent labels
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
|
|
936
|
+
const stateDeps = new Map<number, { slotIndex: number; formatExpr: string }>();
|
|
937
|
+
const skinDeps = new Map<number, { slotIndex: number; skins: [string, string] }>();
|
|
938
|
+
|
|
939
|
+
// Branch info: when tree structure changes between baseline and perturbed,
|
|
940
|
+
// we record both renders' full label sets so we can emit both as branches
|
|
941
|
+
// wrapped in named Containers with .visible toggling.
|
|
942
|
+
interface BranchInfo {
|
|
943
|
+
stateSlot: number;
|
|
944
|
+
perturbedValue: unknown;
|
|
945
|
+
baselineLabels: Map<number, string>;
|
|
946
|
+
perturbedLabels: Map<number, string>;
|
|
947
|
+
}
|
|
948
|
+
const branchInfos: BranchInfo[] = [];
|
|
949
|
+
|
|
950
|
+
// Per-subtree conditionals use the hoisted `conditionalChildren` array
|
|
951
|
+
// (declared before emitNode so it's accessible during rendering).
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Compare children of two pebble-dom Group nodes to find which children
|
|
955
|
+
* were added or removed when a state was perturbed.
|
|
956
|
+
*
|
|
957
|
+
* Uses a simple fingerprint match: each child is identified by its type
|
|
958
|
+
* + first text content. Children present in baseline but not perturbed
|
|
959
|
+
* are 'removed' (conditional on the truthy state).
|
|
960
|
+
*/
|
|
961
|
+
function diffTreeChildren(
|
|
962
|
+
baselineRoot: DOMElement,
|
|
963
|
+
perturbedRoot: DOMElement,
|
|
964
|
+
stateSlot: number,
|
|
965
|
+
): ConditionalChild[] {
|
|
966
|
+
const result: ConditionalChild[] = [];
|
|
967
|
+
|
|
968
|
+
// Get the actual Group container (root → first child which is the Group)
|
|
969
|
+
const baseGroup = baselineRoot.children[0];
|
|
970
|
+
const pertGroup = perturbedRoot.children[0];
|
|
971
|
+
if (!baseGroup || baseGroup.type === '#text' || !pertGroup || pertGroup.type === '#text') {
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const baseChildren = (baseGroup as DOMElement).children.filter(c => c.type !== '#text');
|
|
976
|
+
const pertChildren = (pertGroup as DOMElement).children.filter(c => c.type !== '#text');
|
|
977
|
+
|
|
978
|
+
// Fingerprint each child: type + first text descendant
|
|
979
|
+
function fingerprint(node: AnyNode): string {
|
|
980
|
+
if (node.type === '#text') return `#text:${node.value}`;
|
|
981
|
+
const el = node as DOMElement;
|
|
982
|
+
const firstText = el.children.find(c => c.type === '#text' || (c.type !== '#text' && getTextContent(c)));
|
|
983
|
+
const text = firstText ? getTextContent(firstText) : '';
|
|
984
|
+
return `${el.type}:${text.slice(0, 30)}`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const baseFPs = baseChildren.map(fingerprint);
|
|
988
|
+
const pertFPs = pertChildren.map(fingerprint);
|
|
989
|
+
|
|
990
|
+
// Find children in baseline but not in perturbed (removed when state perturbed)
|
|
991
|
+
for (let i = 0; i < baseFPs.length; i++) {
|
|
992
|
+
if (!pertFPs.includes(baseFPs[i]!)) {
|
|
993
|
+
result.push({ stateSlot, childIndex: i, type: 'removed' });
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Find children in perturbed but not in baseline (added when state perturbed)
|
|
998
|
+
for (let i = 0; i < pertFPs.length; i++) {
|
|
999
|
+
if (!baseFPs.includes(pertFPs[i]!)) {
|
|
1000
|
+
result.push({ stateSlot, childIndex: i, type: 'added' });
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return result;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* For string enum states, return the set of possible values from button
|
|
1009
|
+
* handler sources. Values are collected during analyzeButtonHandler into
|
|
1010
|
+
* the module-level stringEnumValues map; this function triggers analysis
|
|
1011
|
+
* if not yet done and returns the map.
|
|
1012
|
+
*/
|
|
1013
|
+
function extractStringValuesFromHandlers(): Map<number, Set<string>> {
|
|
1014
|
+
// Ensure handler analysis has run so stringEnumValues is populated.
|
|
1015
|
+
// Analyze all bindings if not yet analyzed.
|
|
1016
|
+
for (const binding of buttonBindings) {
|
|
1017
|
+
analyzeButtonHandler(binding.handlerSource);
|
|
1018
|
+
}
|
|
1019
|
+
return stringEnumValues;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function computePerturbedValues(slot: StateSlot): unknown[] {
|
|
1023
|
+
const v = slot.initialValue;
|
|
1024
|
+
if (typeof v === 'number') return [v + 42];
|
|
1025
|
+
if (typeof v === 'boolean') return [!v];
|
|
1026
|
+
if (typeof v === 'string') {
|
|
1027
|
+
// Use extracted enum values (excluding the initial value itself)
|
|
1028
|
+
const enumValues = extractStringValuesFromHandlers().get(slot.index);
|
|
1029
|
+
if (enumValues && enumValues.size > 0) {
|
|
1030
|
+
return [...enumValues].filter((ev) => ev !== v);
|
|
1031
|
+
}
|
|
1032
|
+
return [v + '__PROBE__']; // fallback
|
|
1033
|
+
}
|
|
1034
|
+
return []; // unsupported type — skip
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
for (const slot of stateSlots) {
|
|
1038
|
+
const perturbedValues = computePerturbedValues(slot);
|
|
1039
|
+
if (perturbedValues.length === 0) continue;
|
|
1040
|
+
|
|
1041
|
+
for (const perturbedValue of perturbedValues) {
|
|
1042
|
+
|
|
1043
|
+
forcedStateValues.set(slot.index, perturbedValue);
|
|
1044
|
+
|
|
1045
|
+
// Re-render with forced state
|
|
1046
|
+
resetStateTracking();
|
|
1047
|
+
silence();
|
|
1048
|
+
const appP = exampleMain();
|
|
1049
|
+
restore();
|
|
1050
|
+
|
|
1051
|
+
if (appP) {
|
|
1052
|
+
const ctxP: EmitContext = {
|
|
1053
|
+
skins: new Map(),
|
|
1054
|
+
styles: new Map(),
|
|
1055
|
+
declarations: [],
|
|
1056
|
+
skinIdx: 0,
|
|
1057
|
+
styleIdx: 0,
|
|
1058
|
+
labelIdx: 0,
|
|
1059
|
+
labelTexts: new Map(),
|
|
1060
|
+
rectIdx: 0,
|
|
1061
|
+
rectFills: new Map(),
|
|
1062
|
+
};
|
|
1063
|
+
emitNode(appP._root, ctxP, '', null, null);
|
|
1064
|
+
|
|
1065
|
+
// Check if tree shape changed (different label count or structure)
|
|
1066
|
+
const baseKeys = [...t1Texts.keys()].sort((a, b) => a - b);
|
|
1067
|
+
const pertKeys = [...ctxP.labelTexts.keys()].sort((a, b) => a - b);
|
|
1068
|
+
const sameShape = baseKeys.length === pertKeys.length &&
|
|
1069
|
+
baseKeys.every((k, i) => k === pertKeys[i]);
|
|
1070
|
+
|
|
1071
|
+
if (sameShape) {
|
|
1072
|
+
// Same structure — check for text changes (existing v3 path)
|
|
1073
|
+
for (const [idx, baseText] of t1Texts) {
|
|
1074
|
+
const pertText = ctxP.labelTexts.get(idx);
|
|
1075
|
+
if (pertText !== undefined && pertText !== baseText) {
|
|
1076
|
+
let formatExpr: string;
|
|
1077
|
+
if (String(perturbedValue) === pertText) {
|
|
1078
|
+
formatExpr = `"" + this.s${slot.index}`;
|
|
1079
|
+
} else if (typeof slot.initialValue === 'boolean') {
|
|
1080
|
+
// Boolean state produced different text — emit a ternary
|
|
1081
|
+
formatExpr = `this.s${slot.index} ? "${pertText.replace(/"/g, '\\"')}" : "${baseText.replace(/"/g, '\\"')}"`;
|
|
1082
|
+
} else {
|
|
1083
|
+
formatExpr = `"" + this.s${slot.index}`;
|
|
1084
|
+
}
|
|
1085
|
+
stateDeps.set(idx, { slotIndex: slot.index, formatExpr });
|
|
1086
|
+
process.stderr.write(
|
|
1087
|
+
` Label ${idx} depends on state slot ${slot.index} (base="${baseText}", perturbed="${pertText}")\n`,
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Also check for rect fill changes
|
|
1093
|
+
const baseRectFills = ctx1.rectFills;
|
|
1094
|
+
for (const [rIdx, baseFill] of baseRectFills) {
|
|
1095
|
+
const pertFill = ctxP.rectFills.get(rIdx);
|
|
1096
|
+
if (pertFill !== undefined && pertFill !== baseFill) {
|
|
1097
|
+
// Ensure both skins exist in declarations
|
|
1098
|
+
ensureSkin(ctx1, baseFill);
|
|
1099
|
+
ensureSkin(ctx1, pertFill);
|
|
1100
|
+
skinDeps.set(rIdx, {
|
|
1101
|
+
slotIndex: slot.index,
|
|
1102
|
+
skins: [baseFill, pertFill],
|
|
1103
|
+
});
|
|
1104
|
+
process.stderr.write(
|
|
1105
|
+
` Rect ${rIdx} skin depends on state slot ${slot.index} (base="${baseFill}", perturbed="${pertFill}")\n`,
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
} else {
|
|
1110
|
+
// Different structure — this is a conditional branch!
|
|
1111
|
+
// EXCEPT: skip if this slot is the list scroll index — list items
|
|
1112
|
+
// change count when scrolled past the end, which is handled by
|
|
1113
|
+
// the list slot pool, not structural branching.
|
|
1114
|
+
if (listInfo && listInfo.scrollSetterName && setterSlotMap.get(listInfo.scrollSetterName) === slot.index) {
|
|
1115
|
+
process.stderr.write(
|
|
1116
|
+
` State slot ${slot.index} causes structural change (skipped — list scroll): ${baseKeys.length} → ${pertKeys.length} labels\n`,
|
|
1117
|
+
);
|
|
1118
|
+
// Still check labels that exist in BOTH renders for text changes
|
|
1119
|
+
// (e.g., a "1/5" counter label that updates on scroll).
|
|
1120
|
+
for (const [idx, baseText] of t1Texts) {
|
|
1121
|
+
const pertText = ctxP.labelTexts.get(idx);
|
|
1122
|
+
if (pertText !== undefined && pertText !== baseText) {
|
|
1123
|
+
// Skip labels that will be handled as list slots
|
|
1124
|
+
if (listSlotLabels.has(idx)) continue;
|
|
1125
|
+
// Infer format by substitution: find where the perturbed value appears in the text
|
|
1126
|
+
let formatExpr: string;
|
|
1127
|
+
const pv = String(Number(perturbedValue) + 1); // try value+1 (common: sel+1)
|
|
1128
|
+
if (pertText.includes(pv)) {
|
|
1129
|
+
// Pattern like "{sel+1}/{total}" → "(this.s{N} + 1) + suffix"
|
|
1130
|
+
const before = pertText.substring(0, pertText.indexOf(pv));
|
|
1131
|
+
const after = pertText.substring(pertText.indexOf(pv) + pv.length);
|
|
1132
|
+
formatExpr = `${before ? `"${before}" + ` : ''}(this.s${slot.index} + 1)${after ? ` + "${after}"` : ''}`;
|
|
1133
|
+
} else if (pertText.includes(String(perturbedValue))) {
|
|
1134
|
+
const pStr = String(perturbedValue);
|
|
1135
|
+
const before = pertText.substring(0, pertText.indexOf(pStr));
|
|
1136
|
+
const after = pertText.substring(pertText.indexOf(pStr) + pStr.length);
|
|
1137
|
+
formatExpr = `${before ? `"${before}" + ` : ''}this.s${slot.index}${after ? ` + "${after}"` : ''}`;
|
|
1138
|
+
} else {
|
|
1139
|
+
formatExpr = `"" + this.s${slot.index}`;
|
|
1140
|
+
}
|
|
1141
|
+
stateDeps.set(idx, { slotIndex: slot.index, formatExpr });
|
|
1142
|
+
process.stderr.write(
|
|
1143
|
+
` Label ${idx} depends on state slot ${slot.index} (base="${baseText}", perturbed="${pertText}")\n`,
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
} else if (typeof slot.initialValue === 'boolean') {
|
|
1148
|
+
// Boolean state with structural change → per-subtree conditional
|
|
1149
|
+
// Diff the tree to find which specific children appeared/disappeared
|
|
1150
|
+
const diffs = diffTreeChildren(app1._root, appP._root, slot.index);
|
|
1151
|
+
if (diffs.length > 0) {
|
|
1152
|
+
conditionalChildren.push(...diffs);
|
|
1153
|
+
process.stderr.write(
|
|
1154
|
+
` State slot ${slot.index}: ${diffs.length} conditional child(ren) detected\n`,
|
|
1155
|
+
);
|
|
1156
|
+
} else {
|
|
1157
|
+
// Fallback to whole-tree branch if diff couldn't identify subtrees
|
|
1158
|
+
process.stderr.write(
|
|
1159
|
+
` State slot ${slot.index} causes structural change: ${baseKeys.length} labels → ${pertKeys.length} labels\n`,
|
|
1160
|
+
);
|
|
1161
|
+
branchInfos.push({
|
|
1162
|
+
stateSlot: slot.index,
|
|
1163
|
+
perturbedValue,
|
|
1164
|
+
baselineLabels: new Map(t1Texts),
|
|
1165
|
+
perturbedLabels: new Map(ctxP.labelTexts),
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
} else {
|
|
1169
|
+
// Non-boolean (string enum etc.) → whole-tree branching as before
|
|
1170
|
+
process.stderr.write(
|
|
1171
|
+
` State slot ${slot.index} causes structural change: ${baseKeys.length} labels → ${pertKeys.length} labels\n`,
|
|
1172
|
+
);
|
|
1173
|
+
branchInfos.push({
|
|
1174
|
+
stateSlot: slot.index,
|
|
1175
|
+
perturbedValue,
|
|
1176
|
+
baselineLabels: new Map(t1Texts),
|
|
1177
|
+
perturbedLabels: new Map(ctxP.labelTexts),
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
appP.unmount();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
forcedStateValues.delete(slot.index);
|
|
1186
|
+
} // end for perturbedValues
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Now safe to unmount baseline
|
|
1190
|
+
app1.unmount();
|
|
1191
|
+
|
|
1192
|
+
process.stderr.write(`State-dependent labels: ${stateDeps.size}\n`);
|
|
1193
|
+
process.stderr.write(`Structural branches: ${branchInfos.length}\n`);
|
|
1194
|
+
if (conditionalChildren.length > 0) {
|
|
1195
|
+
process.stderr.write(`Conditional subtrees: ${conditionalChildren.length}\n`);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// ---------------------------------------------------------------------------
|
|
1199
|
+
// List slot detection: identify which labels are .map() items
|
|
1200
|
+
// ---------------------------------------------------------------------------
|
|
1201
|
+
|
|
1202
|
+
// (listSlotLabels and listScrollSlotIndex hoisted before first emitNode call)
|
|
1203
|
+
// Populated in the list slot detection section below.
|
|
1204
|
+
|
|
1205
|
+
if (listInfo) {
|
|
1206
|
+
if (listInfo.scrollSetterName && setterSlotMap.has(listInfo.scrollSetterName)) {
|
|
1207
|
+
listScrollSlotIndex = setterSlotMap.get(listInfo.scrollSetterName)!;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Perturb scroll index to 1 and diff — labels that change are list slots
|
|
1211
|
+
if (listScrollSlotIndex >= 0) {
|
|
1212
|
+
forcedStateValues.set(listScrollSlotIndex, 1);
|
|
1213
|
+
resetStateTracking();
|
|
1214
|
+
silence();
|
|
1215
|
+
const appScroll = exampleMain();
|
|
1216
|
+
restore();
|
|
1217
|
+
|
|
1218
|
+
if (appScroll) {
|
|
1219
|
+
const ctxScroll: EmitContext = {
|
|
1220
|
+
skins: new Map(), styles: new Map(), declarations: [],
|
|
1221
|
+
skinIdx: 0, styleIdx: 0, labelIdx: 0, labelTexts: new Map(),
|
|
1222
|
+
rectIdx: 0, rectFills: new Map(),
|
|
1223
|
+
};
|
|
1224
|
+
emitNode(appScroll._root, ctxScroll, '', null, null);
|
|
1225
|
+
|
|
1226
|
+
for (const [idx, baseText] of t1Texts) {
|
|
1227
|
+
const scrollText = ctxScroll.labelTexts.get(idx);
|
|
1228
|
+
if (scrollText !== undefined && scrollText !== baseText) {
|
|
1229
|
+
listSlotLabels.add(idx);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
appScroll.unmount();
|
|
1233
|
+
}
|
|
1234
|
+
forcedStateValues.delete(listScrollSlotIndex);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Trim to expected count: visibleCount × labelsPerItem.
|
|
1238
|
+
// Extra labels (e.g., a "1/5" counter) that change on scroll aren't list slots.
|
|
1239
|
+
const expectedSlots = listInfo.visibleCount * listInfo.labelsPerItem;
|
|
1240
|
+
if (listSlotLabels.size > expectedSlots) {
|
|
1241
|
+
const all = [...listSlotLabels];
|
|
1242
|
+
// Keep the LAST expectedSlots (list items appear after header labels)
|
|
1243
|
+
const keep = new Set(all.slice(all.length - expectedSlots));
|
|
1244
|
+
listSlotLabels.clear();
|
|
1245
|
+
for (const idx of keep) listSlotLabels.add(idx);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (listSlotLabels.size > 0) {
|
|
1249
|
+
process.stderr.write(`List slot labels: [${[...listSlotLabels].join(', ')}]\n`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// --- Render at T2 (for time diff) ---
|
|
1254
|
+
(globalThis as unknown as { Date: unknown }).Date = class MockDate2 extends OrigDate {
|
|
1255
|
+
constructor() {
|
|
1256
|
+
super();
|
|
1257
|
+
return T2;
|
|
1258
|
+
}
|
|
1259
|
+
static now() {
|
|
1260
|
+
return T2.getTime();
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
process.stderr.write('T2 Date.now before render: ' + Date.now() + ' vs expected ' + T2.getTime() + '\n');
|
|
1265
|
+
process.stderr.write('T2 new Date(): ' + new Date() + '\n');
|
|
1266
|
+
resetStateTracking();
|
|
1267
|
+
silence();
|
|
1268
|
+
const app2 = exampleMain();
|
|
1269
|
+
restore();
|
|
1270
|
+
|
|
1271
|
+
if (!app2) {
|
|
1272
|
+
process.stderr.write('Failed to render at T2\n');
|
|
1273
|
+
process.exit(1);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const ctx2: EmitContext = {
|
|
1277
|
+
skins: new Map(),
|
|
1278
|
+
styles: new Map(),
|
|
1279
|
+
declarations: [],
|
|
1280
|
+
skinIdx: 0,
|
|
1281
|
+
styleIdx: 0,
|
|
1282
|
+
labelIdx: 0,
|
|
1283
|
+
labelTexts: new Map(),
|
|
1284
|
+
rectIdx: 0,
|
|
1285
|
+
rectFills: new Map(),
|
|
1286
|
+
};
|
|
1287
|
+
emitNode(app2._root, ctx2, '', null, null);
|
|
1288
|
+
const t2Texts = new Map(ctx2.labelTexts);
|
|
1289
|
+
app2.unmount();
|
|
1290
|
+
|
|
1291
|
+
// Restore real Date
|
|
1292
|
+
(globalThis as unknown as { Date: typeof Date }).Date = OrigDate;
|
|
1293
|
+
|
|
1294
|
+
// --- Diff texts to find time-dynamic labels ---
|
|
1295
|
+
const dynamicLabels = new Set<number>();
|
|
1296
|
+
const labelFormats = new Map<number, TimeFormat>();
|
|
1297
|
+
|
|
1298
|
+
for (const [idx, text1] of t1Texts) {
|
|
1299
|
+
// Skip labels that are state-dependent (already handled)
|
|
1300
|
+
if (stateDeps.has(idx)) continue;
|
|
1301
|
+
const text2 = t2Texts.get(idx);
|
|
1302
|
+
if (text2 !== undefined && text1 !== text2) {
|
|
1303
|
+
const fmt = inferTimeFormat(text1, T1);
|
|
1304
|
+
if (fmt) {
|
|
1305
|
+
dynamicLabels.add(idx);
|
|
1306
|
+
labelFormats.set(idx, fmt);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
process.stderr.write('T1 texts: ' + JSON.stringify([...t1Texts]) + '\n');
|
|
1312
|
+
process.stderr.write('T2 texts: ' + JSON.stringify([...t2Texts]) + '\n');
|
|
1313
|
+
process.stderr.write(
|
|
1314
|
+
`Found ${dynamicLabels.size} time-dependent label(s): ${[...labelFormats.entries()]
|
|
1315
|
+
.map(([idx, fmt]) => `tl${idx}=${fmt}`)
|
|
1316
|
+
.join(', ')}\n`,
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
// --- Final render at T1 for the emitted static snapshot ---
|
|
1320
|
+
(globalThis as unknown as { Date: unknown }).Date = class MockDate3 extends OrigDate {
|
|
1321
|
+
constructor() {
|
|
1322
|
+
super();
|
|
1323
|
+
return T1;
|
|
1324
|
+
}
|
|
1325
|
+
static now() {
|
|
1326
|
+
return T1.getTime();
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
// Clear forced values for final render
|
|
1331
|
+
forcedStateValues.clear();
|
|
1332
|
+
resetStateTracking();
|
|
1333
|
+
silence();
|
|
1334
|
+
const appFinal = exampleMain();
|
|
1335
|
+
restore();
|
|
1336
|
+
await settle(); // Let async effects fire for final snapshot
|
|
1337
|
+
(globalThis as unknown as { Date: typeof Date }).Date = OrigDate;
|
|
1338
|
+
|
|
1339
|
+
if (!appFinal) {
|
|
1340
|
+
process.stderr.write('Failed to render final snapshot\n');
|
|
1341
|
+
process.exit(1);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const ctx: EmitContext = {
|
|
1345
|
+
skins: new Map(),
|
|
1346
|
+
styles: new Map(),
|
|
1347
|
+
declarations: [],
|
|
1348
|
+
skinIdx: 0,
|
|
1349
|
+
styleIdx: 0,
|
|
1350
|
+
labelIdx: 0,
|
|
1351
|
+
labelTexts: new Map(),
|
|
1352
|
+
rectIdx: 0,
|
|
1353
|
+
rectFills: new Map(),
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
let contents: string | null;
|
|
1357
|
+
|
|
1358
|
+
interface BranchOutput {
|
|
1359
|
+
value: unknown;
|
|
1360
|
+
tree: string;
|
|
1361
|
+
isBaseline: boolean;
|
|
1362
|
+
}
|
|
1363
|
+
const branchesBySlot = new Map<number, BranchOutput[]>();
|
|
1364
|
+
|
|
1365
|
+
if (branchInfos.length > 0) {
|
|
1366
|
+
|
|
1367
|
+
// Baseline tree (for each slot that has branches)
|
|
1368
|
+
emitConditionals = true;
|
|
1369
|
+
const baselineTree = emitNode(appFinal._root, ctx, ' ', dynamicLabels, stateDeps, skinDeps);
|
|
1370
|
+
|
|
1371
|
+
const affectedSlots = new Set(branchInfos.map((b) => b.stateSlot));
|
|
1372
|
+
for (const si of affectedSlots) {
|
|
1373
|
+
const slot = stateSlots[si];
|
|
1374
|
+
branchesBySlot.set(si, [
|
|
1375
|
+
{ value: slot?.initialValue, tree: baselineTree ?? ' /* empty */', isBaseline: true },
|
|
1376
|
+
]);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Perturbed trees
|
|
1380
|
+
for (const branch of branchInfos) {
|
|
1381
|
+
forcedStateValues.set(branch.stateSlot, branch.perturbedValue);
|
|
1382
|
+
resetStateTracking();
|
|
1383
|
+
(globalThis as unknown as { Date: unknown }).Date = class extends OrigDate {
|
|
1384
|
+
constructor() { super(); return T1; }
|
|
1385
|
+
static now() { return T1.getTime(); }
|
|
1386
|
+
};
|
|
1387
|
+
silence();
|
|
1388
|
+
const appBranch = exampleMain();
|
|
1389
|
+
restore();
|
|
1390
|
+
(globalThis as unknown as { Date: typeof Date }).Date = OrigDate;
|
|
1391
|
+
forcedStateValues.clear();
|
|
1392
|
+
|
|
1393
|
+
if (appBranch) {
|
|
1394
|
+
const tree = emitNode(appBranch._root, ctx, ' ', dynamicLabels, stateDeps, skinDeps);
|
|
1395
|
+
appBranch.unmount();
|
|
1396
|
+
branchesBySlot.get(branch.stateSlot)!.push({
|
|
1397
|
+
value: branch.perturbedValue,
|
|
1398
|
+
tree: tree ?? ' /* empty */',
|
|
1399
|
+
isBaseline: false,
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Build the contents with named Containers per branch
|
|
1405
|
+
const branchLines: string[] = [];
|
|
1406
|
+
for (const [si, branches] of branchesBySlot) {
|
|
1407
|
+
for (let bi = 0; bi < branches.length; bi++) {
|
|
1408
|
+
const branch = branches[bi]!;
|
|
1409
|
+
const name = `br_s${si}_v${bi}`;
|
|
1410
|
+
// For message-driven apps, start with loading visible (non-baseline)
|
|
1411
|
+
// since data arrives at runtime. Otherwise, baseline is visible.
|
|
1412
|
+
const visible = messageInfo ? !branch.isBaseline : branch.isBaseline;
|
|
1413
|
+
branchLines.push(
|
|
1414
|
+
` new Container(null, { name: "${name}", visible: ${visible}, left: 0, right: 0, top: 0, bottom: 0, contents: [\n${branch.tree}\n ] })`,
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
contents = branchLines.join(',\n');
|
|
1419
|
+
} else {
|
|
1420
|
+
// No structural branches — emit the single tree as before
|
|
1421
|
+
emitConditionals = true;
|
|
1422
|
+
contents = emitNode(appFinal._root, ctx, ' ', dynamicLabels, stateDeps, skinDeps);
|
|
1423
|
+
}
|
|
1424
|
+
appFinal.unmount();
|
|
1425
|
+
|
|
1426
|
+
// --- Analyze button handlers ---
|
|
1427
|
+
const buttonActions: { button: string; action: HandlerAction }[] = [];
|
|
1428
|
+
for (const binding of buttonBindings) {
|
|
1429
|
+
const action = analyzeButtonHandler(binding.handlerSource);
|
|
1430
|
+
if (action) {
|
|
1431
|
+
buttonActions.push({ button: binding.button, action });
|
|
1432
|
+
process.stderr.write(
|
|
1433
|
+
` Button "${binding.button}": ${action.type} s${action.slotIndex} by ${action.value}\n`,
|
|
1434
|
+
);
|
|
1435
|
+
} else if (listInfo && listSlotLabels.size > 0 && listScrollSlotIndex >= 0) {
|
|
1436
|
+
// Unrecognized handler but we have a list — emit scroll fallback for up/down
|
|
1437
|
+
if (binding.button === 'up') {
|
|
1438
|
+
buttonActions.push({ button: 'up', action: { type: 'decrement', slotIndex: listScrollSlotIndex, value: 1 } });
|
|
1439
|
+
process.stderr.write(` Button "up": list scroll up (fallback)\n`);
|
|
1440
|
+
} else if (binding.button === 'down') {
|
|
1441
|
+
buttonActions.push({ button: 'down', action: { type: 'increment', slotIndex: listScrollSlotIndex, value: 1 } });
|
|
1442
|
+
process.stderr.write(` Button "down": list scroll down (fallback)\n`);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// --- Emit piu output ---
|
|
1448
|
+
const hasTimeDeps = dynamicLabels.size > 0;
|
|
1449
|
+
const hasStateDeps = stateDeps.size > 0;
|
|
1450
|
+
const hasButtons = buttonActions.length > 0;
|
|
1451
|
+
const hasBranches = branchInfos.length > 0;
|
|
1452
|
+
const hasConditionals = conditionalChildren.length > 0;
|
|
1453
|
+
const hasSkinDeps = skinDeps.size > 0;
|
|
1454
|
+
const hasList = listInfo !== null && listSlotLabels.size > 0;
|
|
1455
|
+
const hasBehavior = hasTimeDeps || hasStateDeps || hasButtons || hasBranches || hasSkinDeps || hasList || hasConditionals;
|
|
1456
|
+
|
|
1457
|
+
const lines: string[] = [
|
|
1458
|
+
'// Auto-generated by react-pebble compile-to-piu (v3 with state reactivity)',
|
|
1459
|
+
`// Source: examples/${exampleName}.tsx rendered in Node mock mode.`,
|
|
1460
|
+
'//',
|
|
1461
|
+
'// Regenerate: npx tsx scripts/compile-to-piu.ts > pebble-spike/src/embeddedjs/main.js',
|
|
1462
|
+
'',
|
|
1463
|
+
'import {} from "piu/MC";',
|
|
1464
|
+
hasButtons ? 'import PebbleButton from "pebble/button";' : '',
|
|
1465
|
+
messageInfo ? 'import Message from "pebble/message";' : '',
|
|
1466
|
+
'',
|
|
1467
|
+
];
|
|
1468
|
+
|
|
1469
|
+
// Pre-register all skin deps so declarations are complete before output
|
|
1470
|
+
for (const [, dep] of skinDeps) {
|
|
1471
|
+
ensureSkin(ctx, dep.skins[0]!);
|
|
1472
|
+
ensureSkin(ctx, dep.skins[1]!);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
lines.push(
|
|
1476
|
+
...ctx.declarations,
|
|
1477
|
+
'',
|
|
1478
|
+
`const bgSkin = new Skin({ fill: "${colorToHex('black')}" });`,
|
|
1479
|
+
'',
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
if (hasTimeDeps) {
|
|
1483
|
+
lines.push('function pad(n) { return n < 10 ? "0" + n : "" + n; }');
|
|
1484
|
+
lines.push(
|
|
1485
|
+
'const days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];',
|
|
1486
|
+
'const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];',
|
|
1487
|
+
);
|
|
1488
|
+
lines.push('');
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (messageInfo) {
|
|
1492
|
+
// Runtime data: always declare _data for Message population
|
|
1493
|
+
lines.push('let _data = [];');
|
|
1494
|
+
lines.push('');
|
|
1495
|
+
}
|
|
1496
|
+
if (hasList && !messageInfo) {
|
|
1497
|
+
if (listInfo!.dataArrayObjects) {
|
|
1498
|
+
lines.push(`const _data = ${JSON.stringify(listInfo!.dataArrayObjects)};`);
|
|
1499
|
+
} else if (listInfo!.dataArrayValues) {
|
|
1500
|
+
lines.push(`const _data = ${JSON.stringify(listInfo!.dataArrayValues)};`);
|
|
1501
|
+
}
|
|
1502
|
+
lines.push('');
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
if (hasBehavior) {
|
|
1506
|
+
lines.push('class AppBehavior extends Behavior {');
|
|
1507
|
+
lines.push(' onCreate(app) {');
|
|
1508
|
+
// `c` is the container to search for named content nodes (sl*, sr*, ls*).
|
|
1509
|
+
// Without branches: c = app.first (the root Group container).
|
|
1510
|
+
// With branches: c = the baseline branch's inner Group container.
|
|
1511
|
+
// app → br_s0_v0 (branch) → Container (Group) → named nodes
|
|
1512
|
+
if (hasBranches) {
|
|
1513
|
+
// Find the baseline (initially visible) branch and navigate into its Group
|
|
1514
|
+
const baselineBranchIdx = branchesBySlot.values().next().value?.[0]?.isBaseline ? 0 : 1;
|
|
1515
|
+
const si = branchInfos[0]?.stateSlot ?? 0;
|
|
1516
|
+
lines.push(` const c = app.content("br_s${si}_v${baselineBranchIdx}").first;`);
|
|
1517
|
+
} else {
|
|
1518
|
+
lines.push(' const c = app.first;');
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// State fields (skip non-serializable values like Date objects)
|
|
1522
|
+
for (const slot of stateSlots) {
|
|
1523
|
+
const v = slot.initialValue;
|
|
1524
|
+
if (v instanceof Date || (typeof v === 'object' && v !== null && !(Array.isArray(v)))) {
|
|
1525
|
+
// Skip — internal hook state (e.g., useTime's Date) shouldn't be baked
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
lines.push(` this.s${slot.index} = ${JSON.stringify(v)};`);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Time-dependent label refs
|
|
1532
|
+
for (const [idx] of labelFormats) {
|
|
1533
|
+
lines.push(` this.tl${idx} = c.content("tl${idx}");`);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// State-dependent label refs
|
|
1537
|
+
for (const [idx] of stateDeps) {
|
|
1538
|
+
lines.push(` this.sl${idx} = c.content("sl${idx}");`);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Skin-reactive rect refs
|
|
1542
|
+
for (const [rIdx] of skinDeps) {
|
|
1543
|
+
lines.push(` this.sr${rIdx} = c.content("sr${rIdx}");`);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Branch container refs (for structural conditional rendering)
|
|
1547
|
+
for (const [si, branches] of branchesBySlot ?? []) {
|
|
1548
|
+
for (let bi = 0; bi < branches.length; bi++) {
|
|
1549
|
+
lines.push(` this.br_s${si}_v${bi} = app.content("br_s${si}_v${bi}");`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Per-subtree conditional refs
|
|
1554
|
+
for (const cc of conditionalChildren) {
|
|
1555
|
+
if (cc.type === 'removed') {
|
|
1556
|
+
const name = `cv_s${cc.stateSlot}_${cc.childIndex}`;
|
|
1557
|
+
lines.push(` this.${name} = c.content("${name}");`);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// List slot refs
|
|
1562
|
+
if (hasList) {
|
|
1563
|
+
const lpi = listInfo!.labelsPerItem;
|
|
1564
|
+
lines.push(` this._ls = [];`);
|
|
1565
|
+
for (let i = 0; i < listInfo!.visibleCount; i++) {
|
|
1566
|
+
if (lpi > 1) {
|
|
1567
|
+
// Multi-label: find the item Group, then its labels within
|
|
1568
|
+
lines.push(` const _g${i} = c.content("lg${i}");`);
|
|
1569
|
+
const refs = [];
|
|
1570
|
+
for (let j = 0; j < lpi; j++) {
|
|
1571
|
+
refs.push(`_g${i}.content("ls${i}_${j}")`);
|
|
1572
|
+
}
|
|
1573
|
+
lines.push(` this._ls.push([${refs.join(', ')}]);`);
|
|
1574
|
+
} else {
|
|
1575
|
+
lines.push(` this._ls.push(c.content("ls${i}"));`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
lines.push(' }');
|
|
1581
|
+
|
|
1582
|
+
// onDisplaying
|
|
1583
|
+
lines.push(' onDisplaying(app) {');
|
|
1584
|
+
if (!messageInfo) {
|
|
1585
|
+
lines.push(' this.refresh();');
|
|
1586
|
+
}
|
|
1587
|
+
if (hasTimeDeps) {
|
|
1588
|
+
lines.push(' app.interval = 1000;');
|
|
1589
|
+
lines.push(' app.start();');
|
|
1590
|
+
}
|
|
1591
|
+
if (hasButtons) {
|
|
1592
|
+
// Collect unique button names used by bindings
|
|
1593
|
+
const usedButtons = [...new Set(buttonBindings.map(b => b.button))];
|
|
1594
|
+
for (const btn of usedButtons) {
|
|
1595
|
+
lines.push(` new PebbleButton({ type: "${btn}", onPush: (pushed, name) => { if (pushed) this.onButton({ button: name }); } });`);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
if (messageInfo) {
|
|
1599
|
+
// Subscribe to phone→watch messages
|
|
1600
|
+
lines.push(` const self = this;`);
|
|
1601
|
+
lines.push(` new Message({`);
|
|
1602
|
+
lines.push(` keys: ["${messageInfo.key}"],`);
|
|
1603
|
+
lines.push(` onReadable() {`);
|
|
1604
|
+
lines.push(` const map = this.read();`);
|
|
1605
|
+
lines.push(` const json = map.get("${messageInfo.key}");`);
|
|
1606
|
+
lines.push(` if (json) {`);
|
|
1607
|
+
lines.push(` try {`);
|
|
1608
|
+
lines.push(` _data = JSON.parse(json);`);
|
|
1609
|
+
// Toggle: show loaded branches (v0 = baseline = loaded), hide loading (v1)
|
|
1610
|
+
for (const [si, branches] of branchesBySlot ?? []) {
|
|
1611
|
+
for (let bi = 0; bi < branches.length; bi++) {
|
|
1612
|
+
lines.push(` self.br_s${si}_v${bi}.visible = ${bi === 0 ? 'true' : 'false'};`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
// Update list labels from parsed data
|
|
1616
|
+
if (listInfo && listInfo.labelsPerItem > 1 && listInfo.propertyOrder) {
|
|
1617
|
+
const lpi = listInfo.labelsPerItem;
|
|
1618
|
+
const vc = listInfo.visibleCount;
|
|
1619
|
+
lines.push(` const c = self.br_s${[...branchesBySlot.keys()][0]}_v0.first;`);
|
|
1620
|
+
for (let i = 0; i < vc; i++) {
|
|
1621
|
+
lines.push(` const g${i} = c.content("lg${i}");`);
|
|
1622
|
+
for (let j = 0; j < lpi; j++) {
|
|
1623
|
+
const prop = listInfo.propertyOrder[j]!;
|
|
1624
|
+
lines.push(` if (g${i}) { const l = g${i}.content("ls${i}_${j}"); if (l) l.string = _data[${i}] ? _data[${i}].${prop} : ""; }`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
} else if (listInfo) {
|
|
1628
|
+
const vc = listInfo.visibleCount;
|
|
1629
|
+
lines.push(` const c = self.br_s${[...branchesBySlot.keys()][0]}_v0.first;`);
|
|
1630
|
+
for (let i = 0; i < vc; i++) {
|
|
1631
|
+
lines.push(` const l${i} = c.content("ls${i}"); if (l${i}) l${i}.string = _data[${i}] || "";`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
lines.push(` } catch (e) { console.log("Parse error: " + e.message); }`);
|
|
1635
|
+
lines.push(` }`);
|
|
1636
|
+
lines.push(` }`);
|
|
1637
|
+
lines.push(` });`);
|
|
1638
|
+
}
|
|
1639
|
+
lines.push(' }');
|
|
1640
|
+
|
|
1641
|
+
// onTimeChanged (only if time-dependent)
|
|
1642
|
+
if (hasTimeDeps) {
|
|
1643
|
+
lines.push(' onTimeChanged() {');
|
|
1644
|
+
lines.push(' this.refresh();');
|
|
1645
|
+
lines.push(' }');
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// onButton (only if buttons)
|
|
1649
|
+
if (hasButtons) {
|
|
1650
|
+
lines.push(' onButton(e) {');
|
|
1651
|
+
lines.push(' const name = e && e.button;');
|
|
1652
|
+
for (const { button, action } of buttonActions) {
|
|
1653
|
+
const cond = `name === "${button}"`;
|
|
1654
|
+
let stmt: string;
|
|
1655
|
+
// Override for list scroll: add clamping
|
|
1656
|
+
const isListScroll = hasList && action.slotIndex === listScrollSlotIndex;
|
|
1657
|
+
|
|
1658
|
+
switch (action.type) {
|
|
1659
|
+
case 'increment':
|
|
1660
|
+
if (isListScroll) {
|
|
1661
|
+
stmt = `this.s${action.slotIndex} = Math.min(_data.length - ${listInfo!.visibleCount}, this.s${action.slotIndex} + ${action.value}); this.refresh();`;
|
|
1662
|
+
} else {
|
|
1663
|
+
stmt = `this.s${action.slotIndex} += ${action.value}; this.refresh();`;
|
|
1664
|
+
}
|
|
1665
|
+
break;
|
|
1666
|
+
case 'decrement':
|
|
1667
|
+
if (isListScroll) {
|
|
1668
|
+
stmt = `this.s${action.slotIndex} = Math.max(0, this.s${action.slotIndex} - ${action.value}); this.refresh();`;
|
|
1669
|
+
} else {
|
|
1670
|
+
stmt = `this.s${action.slotIndex} -= ${action.value}; this.refresh();`;
|
|
1671
|
+
}
|
|
1672
|
+
break;
|
|
1673
|
+
case 'reset':
|
|
1674
|
+
stmt = `this.s${action.slotIndex} = ${action.value}; this.refresh();`;
|
|
1675
|
+
break;
|
|
1676
|
+
case 'toggle':
|
|
1677
|
+
stmt = `this.s${action.slotIndex} = !this.s${action.slotIndex}; this.refresh();`;
|
|
1678
|
+
break;
|
|
1679
|
+
case 'set_string':
|
|
1680
|
+
stmt = `this.s${action.slotIndex} = "${action.stringValue}"; this.refresh();`;
|
|
1681
|
+
break;
|
|
1682
|
+
}
|
|
1683
|
+
lines.push(` if (${cond}) { ${stmt} }`);
|
|
1684
|
+
}
|
|
1685
|
+
lines.push(' }');
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// refresh
|
|
1689
|
+
lines.push(' refresh() {');
|
|
1690
|
+
if (hasTimeDeps) {
|
|
1691
|
+
lines.push(' const d = new Date();');
|
|
1692
|
+
}
|
|
1693
|
+
for (const [idx, fmt] of labelFormats) {
|
|
1694
|
+
lines.push(` this.tl${idx}.string = ${emitTimeExpr(fmt)};`);
|
|
1695
|
+
}
|
|
1696
|
+
for (const [idx, dep] of stateDeps) {
|
|
1697
|
+
lines.push(` this.sl${idx}.string = ${dep.formatExpr};`);
|
|
1698
|
+
}
|
|
1699
|
+
// Skin reactivity — swap skins on state change
|
|
1700
|
+
for (const [rIdx, dep] of skinDeps) {
|
|
1701
|
+
const baseSkinVar = ensureSkin(ctx, dep.skins[0]!);
|
|
1702
|
+
const pertSkinVar = ensureSkin(ctx, dep.skins[1]!);
|
|
1703
|
+
const slot = stateSlots[dep.slotIndex];
|
|
1704
|
+
if (typeof slot?.initialValue === 'boolean') {
|
|
1705
|
+
lines.push(` this.sr${rIdx}.skin = this.s${dep.slotIndex} ? ${pertSkinVar} : ${baseSkinVar};`);
|
|
1706
|
+
} else {
|
|
1707
|
+
lines.push(` this.sr${rIdx}.skin = (this.s${dep.slotIndex} !== ${JSON.stringify(slot?.initialValue)}) ? ${pertSkinVar} : ${baseSkinVar};`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
// Per-subtree conditional visibility
|
|
1711
|
+
for (const cc of conditionalChildren) {
|
|
1712
|
+
if (cc.type === 'removed') {
|
|
1713
|
+
const name = `cv_s${cc.stateSlot}_${cc.childIndex}`;
|
|
1714
|
+
lines.push(` this.${name}.visible = !!this.s${cc.stateSlot};`);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
// Branch visibility toggles
|
|
1718
|
+
for (const [si, branches] of branchesBySlot ?? []) {
|
|
1719
|
+
for (let bi = 0; bi < branches.length; bi++) {
|
|
1720
|
+
const branch = branches[bi]!;
|
|
1721
|
+
const cond = `this.s${si} === ${JSON.stringify(branch.value)}`;
|
|
1722
|
+
lines.push(` this.br_s${si}_v${bi}.visible = (${cond});`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
// List slot scroll updates
|
|
1726
|
+
if (hasList && listScrollSlotIndex >= 0) {
|
|
1727
|
+
const lpi = listInfo!.labelsPerItem;
|
|
1728
|
+
lines.push(` const _start = this.s${listScrollSlotIndex};`);
|
|
1729
|
+
lines.push(` for (let _i = 0; _i < ${listInfo!.visibleCount}; _i++) {`);
|
|
1730
|
+
lines.push(` const _item = _data[_start + _i];`);
|
|
1731
|
+
if (lpi > 1 && listInfo!.propertyOrder) {
|
|
1732
|
+
// Multi-label: update each label from the corresponding property
|
|
1733
|
+
lines.push(` const _slot = this._ls[_i];`);
|
|
1734
|
+
lines.push(` if (_slot) {`);
|
|
1735
|
+
for (let j = 0; j < lpi; j++) {
|
|
1736
|
+
const prop = listInfo!.propertyOrder[j]!;
|
|
1737
|
+
lines.push(` _slot[${j}].string = _item ? _item.${prop} : "";`);
|
|
1738
|
+
lines.push(` _slot[${j}].visible = !!_item;`);
|
|
1739
|
+
}
|
|
1740
|
+
lines.push(` }`);
|
|
1741
|
+
} else {
|
|
1742
|
+
// Single-label: simple string update
|
|
1743
|
+
lines.push(` if (this._ls[_i]) {`);
|
|
1744
|
+
lines.push(` this._ls[_i].string = _item !== undefined ? "" + _item : "";`);
|
|
1745
|
+
lines.push(` this._ls[_i].visible = (_item !== undefined);`);
|
|
1746
|
+
lines.push(` }`);
|
|
1747
|
+
}
|
|
1748
|
+
lines.push(` }`);
|
|
1749
|
+
}
|
|
1750
|
+
lines.push(' }');
|
|
1751
|
+
|
|
1752
|
+
// refreshList — separate from refresh() for Message-driven data updates
|
|
1753
|
+
if (messageInfo && listInfo) {
|
|
1754
|
+
const lpi = listInfo!.labelsPerItem;
|
|
1755
|
+
lines.push(' refreshList() {');
|
|
1756
|
+
lines.push(` for (let i = 0; i < ${listInfo!.visibleCount}; i++) {`);
|
|
1757
|
+
lines.push(` const item = _data[i];`);
|
|
1758
|
+
if (lpi > 1 && listInfo!.propertyOrder) {
|
|
1759
|
+
lines.push(` const slot = this._ls[i];`);
|
|
1760
|
+
lines.push(` if (slot) {`);
|
|
1761
|
+
for (let j = 0; j < lpi; j++) {
|
|
1762
|
+
const prop = listInfo!.propertyOrder[j]!;
|
|
1763
|
+
lines.push(` slot[${j}].string = item ? item.${prop} : "";`);
|
|
1764
|
+
lines.push(` slot[${j}].visible = !!item;`);
|
|
1765
|
+
}
|
|
1766
|
+
lines.push(` }`);
|
|
1767
|
+
} else {
|
|
1768
|
+
lines.push(` if (this._ls[i]) {`);
|
|
1769
|
+
lines.push(` this._ls[i].string = item !== undefined ? "" + item : "";`);
|
|
1770
|
+
lines.push(` this._ls[i].visible = (item !== undefined);`);
|
|
1771
|
+
lines.push(` }`);
|
|
1772
|
+
}
|
|
1773
|
+
lines.push(` }`);
|
|
1774
|
+
lines.push(' }');
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
lines.push('}');
|
|
1778
|
+
lines.push('');
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
lines.push(
|
|
1782
|
+
'const WatchApp = Application.template(() => ({',
|
|
1783
|
+
' skin: bgSkin,',
|
|
1784
|
+
hasBehavior ? ' Behavior: AppBehavior,' : '',
|
|
1785
|
+
' contents: [',
|
|
1786
|
+
contents ?? ' /* empty */',
|
|
1787
|
+
' ],',
|
|
1788
|
+
'}));',
|
|
1789
|
+
'',
|
|
1790
|
+
'export default new WatchApp(null, { touchCount: 0, pixels: screen.width * 4 });',
|
|
1791
|
+
'',
|
|
1792
|
+
);
|
|
1793
|
+
|
|
1794
|
+
process.stdout.write(lines.join('\n'));
|