@typecad/cuttlefish 1.0.0-alpha.12 → 1.0.0-alpha.13

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.
Files changed (54) hide show
  1. package/dist/add-preset.d.ts +4 -0
  2. package/dist/add-preset.js +74 -0
  3. package/dist/api/config.d.ts +0 -4
  4. package/dist/api/shared/display-adapters/sdl.js +1 -1
  5. package/dist/api/shared/display-profile.d.ts +11 -0
  6. package/dist/api/shared/display-profile.js +3 -0
  7. package/dist/api/shared/hal-op-ir.d.ts +19 -0
  8. package/dist/api/shared/toolchain-types.d.ts +0 -1
  9. package/dist/cli.js +15 -4
  10. package/dist/config-loader.d.ts +0 -2
  11. package/dist/config-loader.js +10 -5
  12. package/dist/config-schema.d.ts +87 -94
  13. package/dist/config-schema.js +0 -3
  14. package/dist/create/debug-artifacts.d.ts +20 -0
  15. package/dist/create/debug-artifacts.js +69 -0
  16. package/dist/create/index.d.ts +2 -0
  17. package/dist/create/index.js +1 -0
  18. package/dist/create/init-scaffold.d.ts +1 -0
  19. package/dist/create/init-scaffold.js +5 -0
  20. package/dist/create/init-templates.js +0 -2
  21. package/dist/emit/compliance/rules.js +18 -4
  22. package/dist/emit/emitters/function-emitter-impl.js +7 -1
  23. package/dist/emit/emitters/line-appender.js +6 -0
  24. package/dist/emit/emitters/ui-emitter.js +40 -15
  25. package/dist/emit/route-hal-op.js +55 -1
  26. package/dist/emit/statement-renderer.js +5 -2
  27. package/dist/ir/build-ir.js +22 -1
  28. package/dist/ir/expression-to-ir.js +17 -0
  29. package/dist/ir/hal/hal-emitter.js +23 -5
  30. package/dist/ir/hal/hal-plugins.js +11 -0
  31. package/dist/ir/pin-mode-validation.js +32 -9
  32. package/dist/ir/pin-state-tracking.d.ts +58 -0
  33. package/dist/ir/pin-state-tracking.js +182 -0
  34. package/dist/ir/program-analysis.d.ts +6 -0
  35. package/dist/ir/program-analysis.js +38 -0
  36. package/dist/ir/statement-to-ir.js +14 -0
  37. package/dist/ir/transformers/control-flow.js +29 -0
  38. package/dist/ir/transformers/ui-call-resolver.js +105 -1
  39. package/dist/ir/ui-element-auto-wire.js +7 -4
  40. package/dist/orchestrator/graph-builder.d.ts +4 -1
  41. package/dist/orchestrator/graph-builder.js +7 -1
  42. package/dist/preview/api-shared-shim.d.ts +1 -0
  43. package/dist/preview/api-shared-shim.js +7 -0
  44. package/dist/preview/client.js +220 -1
  45. package/dist/preview/server.js +154 -62
  46. package/dist/theme-tokens.d.ts +22 -0
  47. package/dist/theme-tokens.js +172 -0
  48. package/dist/transpile.js +35 -5
  49. package/dist/types.d.ts +5 -0
  50. package/dist/ui-hook.d.ts +7 -0
  51. package/dist/utils/cli.js +9 -0
  52. package/dist/utils/ui.d.ts +5 -0
  53. package/dist/utils/ui.js +7 -0
  54. package/package.json +7 -6
@@ -0,0 +1,58 @@
1
+ import type { ProgramIR } from "../api/index.js";
2
+ export type TrackedPinLevel = 'high' | 'low' | 'unknown';
3
+ /** C++ identifier for a pin's shadow state variable. */
4
+ export declare function pinShadowVarName(pin: number): string;
5
+ export declare function resetPinStateTracking(): void;
6
+ /** Record gpio.set_mode — OUTPUT* modes enable tracking, anything else ends it. */
7
+ export declare function notePinSetMode(pin: number, mode: string | null): void;
8
+ /** Record gpio.write — literal values set the level; expressions make it unknown. */
9
+ export declare function notePinWrite(pin: number, literalValue: number | boolean | null): void;
10
+ /** Record gpio.toggle — inverts a known level, keeps unknown as unknown. */
11
+ export declare function notePinToggle(pin: number): void;
12
+ /**
13
+ * Record PWM/tone output — the pin no longer has a digital level, so any
14
+ * tracked level goes unknown (reads must not fold to the pre-PWM literal).
15
+ * A later digital write/toggle restores digital tracking.
16
+ */
17
+ export declare function notePinAnalogOutput(pin: number): void;
18
+ /**
19
+ * Resolve a read on a pin at the current program point.
20
+ * Returns 'high'/'low' when the level folds to a constant, 'shadow' when a
21
+ * shadow variable must carry it, or null when the pin is not a tracked
22
+ * output pin (unconfigured or input mode → normal hardware read lowering).
23
+ */
24
+ export declare function resolveTrackedRead(pin: number): 'high' | 'low' | 'shadow' | null;
25
+ /** Whether write/toggle ops on this pin must also update its shadow variable. */
26
+ export declare function isShadowPin(pin: number): boolean;
27
+ /** Consume the shadow declarations owed by this build (pin + initializer).
28
+ * Clears the shadow set: emit reads the updatesShadow flags baked into the
29
+ * ops below, never this module's live state (each file's build resets the
30
+ * tracker, and all files finish building before any emit runs). */
31
+ export declare function takeShadowDeclarations(): {
32
+ pin: number;
33
+ initial: boolean;
34
+ }[];
35
+ /** Mark every gpio.write / gpio.toggle op in the program whose pin has a
36
+ * tracked shadow read, so the emitter appends the shadow-variable update.
37
+ * Runs once at the end of buildProgramIR, after the whole file is lowered —
38
+ * at that point the shadow set is final, so writes that were lowered BEFORE
39
+ * the first shadow read are marked too (their ops are already in the IR).
40
+ * This is what makes emit independent of tracker state across files. */
41
+ export declare function markShadowUpdatingOps(program: ProgramIR): void;
42
+ export type PinLevelSnapshot = Map<number, TrackedPinLevel>;
43
+ export declare function snapshotPinLevels(): PinLevelSnapshot;
44
+ export declare function restorePinLevels(snapshot: PinLevelSnapshot): void;
45
+ /**
46
+ * Merge two branch-end states into the live state: pins that agree keep
47
+ * their level; pins that differ (or exist in only one branch) become
48
+ * unknown. Pass the pre-branch state as one side when there is no else
49
+ * branch, so pins written in the taken branch invalidate.
50
+ */
51
+ export declare function mergePinLevels(a: PinLevelSnapshot, b: PinLevelSnapshot): void;
52
+ /** Invalidate all known levels (after loop/switch bodies, which may run any
53
+ * number of times). Shadow tracking is unaffected — it is runtime truth. */
54
+ export declare function invalidatePinLevels(): void;
55
+ /** Disable/enable constant folding around function and callback bodies. */
56
+ export declare function setPinFoldingEnabled(enabled: boolean): void;
57
+ /** Whether pin-level constant folding is currently allowed. */
58
+ export declare function isPinFoldingEnabled(): boolean;
@@ -0,0 +1,182 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Output Pin State Tracking
3
+ //
4
+ // Tracks the driven level of pins configured as OUTPUT so that
5
+ // OutputPin.read()/isHigh()/isLow() can be answered without a hardware pin
6
+ // read. Reading back an OUTPUT-only pin is not portable (Zephyr's
7
+ // gpio_pin_get reads the input latch, which is undefined for direction-only
8
+ // outputs), so the transpiler instead maintains the state itself:
9
+ //
10
+ // - When the level is statically known at the read site (asOutput(false)
11
+ // followed by literal high/low/write/toggle calls), the read folds to a
12
+ // compile-time constant.
13
+ // - When it is not (runtime-valued writes, loops, branches, function
14
+ // bodies), the read lowers to a shadow variable (`__tc_pin_state_<n>`)
15
+ // that every generated write/toggle on that pin keeps updated.
16
+ //
17
+ // The tracker is updated in program order during IR lowering (the same pass
18
+ // that resolves HAL method bodies), so the state seen at each read site is
19
+ // the state at that point in the source. Control-flow joins and loop bodies
20
+ // conservatively invalidate levels (see control-flow.ts hooks); function and
21
+ // callback bodies disable folding entirely and force the shadow form.
22
+ // ---------------------------------------------------------------------------
23
+ import { walkProgramIR, walkExpressions } from "./utils/walk-ir.js";
24
+ /** Pin numbers explicitly configured as OUTPUT via gpio.set_mode. */
25
+ const outputPins = new Set();
26
+ /** Last driven level per output pin, at the current program point. */
27
+ const pinLevels = new Map();
28
+ /** Output pins whose reads lowered to the shadow variable form. */
29
+ const shadowPins = new Set();
30
+ /** Initializer for each shadow variable: the statically-known level at the
31
+ * time the first shadow read was resolved, or `false` when unknown. */
32
+ const shadowInitial = new Map();
33
+ /** Folding is disabled inside function/callback bodies, where the top-level
34
+ * program-point state does not apply at runtime. */
35
+ let foldingEnabled = true;
36
+ /** C++ identifier for a pin's shadow state variable. */
37
+ export function pinShadowVarName(pin) {
38
+ return `__tc_pin_state_${pin}`;
39
+ }
40
+ export function resetPinStateTracking() {
41
+ outputPins.clear();
42
+ pinLevels.clear();
43
+ shadowPins.clear();
44
+ shadowInitial.clear();
45
+ foldingEnabled = true;
46
+ }
47
+ /** Record gpio.set_mode — OUTPUT* modes enable tracking, anything else ends it. */
48
+ export function notePinSetMode(pin, mode) {
49
+ const isOutput = mode !== null && /output/i.test(mode);
50
+ if (isOutput) {
51
+ outputPins.add(pin);
52
+ pinLevels.set(pin, 'unknown');
53
+ }
54
+ else {
55
+ outputPins.delete(pin);
56
+ pinLevels.delete(pin);
57
+ }
58
+ }
59
+ /** Record gpio.write — literal values set the level; expressions make it unknown. */
60
+ export function notePinWrite(pin, literalValue) {
61
+ if (!outputPins.has(pin))
62
+ return;
63
+ pinLevels.set(pin, literalValue === null ? 'unknown' : (literalValue ? 'high' : 'low'));
64
+ }
65
+ /** Record gpio.toggle — inverts a known level, keeps unknown as unknown. */
66
+ export function notePinToggle(pin) {
67
+ if (!outputPins.has(pin))
68
+ return;
69
+ const level = pinLevels.get(pin);
70
+ if (level === 'high')
71
+ pinLevels.set(pin, 'low');
72
+ else if (level === 'low')
73
+ pinLevels.set(pin, 'high');
74
+ else
75
+ pinLevels.set(pin, 'unknown');
76
+ }
77
+ /**
78
+ * Record PWM/tone output — the pin no longer has a digital level, so any
79
+ * tracked level goes unknown (reads must not fold to the pre-PWM literal).
80
+ * A later digital write/toggle restores digital tracking.
81
+ */
82
+ export function notePinAnalogOutput(pin) {
83
+ if (!outputPins.has(pin))
84
+ return;
85
+ pinLevels.set(pin, 'unknown');
86
+ }
87
+ /**
88
+ * Resolve a read on a pin at the current program point.
89
+ * Returns 'high'/'low' when the level folds to a constant, 'shadow' when a
90
+ * shadow variable must carry it, or null when the pin is not a tracked
91
+ * output pin (unconfigured or input mode → normal hardware read lowering).
92
+ */
93
+ export function resolveTrackedRead(pin) {
94
+ if (!outputPins.has(pin))
95
+ return null;
96
+ const level = pinLevels.get(pin);
97
+ if (foldingEnabled && (level === 'high' || level === 'low'))
98
+ return level;
99
+ shadowPins.add(pin);
100
+ if (!shadowInitial.has(pin))
101
+ shadowInitial.set(pin, level === 'high');
102
+ return 'shadow';
103
+ }
104
+ /** Whether write/toggle ops on this pin must also update its shadow variable. */
105
+ export function isShadowPin(pin) {
106
+ return shadowPins.has(pin);
107
+ }
108
+ /** Consume the shadow declarations owed by this build (pin + initializer).
109
+ * Clears the shadow set: emit reads the updatesShadow flags baked into the
110
+ * ops below, never this module's live state (each file's build resets the
111
+ * tracker, and all files finish building before any emit runs). */
112
+ export function takeShadowDeclarations() {
113
+ const decls = [...shadowPins].sort((a, b) => a - b).map(pin => ({
114
+ pin,
115
+ initial: shadowInitial.get(pin) ?? false,
116
+ }));
117
+ shadowPins.clear();
118
+ shadowInitial.clear();
119
+ return decls;
120
+ }
121
+ /** Mark every gpio.write / gpio.toggle op in the program whose pin has a
122
+ * tracked shadow read, so the emitter appends the shadow-variable update.
123
+ * Runs once at the end of buildProgramIR, after the whole file is lowered —
124
+ * at that point the shadow set is final, so writes that were lowered BEFORE
125
+ * the first shadow read are marked too (their ops are already in the IR).
126
+ * This is what makes emit independent of tracker state across files. */
127
+ export function markShadowUpdatingOps(program) {
128
+ if (shadowPins.size === 0)
129
+ return;
130
+ const markOp = (op) => {
131
+ const halOp = op;
132
+ if (!halOp)
133
+ return;
134
+ if ((halOp.operation === "gpio.write" || halOp.operation === "gpio.toggle") && isShadowPin(halOp.pin)) {
135
+ halOp.updatesShadow = true;
136
+ }
137
+ };
138
+ walkProgramIR(program, (stmt) => {
139
+ if (stmt.kind === "hal-op")
140
+ markOp(stmt.operation);
141
+ walkExpressions([stmt], (expr) => {
142
+ if (expr.kind === "hal-expr")
143
+ markOp(expr.operation);
144
+ });
145
+ });
146
+ }
147
+ export function snapshotPinLevels() {
148
+ return new Map(pinLevels);
149
+ }
150
+ export function restorePinLevels(snapshot) {
151
+ pinLevels.clear();
152
+ for (const [pin, level] of snapshot)
153
+ pinLevels.set(pin, level);
154
+ }
155
+ /**
156
+ * Merge two branch-end states into the live state: pins that agree keep
157
+ * their level; pins that differ (or exist in only one branch) become
158
+ * unknown. Pass the pre-branch state as one side when there is no else
159
+ * branch, so pins written in the taken branch invalidate.
160
+ */
161
+ export function mergePinLevels(a, b) {
162
+ const pins = new Set([...a.keys(), ...b.keys()]);
163
+ for (const pin of pins) {
164
+ const la = a.get(pin) ?? 'unknown';
165
+ const lb = b.get(pin) ?? 'unknown';
166
+ pinLevels.set(pin, la === lb ? la : 'unknown');
167
+ }
168
+ }
169
+ /** Invalidate all known levels (after loop/switch bodies, which may run any
170
+ * number of times). Shadow tracking is unaffected — it is runtime truth. */
171
+ export function invalidatePinLevels() {
172
+ for (const pin of outputPins)
173
+ pinLevels.set(pin, 'unknown');
174
+ }
175
+ /** Disable/enable constant folding around function and callback bodies. */
176
+ export function setPinFoldingEnabled(enabled) {
177
+ foldingEnabled = enabled;
178
+ }
179
+ /** Whether pin-level constant folding is currently allowed. */
180
+ export function isPinFoldingEnabled() {
181
+ return foldingEnabled;
182
+ }
@@ -19,6 +19,12 @@ export interface ProgramAnalysisResult {
19
19
  usesStringConversion: boolean;
20
20
  usesDateNow: boolean;
21
21
  usesMillis: boolean;
22
+ /** True when the program references millis()/micros() directly — WITHOUT the
23
+ * delay() conflation usesMillis carries (framework-avr needs delay to keep
24
+ * the native timing ISR alive, but frameworks whose delay() lowers straight
25
+ * to a native sleep — e.g. Zephyr's k_msleep — must not treat a delay-only
26
+ * program as a millis() consumer). */
27
+ usesWallClock: boolean;
22
28
  usesNullish: boolean;
23
29
  /** True when this file actually emits a cuttlefish_nullish/exists/is_nullish CALL
24
30
  * (e.g. from a `??` lowering), as opposed to just referencing the
@@ -61,6 +61,18 @@ function analyzeExpression(expr, result, strategy) {
61
61
  if (/\bmillis\s*\(/.test(expr.value)) {
62
62
  result.usesMillis = true;
63
63
  }
64
+ if (/\bmillis\s*\(/.test(expr.value) || /\bmicros\s*\(/.test(expr.value)) {
65
+ result.usesWallClock = true;
66
+ }
67
+ // Test-runner console helpers (@typecad/expect's preprocessor injects
68
+ // __tc_print/__tc_println calls into the source). Track them as polyfill
69
+ // helpers so frameworks can gate their definitions (and <cstdio>) on use.
70
+ if (expr.value.includes("__tc_println(")) {
71
+ result.usedPolyfillHelpers.add("__tc_println");
72
+ }
73
+ if (expr.value.includes("__tc_print(")) {
74
+ result.usedPolyfillHelpers.add("__tc_print");
75
+ }
64
76
  if (expr.value.includes('cuttlefish_nullish(') || expr.value.includes('cuttlefish_exists(') || expr.value.includes('cuttlefish_is_nullish(')) {
65
77
  result.usesNullish = true;
66
78
  result.usesNullishHelper = true;
@@ -100,6 +112,12 @@ function analyzeExpression(expr, result, strategy) {
100
112
  if (/\bmillis\b/.test(expr.callee) || /\bdelay\b/.test(expr.callee) || /\bmicros\b/.test(expr.callee)) {
101
113
  result.usesMillis = true;
102
114
  }
115
+ if (/\bmillis\b/.test(expr.callee) || /\bmicros\b/.test(expr.callee)) {
116
+ result.usesWallClock = true;
117
+ }
118
+ if (expr.callee === "__tc_print" || expr.callee === "__tc_println") {
119
+ result.usedPolyfillHelpers.add(expr.callee);
120
+ }
103
121
  if (expr.callee === "Date.now" || expr.callee === "Date::now") {
104
122
  result.usesDateNow = true;
105
123
  }
@@ -373,10 +391,17 @@ function analyzeStatement(statement, result, strategy) {
373
391
  if (statement.callee === "millis" || statement.callee === "delay") {
374
392
  result.usesMillis = true;
375
393
  }
394
+ if (statement.callee === "millis") {
395
+ result.usesWallClock = true;
396
+ }
376
397
  // Awaited HAL wait markers become async state-machine poll states that
377
398
  // arm deadlines via currentTimeMillis().
378
399
  if (statement.callee === "__WIFI_WAIT__" || statement.callee === "__HTTP_WAIT__" || statement.callee === "__HAL_WAIT__") {
379
400
  result.usesMillis = true;
401
+ result.usesWallClock = true;
402
+ }
403
+ if (statement.callee === "__tc_print" || statement.callee === "__tc_println") {
404
+ result.usedPolyfillHelpers.add(statement.callee);
380
405
  }
381
406
  // Namespace-qualified polyfill entry points used as bare call statements
382
407
  // (e.g. `Timing.delay(5);`). The expression-level analyzer (case
@@ -694,6 +719,9 @@ function analyzeStatement(statement, result, strategy) {
694
719
  result.usesTiming = true;
695
720
  result.usesMillis = true;
696
721
  }
722
+ if (opName === "timing.millis" || opName === "timing.micros") {
723
+ result.usesWallClock = true;
724
+ }
697
725
  }
698
726
  // Scan raw C++ code in HAL ops for polyfill helper usage
699
727
  if (statement.operation && statement.operation.operation === "raw" && typeof statement.operation.code === "string") {
@@ -722,6 +750,15 @@ function analyzeStatement(statement, result, strategy) {
722
750
  result.usesTiming = true;
723
751
  result.usesMillis = true;
724
752
  }
753
+ if (/\b(millis|micros)\s*\(/.test(code)) {
754
+ result.usesWallClock = true;
755
+ }
756
+ if (code.includes("__tc_println(")) {
757
+ result.usedPolyfillHelpers.add("__tc_println");
758
+ }
759
+ if (code.includes("__tc_print(")) {
760
+ result.usedPolyfillHelpers.add("__tc_print");
761
+ }
725
762
  // Timer polyfill sizing — `Timing.setInterval(...)` / `Timing.setTimeout(...)`
726
763
  // are resolved by the HAL method resolver, which reads TimingClass's
727
764
  // rawCpp body and emits a raw hal-op whose code is e.g.
@@ -795,6 +832,7 @@ export function analyzeProgram(program, strategy) {
795
832
  usesStringConversion: false,
796
833
  usesDateNow: false,
797
834
  usesMillis: false,
835
+ usesWallClock: false,
798
836
  usesNullish: false,
799
837
  usesNullishHelper: false,
800
838
  usesNum: false,
@@ -3,6 +3,7 @@ import { isStringEnum } from "../api/shared/index.js";
3
3
  import { extractNodeComments, makeDiagnostic, makeSourceSpan } from "./ast-node-utils.js";
4
4
  import { isCompileTimeOnlyCallName, isCompileTimeOnlyClassName } from "./compile-time-only.js";
5
5
  import { TYPED_ARRAY_ELEMENT_MAP, hoistedNestedEnums, hoistedNestedInterfaces, hoistedNestedTypeAliases, nestedFunctionAliases, nestedClassAliases, mutableArrayVars, arrayLiteralSizes, activeEnumNames, activeStringEnumNames, resetFunctionScopeState } from "./build-ir-state.js";
6
+ import { isPinFoldingEnabled, setPinFoldingEnabled, invalidatePinLevels } from "./pin-state-tracking.js";
6
7
  import { getCurrentIrTypeScope, bindIrTypeScopeLocals } from "./symbol-types.js";
7
8
  import { renderExprAsText } from "./render-expr.js";
8
9
  import { expressionToIR } from "./expression-to-ir.js";
@@ -177,6 +178,15 @@ export function lowerStatementList(statements, fileName, sourceText, diagnostics
177
178
  const lowered = [];
178
179
  const nestedNames = [];
179
180
  const nestedClassNames = [];
181
+ // Function/method bodies run at an unmodeled time relative to top-level
182
+ // flow, so pin-state constant folding must be off inside them (reads fall
183
+ // back to the shadow variable), and any writes they contain invalidate the
184
+ // top-level levels afterwards. "" and "<top-level>" are the top-level
185
+ // markers; everything else names a function/method/lambda body.
186
+ const isFunctionBodyCtx = functionNameForDiagnostics !== "" && functionNameForDiagnostics !== "<top-level>";
187
+ const prevPinFolding = isPinFoldingEnabled();
188
+ if (isFunctionBodyCtx)
189
+ setPinFoldingEnabled(false);
180
190
  // Phase 1: Pre-scan for nested function declarations — register aliases only.
181
191
  // This ensures sibling functions can reference each other.
182
192
  for (const statement of statements) {
@@ -345,5 +355,9 @@ export function lowerStatementList(statements, fileName, sourceText, diagnostics
345
355
  for (const name of nestedClassNames) {
346
356
  nestedClassAliases.delete(name);
347
357
  }
358
+ if (isFunctionBodyCtx) {
359
+ setPinFoldingEnabled(prevPinFolding);
360
+ invalidatePinLevels();
361
+ }
348
362
  return lowered;
349
363
  }
@@ -6,6 +6,7 @@ import { parseCppType, renderCppType, isPointer, parsedIsVector, parsedElementSt
6
6
  import { expressionToIR } from "../expression-to-ir.js";
7
7
  import { lowerStatementList, expressionStatementToIR } from "../statement-to-ir.js";
8
8
  import { assignmentOperatorToString, updateLocalTypeFromAssignment, extractForInKeys } from "./variables.js";
9
+ import { snapshotPinLevels, restorePinLevels, mergePinLevels, invalidatePinLevels } from "../pin-state-tracking.js";
9
10
  // Monotonic counter for synthetic for...of destructure loop variables.
10
11
  let forOfDestructureCounter = 0;
11
12
  /**
@@ -184,6 +185,9 @@ export function lowerControlFlowStatement(statement, fileName, sourceText, diagn
184
185
  }];
185
186
  }
186
187
  if (ts.isWhileStatement(statement)) {
188
+ // Loop conditions/bodies may evaluate any number of times, so tracked
189
+ // pin levels from before the loop are not valid inside it.
190
+ invalidatePinLevels();
187
191
  const comments = extractNodeComments(statement, sourceText);
188
192
  const bodyStatements = lowerStatementList(ts.isBlock(statement.statement) ? statement.statement.statements : [statement.statement], fileName, sourceText, diagnostics, functionReturnTypes, localVariableTypes, functionNameForDiagnostics, typeAliases, pointerVars);
189
193
  return [{
@@ -196,12 +200,24 @@ export function lowerControlFlowStatement(statement, fileName, sourceText, diagn
196
200
  }];
197
201
  }
198
202
  if (ts.isIfStatement(statement)) {
203
+ // Branch merge: the level after the if must be the join of both branch
204
+ // ends (pins that disagree become unknown). The condition is lowered
205
+ // after the branches in this function but runs before them at runtime,
206
+ // so the pre-branch state is restored before the condition is evaluated
207
+ // and the merge is applied last.
208
+ const preBranchPinState = snapshotPinLevels();
199
209
  const comments = extractNodeComments(statement, sourceText);
200
210
  const thenStatements = lowerStatementList(ts.isBlock(statement.thenStatement) ? statement.thenStatement.statements : [statement.thenStatement], fileName, sourceText, diagnostics, functionReturnTypes, localVariableTypes, functionNameForDiagnostics, typeAliases, pointerVars);
211
+ const thenBranchPinState = snapshotPinLevels();
212
+ // Restore the pre-branch state before lowering the else branch and the
213
+ // condition — both run before the branches at runtime... the condition
214
+ // before them, the else on the path where the then branch never ran.
215
+ restorePinLevels(preBranchPinState);
201
216
  let elseBranch;
202
217
  if (statement.elseStatement) {
203
218
  elseBranch = lowerStatementList(ts.isBlock(statement.elseStatement) ? statement.elseStatement.statements : [statement.elseStatement], fileName, sourceText, diagnostics, functionReturnTypes, localVariableTypes, functionNameForDiagnostics, typeAliases, pointerVars);
204
219
  }
220
+ const elseBranchPinState = snapshotPinLevels();
205
221
  // Evaluate the condition. If it's a bare identifier that resolves to a
206
222
  // HAL alias (e.g. `if (bus2)` where bus2 = I2C0.take()), the alias is a
207
223
  // compile-time non-null reference — constant-fold to `true` so the emit
@@ -213,6 +229,10 @@ export function lowerControlFlowStatement(statement, fileName, sourceText, diagn
213
229
  else {
214
230
  condition = expressionToIR(statement.expression, sourceText, diagnostics, pointerVars);
215
231
  }
232
+ // Join the branch-end states into the live state. With no else branch,
233
+ // elseBranchPinState equals the pre-branch state, so pins written by the
234
+ // then branch invalidate unless the write agrees with the prior level.
235
+ mergePinLevels(thenBranchPinState, elseBranchPinState);
216
236
  return [{
217
237
  kind: "if",
218
238
  sourceSpan: makeSourceSpan(statement, fileName, sourceText),
@@ -235,6 +255,9 @@ export function lowerControlFlowStatement(statement, fileName, sourceText, diagn
235
255
  initializer = loweredExpr;
236
256
  }
237
257
  }
258
+ // Loop conditions/bodies may evaluate any number of times, so tracked
259
+ // pin levels from before the loop are not valid inside it.
260
+ invalidatePinLevels();
238
261
  const condition = statement.condition
239
262
  ? expressionToIR(statement.condition, sourceText, diagnostics, pointerVars)
240
263
  : undefined;
@@ -362,6 +385,9 @@ export function lowerControlFlowStatement(statement, fileName, sourceText, diagn
362
385
  }
363
386
  // Handle do...while loops
364
387
  if (ts.isDoStatement(statement)) {
388
+ // Loop bodies may run more than once; pre-loop pin levels are not valid
389
+ // inside them.
390
+ invalidatePinLevels();
365
391
  const comments = extractNodeComments(statement, sourceText);
366
392
  const bodyStatements = lowerStatementList(ts.isBlock(statement.statement) ? statement.statement.statements : [statement.statement], fileName, sourceText, diagnostics, functionReturnTypes, localVariableTypes, functionNameForDiagnostics, typeAliases, pointerVars);
367
393
  return [{
@@ -375,6 +401,9 @@ export function lowerControlFlowStatement(statement, fileName, sourceText, diagn
375
401
  }
376
402
  // Handle switch statements
377
403
  if (ts.isSwitchStatement(statement)) {
404
+ // Exactly one case runs at runtime, but all are lowered here; pin levels
405
+ // from case bodies must not leak past the switch.
406
+ invalidatePinLevels();
378
407
  const comments = extractNodeComments(statement, sourceText);
379
408
  const cases = [];
380
409
  for (const clause of statement.caseBlock.clauses) {
@@ -9,7 +9,7 @@
9
9
  // and is invoked from call-statement.ts after tryResolveHALMethod.
10
10
  // ---------------------------------------------------------------------------
11
11
  import ts from "typescript";
12
- import { makeSourceSpan } from "../ast-node-utils.js";
12
+ import { makeDiagnostic, makeSourceSpan } from "../ast-node-utils.js";
13
13
  import { halOpsToIR } from "./hal-emit-helpers.js";
14
14
  import { resolveMount } from "./ui-mount.js";
15
15
  import { emitSignalDecl, recordListBinding, getListBindingsCount, recordInputBinding, getInputBindingsCount, resetInputBindings } from "./ui-reactive.js";
@@ -184,6 +184,79 @@ function matchUIWindowCall(call) {
184
184
  return undefined;
185
185
  return call.expression.name.text;
186
186
  }
187
+ /** Detect `ui.drawer.<method>(...)` — same shape as ui.window. Returns the
188
+ * method name ("open" / "close") or undefined. */
189
+ function matchUIDrawerCall(call) {
190
+ if (!ts.isPropertyAccessExpression(call.expression))
191
+ return undefined;
192
+ const inner = call.expression.expression;
193
+ if (!ts.isPropertyAccessExpression(inner))
194
+ return undefined;
195
+ if (!ts.isIdentifier(inner.expression) || inner.expression.text !== "ui")
196
+ return undefined;
197
+ if (!ts.isIdentifier(inner.name) || inner.name.text !== "drawer")
198
+ return undefined;
199
+ return call.expression.name.text;
200
+ }
201
+ /** Detect ui.dialog.<method>(...) — same shape, resolves <dialog> ids. */
202
+ function matchUIDialogCall(call) {
203
+ if (!ts.isPropertyAccessExpression(call.expression))
204
+ return undefined;
205
+ const inner = call.expression.expression;
206
+ if (!ts.isPropertyAccessExpression(inner))
207
+ return undefined;
208
+ if (!ts.isIdentifier(inner.expression) || inner.expression.text !== "ui")
209
+ return undefined;
210
+ if (!ts.isIdentifier(inner.name) || inner.name.text !== "dialog")
211
+ return undefined;
212
+ return call.expression.name.text;
213
+ }
214
+ /** Detect ui.toast('<id>') — show a <toast>; auto-closes via duration. */
215
+ function matchUIToastCall(call) {
216
+ if (!ts.isPropertyAccessExpression(call.expression))
217
+ return false;
218
+ const inner = call.expression.expression;
219
+ if (!ts.isIdentifier(inner) || inner.text !== "ui")
220
+ return false;
221
+ return ts.isIdentifier(call.expression.name) && call.expression.name.text === "toast";
222
+ }
223
+ /** Resolve ui.drawer.open('<id>') / ui.drawer.close('<id>' | ), and the
224
+ * dialog/toast aliases. The id resolves at BUILD time to the node index of
225
+ * the <drawer>/<dialog>/<toast> element, emitting ui_drawer_open(N) /
226
+ * ui_drawer_close(N) (or close_all with no argument) — the preview
227
+ * evaluates the same call dynamically. */
228
+ function resolveDrawerCall(method, call, sourceText, diagnostics, kind = "drawer") {
229
+ const sourceSpan = makeSourceSpan(call, call.getSourceFile()?.fileName ?? "", sourceText);
230
+ if (method !== "open" && method !== "close") {
231
+ diagnostics.push(makeDiagnostic(sourceText, call.pos, `ui.${kind}.${method} is not supported — use open(id) or close(id?).`, "error", "UI_DRAWER_METHOD"));
232
+ return { kind: "block", sourceSpan, body: [] };
233
+ }
234
+ const arg = call.arguments[0];
235
+ const emit = (stmt) => ({
236
+ kind: "call",
237
+ callee: "__EMIT__",
238
+ args: [{ kind: "string", value: stmt }],
239
+ sourceSpan,
240
+ });
241
+ // close() with no id closes every open drawer.
242
+ if (method === "close" && arg === undefined)
243
+ return emit("ui_drawer_close_all();");
244
+ let idText;
245
+ if (arg && ts.isStringLiteral(arg))
246
+ idText = arg.text;
247
+ if (idText === undefined) {
248
+ diagnostics.push(makeDiagnostic(sourceText, call.pos, `ui.${kind}.${method} needs an id string literal (e.g. ui.${kind}.open('settings')).`, "error", "UI_DRAWER_ID"));
249
+ return { kind: "block", sourceSpan, body: [] };
250
+ }
251
+ const nodeIdx = resolveElementValue("screen", idText);
252
+ if (nodeIdx === undefined) {
253
+ diagnostics.push(makeDiagnostic(sourceText, call.pos, `ui.${kind}.${method}('${idText}'): no <${kind} id="${idText}"> in the mounted UI tree.`, "error", "UI_DRAWER_UNKNOWN_ID"));
254
+ return { kind: "block", sourceSpan, body: [] };
255
+ }
256
+ return emit(method === "open"
257
+ ? `ui_drawer_open(${nodeIdx});`
258
+ : `ui_drawer_close(${nodeIdx});`);
259
+ }
187
260
  /** Resolve ui.window.setTitle / ui.window.setIcon. Native SDL only; on hardware
188
261
  * these are silent no-ops (a desktop-only convenience, not an error). */
189
262
  function resolveWindowCall(method, call, sourceText, diagnostics) {
@@ -308,6 +381,37 @@ export function tryResolveUICall(call, fileName, sourceText, diagnostics, option
308
381
  const windowCall = matchUIWindowCall(call);
309
382
  if (windowCall)
310
383
  return resolveWindowCall(windowCall, call, sourceText, diagnostics);
384
+ // ui.drawer.open('id') / ui.drawer.close('id'|) — also a two-level chain.
385
+ const drawerCall = matchUIDrawerCall(call);
386
+ if (drawerCall)
387
+ return resolveDrawerCall(drawerCall, call, sourceText, diagnostics);
388
+ const dialogCall = matchUIDialogCall(call);
389
+ if (dialogCall)
390
+ return resolveDrawerCall(dialogCall, call, sourceText, diagnostics, "dialog");
391
+ if (matchUIToastCall(call)) {
392
+ // ui.toast('<id>') — show a <toast>; the duration attribute drives the
393
+ // auto-close. Same open lowering as a drawer.
394
+ const sourceSpan = makeSourceSpan(call, call.getSourceFile()?.fileName ?? "", sourceText);
395
+ const arg = call.arguments[0];
396
+ let idText;
397
+ if (arg && ts.isStringLiteral(arg))
398
+ idText = arg.text;
399
+ if (idText === undefined) {
400
+ diagnostics.push(makeDiagnostic(sourceText, call.pos, "ui.toast needs a toast id string literal (e.g. ui.toast('saved')).", "error", "UI_TOAST_ID"));
401
+ return { kind: "block", sourceSpan, body: [] };
402
+ }
403
+ const nodeIdx = resolveElementValue("screen", idText);
404
+ if (nodeIdx === undefined) {
405
+ diagnostics.push(makeDiagnostic(sourceText, call.pos, `ui.toast('${idText}'): no <toast id="${idText}"> in the mounted UI tree.`, "error", "UI_TOAST_UNKNOWN_ID"));
406
+ return { kind: "block", sourceSpan, body: [] };
407
+ }
408
+ return {
409
+ kind: "call",
410
+ sourceSpan,
411
+ callee: "__EMIT__",
412
+ args: [{ kind: "string", value: `ui_drawer_open(${nodeIdx});` }],
413
+ };
414
+ }
311
415
  if (!isUICall(call))
312
416
  return null;
313
417
  const method = call.expression.name.text;
@@ -99,12 +99,13 @@ function autoWireNode(treeName, node, nodeIndex) {
99
99
  fnName: `__ui_bindval_${nodeIndex}`,
100
100
  cppExpr: sig,
101
101
  });
102
- // Write: range drag / check toggle → signal.set(value).
102
+ // Write: range drag / check toggle → plain assignment (signals lower
103
+ // to plain device variables; .set() is author-facing syntax only).
103
104
  recordClickHandler({
104
105
  nodeIndex,
105
106
  kind: node.tag === "check" ? "click" : "rangechange",
106
107
  fnName: `__ui_bindval_cb_${nodeIndex}`,
107
- callbackBody: `${sig}.set(__ui_nodes[${nodeIndex}].value);`,
108
+ callbackBody: `${sig} = __ui_nodes[${nodeIndex}].value;`,
108
109
  });
109
110
  }
110
111
  }
@@ -149,12 +150,14 @@ function autoWireNode(treeName, node, nodeIndex) {
149
150
  ? node.options.map(o => o.text)
150
151
  : (node.text || "").split(",").map(s => s.trim()).filter(Boolean);
151
152
  const count = Math.max(options.length, 2);
152
- // Auto-wire: onClick cycles value 0..count-1
153
+ // Auto-wire: onClick opens the modal option list (ui_select_menu_open);
154
+ // tapping a row in the modal sets the value. Preview parity: the preview
155
+ // runtime opens its own overlay on tap instead of cycling.
153
156
  recordClickHandler({
154
157
  nodeIndex,
155
158
  kind: "click",
156
159
  fnName: `__ui_${node.id}_autoclick`,
157
- callbackBody: `__ui_nodes[${nodeIndex}].value = (__ui_nodes[${nodeIndex}].value + 1) % ${count};`,
160
+ callbackBody: `ui_select_menu_open(${nodeIndex});`,
158
161
  });
159
162
  // Auto-bind text to show the current option via snprintf if/else chain.
160
163
  // Cast size to size_t to satisfy -Wformat (snprintf's n param is size_t;
@@ -15,4 +15,7 @@ export declare function topologicalSortFiles(files: string[], dependencies: Map<
15
15
  * @param boardPackage When provided, `@typecad/board` imports resolve to this
16
16
  * board package (e.g. `'@typecad/board-arduino-uno'`).
17
17
  */
18
- export declare function collectTranspileGraph(entryFile: string, boardPackage?: string): TranspileGraphResult;
18
+ export declare function collectTranspileGraph(entryFile: string, boardPackage?: string, imageDecodeOpts?: {
19
+ maxW?: number;
20
+ maxH?: number;
21
+ }): Promise<TranspileGraphResult>;
@@ -68,7 +68,7 @@ export function topologicalSortFiles(files, dependencies) {
68
68
  * @param boardPackage When provided, `@typecad/board` imports resolve to this
69
69
  * board package (e.g. `'@typecad/board-arduino-uno'`).
70
70
  */
71
- export function collectTranspileGraph(entryFile, boardPackage) {
71
+ export async function collectTranspileGraph(entryFile, boardPackage, imageDecodeOpts) {
72
72
  const ordered = [];
73
73
  const pending = [path.resolve(entryFile)];
74
74
  const visited = new Set();
@@ -102,6 +102,10 @@ export function collectTranspileGraph(entryFile, boardPackage) {
102
102
  const parts = ui.splitUiFile(sourceText);
103
103
  // Register the template as a UI module at <file>.ui.html (synthetic path).
104
104
  const uiHtmlPath = filePath + ".html";
105
+ // Prime the image-conversion cache before the (synchronous) module
106
+ // load — <img src="*.png|jpg|ico|…"> decodes here, and the module's
107
+ // asset reader + natural-size layout pull from the cache.
108
+ await ui.warmUpImageDecoding(parts.html, path.dirname(filePath), imageDecodeOpts ?? {});
105
109
  ui.loadUIModuleFromText(uiHtmlPath, parts.html, parts.style, filePath);
106
110
  uiModules.add(uiHtmlPath);
107
111
  // Use the <script> as the TS source for import-graph walking. Inject an
@@ -189,6 +193,8 @@ export function collectTranspileGraph(entryFile, boardPackage) {
189
193
  // .ui.html modules: load into the UI registry, record the path, and don't
190
194
  // push onto `pending` (they are never parsed as TypeScript).
191
195
  if (resolved?.uiModule) {
196
+ const uiHtmlText = readText(resolved.sourcePath);
197
+ await requireUIHook().warmUpImageDecoding(uiHtmlText, path.dirname(resolved.sourcePath), imageDecodeOpts ?? {});
192
198
  requireUIHook().loadUIModule(resolved.sourcePath);
193
199
  uiModules.add(resolved.sourcePath);
194
200
  // Track the dependency edge so topological sort orders the importer
@@ -0,0 +1 @@
1
+ export { resolveScrollConfig } from "../api/shared/display-profile.js";