@typecad/ui 1.0.0-alpha.11 → 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 (135) hide show
  1. package/README.md +21 -1
  2. package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
  3. package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  4. package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
  5. package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
  6. package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
  7. package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
  8. package/assets/fonts/dejavu/LICENSE +187 -0
  9. package/assets/fonts/dejavu/README.md +49 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +74 -0
  12. package/dist/engine-index.js +2 -0
  13. package/dist/preview/build-program.d.ts +7 -1
  14. package/dist/preview/build-program.js +94 -15
  15. package/dist/preview/host-gfx.d.ts +3 -0
  16. package/dist/preview/host-gfx.js +30 -3
  17. package/dist/preview/host-ui-runtime.d.ts +115 -0
  18. package/dist/preview/host-ui-runtime.js +928 -46
  19. package/dist/ui-engine/block-layout.js +25 -11
  20. package/dist/ui-engine/color.d.ts +6 -0
  21. package/dist/ui-engine/color.js +80 -2
  22. package/dist/ui-engine/compat-report.d.ts +8 -0
  23. package/dist/ui-engine/compat-report.js +124 -0
  24. package/dist/ui-engine/css-imports.d.ts +4 -0
  25. package/dist/ui-engine/css-imports.js +70 -0
  26. package/dist/ui-engine/css-parser.js +58 -14
  27. package/dist/ui-engine/default-font.d.ts +25 -0
  28. package/dist/ui-engine/default-font.js +80 -0
  29. package/dist/ui-engine/font-assets.d.ts +2 -2
  30. package/dist/ui-engine/font-assets.js +22 -5
  31. package/dist/ui-engine/html-parser.d.ts +4 -0
  32. package/dist/ui-engine/html-parser.js +241 -26
  33. package/dist/ui-engine/image-assets.d.ts +8 -1
  34. package/dist/ui-engine/image-assets.js +35 -2
  35. package/dist/ui-engine/image-decode.d.ts +22 -0
  36. package/dist/ui-engine/image-decode.js +194 -0
  37. package/dist/ui-engine/inline-parser.js +1 -1
  38. package/dist/ui-engine/layout-engine.d.ts +19 -0
  39. package/dist/ui-engine/layout-engine.js +59 -3
  40. package/dist/ui-engine/model.d.ts +18 -0
  41. package/dist/ui-engine/model.js +76 -8
  42. package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
  43. package/dist/ui-engine/runtime-header/canvas-helpers.js +5 -2
  44. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
  45. package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
  46. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
  47. package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
  48. package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
  49. package/dist/ui-engine/runtime-header/keyboard.js +682 -380
  50. package/dist/ui-engine/runtime-header/node-draw-body.js +1656 -1377
  51. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
  52. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
  53. package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
  54. package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
  55. package/dist/ui-engine/runtime-header/structs.js +228 -216
  56. package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
  57. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
  58. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
  59. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
  60. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +79 -23
  61. package/dist/ui-engine/runtime-header/types-defines.js +110 -93
  62. package/dist/ui-engine/shadcn-kit.d.ts +1 -0
  63. package/dist/ui-engine/shadcn-kit.js +502 -0
  64. package/dist/ui-engine/style-resolver.d.ts +4 -0
  65. package/dist/ui-engine/style-resolver.js +58 -6
  66. package/dist/ui-engine/transpile-ui.js +12 -5
  67. package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
  68. package/dist/ui-engine/ua-stylesheet.js +119 -32
  69. package/dist/ui-engine/ui-lowering.js +37 -3
  70. package/dist/ui-engine/ui-registry.js +32 -4
  71. package/dist/ui-engine/yoga-layout.js +57 -52
  72. package/dist/wizard/config-writer.d.ts +49 -0
  73. package/dist/wizard/config-writer.js +366 -0
  74. package/dist/wizard/display-catalog.d.ts +75 -0
  75. package/dist/wizard/display-catalog.js +207 -0
  76. package/dist/wizard/index.d.ts +7 -0
  77. package/dist/wizard/index.js +10 -0
  78. package/dist/wizard/integration-wizard.d.ts +15 -0
  79. package/dist/wizard/integration-wizard.js +486 -0
  80. package/dist/wizard/prompts.d.ts +32 -0
  81. package/dist/wizard/prompts.js +95 -0
  82. package/dist/wizard/starter-ui.d.ts +1 -0
  83. package/dist/wizard/starter-ui.js +56 -0
  84. package/package.json +18 -5
  85. package/src/cli.ts +87 -0
  86. package/src/engine-index.ts +53 -51
  87. package/src/preview/build-program.ts +810 -734
  88. package/src/preview/host-gfx.ts +30 -3
  89. package/src/preview/host-ui-runtime.ts +4133 -3289
  90. package/src/ui-engine/block-layout.ts +24 -11
  91. package/src/ui-engine/color.ts +77 -2
  92. package/src/ui-engine/compat-report.ts +139 -0
  93. package/src/ui-engine/css-imports.ts +69 -0
  94. package/src/ui-engine/css-parser.ts +64 -15
  95. package/src/ui-engine/default-font.ts +95 -0
  96. package/src/ui-engine/font-assets.ts +545 -525
  97. package/src/ui-engine/html-parser.ts +615 -397
  98. package/src/ui-engine/image-assets.ts +37 -2
  99. package/src/ui-engine/image-decode.ts +244 -0
  100. package/src/ui-engine/inline-parser.ts +1 -1
  101. package/src/ui-engine/layout-engine.ts +61 -3
  102. package/src/ui-engine/model.ts +1317 -1230
  103. package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
  104. package/src/ui-engine/runtime-header/canvas-helpers.ts +5 -2
  105. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
  106. package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
  107. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
  108. package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
  109. package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
  110. package/src/ui-engine/runtime-header/keyboard.ts +689 -386
  111. package/src/ui-engine/runtime-header/node-draw-body.ts +1681 -1402
  112. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
  113. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
  114. package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
  115. package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
  116. package/src/ui-engine/runtime-header/structs.ts +234 -222
  117. package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
  118. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
  119. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
  120. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
  121. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +79 -23
  122. package/src/ui-engine/runtime-header/types-defines.ts +116 -99
  123. package/src/ui-engine/shadcn-kit.ts +503 -0
  124. package/src/ui-engine/style-resolver.ts +471 -416
  125. package/src/ui-engine/transpile-ui.ts +12 -5
  126. package/src/ui-engine/ua-stylesheet.ts +137 -31
  127. package/src/ui-engine/ui-lowering.ts +486 -452
  128. package/src/ui-engine/ui-registry.ts +556 -524
  129. package/src/ui-engine/yoga-layout.ts +307 -309
  130. package/src/wizard/config-writer.ts +404 -0
  131. package/src/wizard/display-catalog.ts +273 -0
  132. package/src/wizard/index.ts +38 -0
  133. package/src/wizard/integration-wizard.ts +619 -0
  134. package/src/wizard/prompts.ts +145 -0
  135. package/src/wizard/starter-ui.ts +58 -0
@@ -0,0 +1,38 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Public surface of the @typecad/ui integration wizard (`npx @typecad/ui
3
+ // --config`). Pure helpers (catalog, rendering, config splicing, starter
4
+ // template) are exported for tests and tooling; the interactive flow lives in
5
+ // integration-wizard.ts.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export {
9
+ DISPLAY_CATALOG,
10
+ TOUCH_CATALOG,
11
+ findPinConflicts,
12
+ } from "./display-catalog.js";
13
+ export type {
14
+ DisplayCatalogEntry,
15
+ TouchCatalogEntry,
16
+ WizardBus,
17
+ WizardTouchKind,
18
+ } from "./display-catalog.js";
19
+
20
+ export {
21
+ findCuttlefishConfig,
22
+ findSyntaxError,
23
+ readConfigSection,
24
+ readEntryPath,
25
+ renderDisplayBody,
26
+ renderDisplayProperty,
27
+ upsertDisplaySection,
28
+ } from "./config-writer.js";
29
+ export type {
30
+ ConfigRecord,
31
+ RenderDisplayOptions,
32
+ UpsertDisplayResult,
33
+ } from "./config-writer.js";
34
+
35
+ export { renderStarterUi } from "./starter-ui.js";
36
+
37
+ export { runIntegrationWizard } from "./integration-wizard.js";
38
+ export type { WizardRunResult, WizardStreams } from "./integration-wizard.js";
@@ -0,0 +1,619 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/ui integration wizard — `npx @typecad/ui --config`.
3
+ //
4
+ // Walks the user through wiring a display (and optional touch controller)
5
+ // into their project's cuttlefish.config.ts: pick a display, answer bus/pin/
6
+ // speed questions with hardware-aware defaults, then splice the resulting
7
+ // `display` section into the config without touching any other section.
8
+ // Offers to create a starter .ui entry file when none exists and prints the
9
+ // next build/flash/library steps at the end.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import chalk from "chalk";
15
+ import {
16
+ createPromptInterface,
17
+ promptConfirm,
18
+ promptInt,
19
+ promptSelect,
20
+ promptText,
21
+ type ReadlineInterface,
22
+ } from "./prompts.js";
23
+ import {
24
+ DISPLAY_CATALOG,
25
+ TOUCH_CATALOG,
26
+ findPinConflicts,
27
+ type DisplayCatalogEntry,
28
+ type TouchCatalogEntry,
29
+ } from "./display-catalog.js";
30
+ import {
31
+ findCuttlefishConfig,
32
+ findSyntaxError,
33
+ readConfigSection,
34
+ readEntryPath,
35
+ renderDisplayProperty,
36
+ upsertDisplaySection,
37
+ type ConfigRecord,
38
+ } from "./config-writer.js";
39
+ import { renderStarterUi } from "./starter-ui.js";
40
+
41
+ /** Keys the wizard manages — anything else found in an existing display
42
+ * section is carried over untouched instead of being silently dropped. */
43
+ const MANAGED_DISPLAY_KEYS: ReadonlySet<string> = new Set([
44
+ "profile", "driver", "bus", "cs", "dc", "rst", "address", "reset",
45
+ "backlight", "spiFrequency", "spiPins", "width", "height",
46
+ "nativeWidth", "nativeHeight", "colorFormat", "displayClass",
47
+ "rotation", "colorOrder", "invertDisplay", "antialias",
48
+ "themeCss", "themeClass", "touch",
49
+ ]);
50
+
51
+ function asRecord(value: unknown): ConfigRecord {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as ConfigRecord) : {};
53
+ }
54
+
55
+ function existingNumber(section: ConfigRecord, key: string): number | undefined {
56
+ const value = section[key];
57
+ return typeof value === "number" ? value : undefined;
58
+ }
59
+
60
+ function existingString(section: ConfigRecord, key: string): string | undefined {
61
+ const value = section[key];
62
+ return typeof value === "string" ? value : undefined;
63
+ }
64
+
65
+ /** Parse "15", "0x38" into a number; undefined when not numeric. */
66
+ function parseNumeric(text: string): number | undefined {
67
+ const trimmed = text.trim().toLowerCase();
68
+ if (/^0x[0-9a-f]+$/.test(trimmed)) return parseInt(trimmed, 16);
69
+ if (/^-?\d+$/.test(trimmed)) return parseInt(trimmed, 10);
70
+ return undefined;
71
+ }
72
+
73
+ interface Calibration {
74
+ xMin: number;
75
+ xMax: number;
76
+ yMin: number;
77
+ yMax: number;
78
+ }
79
+
80
+ function asCalibration(value: unknown): Calibration | undefined {
81
+ const record = asRecord(value);
82
+ const { xMin, xMax, yMin, yMax } = record;
83
+ if ([xMin, xMax, yMin, yMax].every((bound) => typeof bound === "number")) {
84
+ return { xMin: xMin as number, xMax: xMax as number, yMin: yMin as number, yMax: yMax as number };
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ /** Ask the four calibration bounds, prefilled with `defaults`. */
90
+ async function promptCalibration(rl: ReadlineInterface, defaults: Calibration): Promise<Calibration> {
91
+ return {
92
+ xMin: (await promptInt(rl, " xMin (raw at left edge)", { defaultValue: defaults.xMin }))!,
93
+ xMax: (await promptInt(rl, " xMax (raw at right edge)", { defaultValue: defaults.xMax }))!,
94
+ yMin: (await promptInt(rl, " yMin (raw at top edge)", { defaultValue: defaults.yMin }))!,
95
+ yMax: (await promptInt(rl, " yMax (raw at bottom edge)", { defaultValue: defaults.yMax }))!,
96
+ };
97
+ }
98
+
99
+ /** SPI wiring answers, copied into the display record in a fixed key order. */
100
+ interface SpiWiring {
101
+ cs: number;
102
+ dc: number;
103
+ rst: number;
104
+ backlight?: number;
105
+ spiFrequency: number;
106
+ spiPins?: { mosi: number; sck: number; miso: number };
107
+ }
108
+
109
+ async function promptSpiWiring(
110
+ rl: ReadlineInterface,
111
+ display: DisplayCatalogEntry,
112
+ existing: ConfigRecord,
113
+ ): Promise<SpiWiring> {
114
+ const cs = (await promptInt(rl, "CS (chip select) GPIO", {
115
+ defaultValue: existingNumber(existing, "cs") ?? display.defaults.cs,
116
+ }))!;
117
+ const dc = (await promptInt(rl, "DC (data/command) GPIO", {
118
+ defaultValue: existingNumber(existing, "dc") ?? display.defaults.dc,
119
+ }))!;
120
+ const rst = (await promptInt(rl, "RST (reset) GPIO", {
121
+ defaultValue: existingNumber(existing, "rst") ?? display.defaults.rst,
122
+ }))!;
123
+
124
+ const backlightDefault = existingNumber(existing, "backlight");
125
+ const backlightHint = display.defaults.backlightProfileDefault !== undefined
126
+ ? `blank = profile default ${display.defaults.backlightProfileDefault}`
127
+ : "blank = not controlled";
128
+ const backlight = await promptInt(rl, `Backlight GPIO (${backlightHint})`, {
129
+ defaultValue: backlightDefault,
130
+ allowBlank: backlightDefault === undefined,
131
+ });
132
+
133
+ const defaultMhz =
134
+ (existingNumber(existing, "spiFrequency") ?? display.defaults.spiFrequency ?? 40_000_000) / 1_000_000;
135
+ const frequencyMhz = await promptText(rl, "SPI frequency in MHz", {
136
+ defaultValue: String(defaultMhz),
137
+ validate: (value) => {
138
+ const mhz = Number(value);
139
+ if (!Number.isFinite(mhz) || mhz <= 0 || mhz > 200) return "Enter a frequency between 1 and 200 MHz.";
140
+ return null;
141
+ },
142
+ });
143
+
144
+ let spiPins: SpiWiring["spiPins"];
145
+ const overridePins = await promptConfirm(
146
+ rl,
147
+ "Override the SPI data pins (SCK/MOSI/MISO)?",
148
+ existing.spiPins !== undefined,
149
+ );
150
+ if (overridePins) {
151
+ const current = asRecord(existing.spiPins);
152
+ const sck = (await promptInt(rl, " SCK GPIO", { defaultValue: existingNumber(current, "sck") ?? 18 }))!;
153
+ const mosi = (await promptInt(rl, " MOSI GPIO", { defaultValue: existingNumber(current, "mosi") ?? 23 }))!;
154
+ const miso = await promptInt(rl, " MISO GPIO (blank = write-only)", {
155
+ defaultValue: existingNumber(current, "miso"),
156
+ allowBlank: existingNumber(current, "miso") === undefined,
157
+ });
158
+ spiPins = { mosi, sck, miso: miso ?? -1 };
159
+ }
160
+
161
+ return {
162
+ cs,
163
+ dc,
164
+ rst,
165
+ ...(backlight !== undefined ? { backlight } : {}),
166
+ spiFrequency: Math.round(Number(frequencyMhz) * 1_000_000),
167
+ ...(spiPins ? { spiPins } : {}),
168
+ };
169
+ }
170
+
171
+ async function promptTouch(
172
+ rl: ReadlineInterface,
173
+ touchEntry: TouchCatalogEntry,
174
+ context: { existingTouch: ConfigRecord; panelWidth: number; panelHeight: number },
175
+ ): Promise<ConfigRecord> {
176
+ const { existingTouch, panelWidth, panelHeight } = context;
177
+ const touch: ConfigRecord = {};
178
+
179
+ if (touchEntry.kind === "adapter") {
180
+ touch.adapter = await promptText(rl, "Adapter file path (relative to project root)", {
181
+ defaultValue: existingString(existingTouch, "adapter") ?? "./touch-adapter",
182
+ });
183
+ const identity = { xMin: 0, xMax: panelWidth, yMin: 0, yMax: panelHeight };
184
+ const useIdentity = await promptConfirm(
185
+ rl,
186
+ "Use identity pixel calibration?",
187
+ existingTouch.calibration === undefined,
188
+ );
189
+ touch.calibration = useIdentity ? identity : await promptCalibration(rl, asCalibration(existingTouch.calibration) ?? identity);
190
+ return touch;
191
+ }
192
+
193
+ touch.library = touchEntry.id;
194
+
195
+ if (touchEntry.kind === "spi") {
196
+ touch.cs = (await promptInt(rl, "Touch CS GPIO", {
197
+ defaultValue: existingNumber(existingTouch, "cs") ?? touchEntry.defaults.cs,
198
+ }))!;
199
+ const irq = await promptInt(rl, "Touch IRQ GPIO (blank = none)", {
200
+ defaultValue: existingNumber(existingTouch, "irq"),
201
+ allowBlank: existingNumber(existingTouch, "irq") === undefined,
202
+ });
203
+ if (irq !== undefined) touch.irq = irq;
204
+ } else if (touchEntry.kind === "i2c") {
205
+ const addressText = await promptText(rl, "Touch I2C address", {
206
+ defaultValue: `0x${(existingNumber(existingTouch, "i2cAddress") ?? touchEntry.defaults.i2cAddress ?? 0x38).toString(16)}`,
207
+ validate: (value) => (parseNumeric(value) === undefined ? "Enter an address like 0x38 or 56." : null),
208
+ });
209
+ touch.i2cAddress = parseNumeric(addressText)!;
210
+ const defaultKhz =
211
+ (existingNumber(existingTouch, "i2cFrequency") ?? touchEntry.defaults.i2cFrequency ?? 400_000) / 1000;
212
+ const frequencyKhz = await promptText(rl, "I2C bus speed in kHz", {
213
+ defaultValue: String(defaultKhz),
214
+ validate: (value) => {
215
+ const khz = Number(value);
216
+ if (!Number.isFinite(khz) || khz <= 0 || khz > 3400) return "Enter a speed between 1 and 3400 kHz.";
217
+ return null;
218
+ },
219
+ });
220
+ touch.i2cFrequency = Math.round(Number(frequencyKhz) * 1000);
221
+ const irq = await promptInt(rl, "Touch IRQ GPIO (blank = none)", {
222
+ defaultValue: existingNumber(existingTouch, "irq"),
223
+ allowBlank: existingNumber(existingTouch, "irq") === undefined,
224
+ });
225
+ if (irq !== undefined) touch.irq = irq;
226
+ const resetPin = await promptInt(rl, "Touch reset GPIO (blank = none)", {
227
+ defaultValue: existingNumber(existingTouch, "resetPin"),
228
+ allowBlank: existingNumber(existingTouch, "resetPin") === undefined,
229
+ });
230
+ if (resetPin !== undefined) touch.resetPin = resetPin;
231
+ } else if (touchEntry.kind === "analog") {
232
+ const current = asRecord(existingTouch.analogPins);
233
+ const defaults = touchEntry.defaults.analogPins!;
234
+ touch.analogPins = {
235
+ xp: (await promptInt(rl, "XP GPIO", { defaultValue: existingNumber(current, "xp") ?? defaults.xp }))!,
236
+ yp: (await promptInt(rl, "YP GPIO", { defaultValue: existingNumber(current, "yp") ?? defaults.yp }))!,
237
+ xm: (await promptInt(rl, "XM GPIO", { defaultValue: existingNumber(current, "xm") ?? defaults.xm }))!,
238
+ ym: (await promptInt(rl, "YM GPIO", { defaultValue: existingNumber(current, "ym") ?? defaults.ym }))!,
239
+ rx: (await promptInt(rl, "Rx resistance (ohms)", { defaultValue: existingNumber(current, "rx") ?? defaults.rx }))!,
240
+ };
241
+ }
242
+
243
+ if (touchEntry.kind === "spi" || touchEntry.kind === "analog") {
244
+ // Resistive controllers report raw ADC values — typical defaults exist but
245
+ // per-panel calibration is expected sooner or later.
246
+ const typical = touchEntry.defaults.calibration ?? { xMin: 0, xMax: 4095, yMin: 0, yMax: 4095 };
247
+ const useTypical = await promptConfirm(
248
+ rl,
249
+ "Use typical raw-ADC calibration values?",
250
+ existingTouch.calibration === undefined,
251
+ );
252
+ touch.calibration = useTypical
253
+ ? typical
254
+ : await promptCalibration(rl, asCalibration(existingTouch.calibration) ?? typical);
255
+ } else {
256
+ // Capacitive controllers report pixel coordinates; default to the panel's
257
+ // native (unrotated) size — the runtime applies rotation after mapping.
258
+ const pixel = { xMin: 0, xMax: panelWidth, yMin: 0, yMax: panelHeight };
259
+ const useDefault = await promptConfirm(
260
+ rl,
261
+ `Use pixel calibration (0..${panelWidth} × 0..${panelHeight})?`,
262
+ existingTouch.calibration === undefined,
263
+ );
264
+ touch.calibration = useDefault
265
+ ? pixel
266
+ : await promptCalibration(rl, asCalibration(existingTouch.calibration) ?? pixel);
267
+ }
268
+
269
+ return touch;
270
+ }
271
+
272
+ export interface WizardRunResult {
273
+ exitCode: number;
274
+ /** Absolute path of the config that was written, when a write happened. */
275
+ configPath?: string;
276
+ }
277
+
278
+ /** Optional stream overrides so tests can drive the interactive flow. */
279
+ export interface WizardStreams {
280
+ input?: NodeJS.ReadableStream;
281
+ output?: NodeJS.WritableStream;
282
+ }
283
+
284
+ /**
285
+ * Run the interactive integration wizard. Returns a process exit code
286
+ * (0 = wrote (or deliberately declined), 1 = blocked before writing).
287
+ */
288
+ export async function runIntegrationWizard(
289
+ cwd: string = process.cwd(),
290
+ streams: WizardStreams = {},
291
+ ): Promise<WizardRunResult> {
292
+ const rl = createPromptInterface(streams.input, streams.output);
293
+ try {
294
+ console.log();
295
+ console.log(chalk.cyan("⤳ @typecad/ui") + chalk.dim(" — Display Integration Wizard"));
296
+ console.log(chalk.dim(" Configure a display + touch hardware for cuttlefish."));
297
+ console.log();
298
+
299
+ const configPath = findCuttlefishConfig(cwd);
300
+ if (!configPath) {
301
+ console.log(`${chalk.yellow("!")} No ${chalk.white("cuttlefish.config.ts")} found in ${chalk.dim(cwd)} (or any parent).`);
302
+ console.log();
303
+ console.log(" Create a cuttlefish project first, then re-run the wizard:");
304
+ console.log();
305
+ console.log(` ${chalk.cyan("npx @typecad/cuttlefish init")}`);
306
+ console.log(` ${chalk.cyan("npx @typecad/ui --config")}`);
307
+ console.log();
308
+ return { exitCode: 1 };
309
+ }
310
+
311
+ const sourceText = fs.readFileSync(configPath, "utf-8");
312
+ const existingDisplay = readConfigSection(sourceText, "display");
313
+ const existing = existingDisplay ?? {};
314
+ const existingTouch = asRecord(existing.touch);
315
+ const entryPath = readEntryPath(sourceText);
316
+
317
+ console.log(`${chalk.green("✓")} Config: ${chalk.white(path.relative(cwd, configPath))}`);
318
+ if (existingDisplay) {
319
+ console.log(chalk.dim(" An existing display section was found — answers below are prefilled from it."));
320
+ }
321
+ if (entryPath) {
322
+ const entryAbs = path.resolve(path.dirname(configPath), entryPath);
323
+ console.log(
324
+ `${chalk.dim(` Entry: ${entryPath} `)}${fs.existsSync(entryAbs) ? chalk.green("(exists)") : chalk.yellow("(missing — the wizard can create it)")}`,
325
+ );
326
+ }
327
+ console.log();
328
+
329
+ // --- 1. Display selection -------------------------------------------------
330
+ const displayId = await promptSelect(
331
+ rl,
332
+ "Which display are you integrating?",
333
+ DISPLAY_CATALOG.map((entry) => ({ label: entry.label, value: entry.id, hint: entry.hint })),
334
+ );
335
+ const displayEntry = DISPLAY_CATALOG.find((entry) => entry.id === displayId)!;
336
+
337
+ const record: ConfigRecord = {};
338
+ const comments: Record<string, string> = {};
339
+
340
+ if (displayEntry.profile) {
341
+ record.profile = displayEntry.profile;
342
+ }
343
+
344
+ // --- 2. Driver-specific basics ---------------------------------------------
345
+ let customBus: "spi" | "i2c" | undefined;
346
+ if (displayId === "custom") {
347
+ record.driver = await promptText(rl, "Driver name", {
348
+ defaultValue: existingString(existing, "driver") ?? "ili9341",
349
+ });
350
+ customBus = await promptSelect<"spi" | "i2c">(rl, "Bus", [
351
+ { label: "SPI", value: "spi", hint: "TFT-style panels" },
352
+ { label: "I2C", value: "i2c", hint: "small OLED/mono panels" },
353
+ ]);
354
+ record.width = (await promptInt(rl, "Panel width in px", {
355
+ defaultValue: existingNumber(existing, "width") ?? 320,
356
+ }))!;
357
+ record.height = (await promptInt(rl, "Panel height in px", {
358
+ defaultValue: existingNumber(existing, "height") ?? 240,
359
+ }))!;
360
+ const colorFormat = await promptSelect(rl, "Color format", [
361
+ { label: "rgb565 — 16-bit color TFT", value: "rgb565" },
362
+ { label: "rgb888 — 24-bit color", value: "rgb888" },
363
+ { label: "mono — 1-bit monochrome", value: "mono" },
364
+ ]);
365
+ record.colorFormat = colorFormat;
366
+ if (colorFormat === "mono") {
367
+ record.displayClass = await promptSelect(rl, "Panel class", [
368
+ { label: "oled — page-buffered OLED", value: "oled" },
369
+ { label: "eink — bistable e-paper", value: "eink" },
370
+ ]);
371
+ }
372
+ } else if (displayId === "sdl") {
373
+ record.driver = "sdl";
374
+ record.width = (await promptInt(rl, "Window width in px", {
375
+ defaultValue: existingNumber(existing, "width") ?? 320,
376
+ }))!;
377
+ record.height = (await promptInt(rl, "Window height in px", {
378
+ defaultValue: existingNumber(existing, "height") ?? 240,
379
+ }))!;
380
+ record.colorFormat = "rgb888";
381
+ }
382
+
383
+ // --- 3. Bus wiring ----------------------------------------------------------
384
+ const bus = displayId === "custom" ? customBus! : displayEntry.bus;
385
+
386
+ if (bus === "spi" && displayId !== "sdl") {
387
+ const wiring = await promptSpiWiring(rl, displayEntry, existing);
388
+ record.cs = wiring.cs;
389
+ record.dc = wiring.dc;
390
+ record.rst = wiring.rst;
391
+ if (wiring.backlight !== undefined) record.backlight = wiring.backlight;
392
+ record.spiFrequency = wiring.spiFrequency;
393
+ comments.spiFrequency = "Hz — lower this if the panel glitches";
394
+ if (wiring.spiPins) record.spiPins = wiring.spiPins;
395
+ } else if (bus === "i2c") {
396
+ record.bus = "I2C";
397
+ const addressText = await promptText(rl, "I2C address", {
398
+ defaultValue: `0x${(existingNumber(existing, "address") ?? displayEntry.defaults.address ?? 0x3c).toString(16)}`,
399
+ validate: (value) => (parseNumeric(value) === undefined ? "Enter an address like 0x3C or 60." : null),
400
+ });
401
+ record.address = parseNumeric(addressText)!;
402
+ const resetPin = await promptInt(rl, "Reset GPIO (blank = none)", {
403
+ defaultValue: existingNumber(existing, "reset"),
404
+ allowBlank: existingNumber(existing, "reset") === undefined,
405
+ });
406
+ if (resetPin !== undefined) record.reset = resetPin;
407
+ }
408
+
409
+ // --- 4. Orientation + rendering ---------------------------------------------
410
+ record.rotation = (await promptInt(rl, "Rotation (0=portrait, 1=landscape, 2=180°, 3=270°)", {
411
+ defaultValue: existingNumber(existing, "rotation") ?? displayEntry.defaults.rotation ?? 0,
412
+ min: 0,
413
+ max: 3,
414
+ }))!;
415
+
416
+ const antialias = await promptConfirm(
417
+ rl,
418
+ "Enable antialiased rendering (smoother text and edges, uses more RAM)?",
419
+ existing.antialias !== undefined ? existing.antialias === true : (displayEntry.defaults.antialias ?? false),
420
+ );
421
+ if (antialias) record.antialias = true;
422
+
423
+ const advanced = await promptConfirm(rl, "Configure advanced color options (color order / inversion)?", false);
424
+ if (advanced) {
425
+ const colorOrder = await promptSelect(rl, "Pixel color order", [
426
+ { label: "rgb — normal", value: "rgb" },
427
+ { label: "bgr — swapped (red and blue flipped)", value: "bgr" },
428
+ ]);
429
+ record.colorOrder = colorOrder;
430
+ if (await promptConfirm(rl, "Invert display colors?", false)) {
431
+ record.invertDisplay = true;
432
+ }
433
+ } else if (displayEntry.defaults.colorOrder === "bgr") {
434
+ // ST7796S modules expect BGR — carry the proven default like the demos.
435
+ record.colorOrder = "bgr";
436
+ }
437
+ if (displayEntry.defaults.invert === false && record.invertDisplay === undefined) {
438
+ // ST7796S panels ship non-inverted; pin it so a future profile change
439
+ // cannot silently flip colors (matches demos/demo-display).
440
+ record.invertDisplay = false;
441
+ }
442
+
443
+ // --- 5. Touch ----------------------------------------------------------------
444
+ let touchRecord: ConfigRecord | undefined;
445
+ let touchEntry: TouchCatalogEntry | undefined;
446
+
447
+ if (displayId === "sdl") {
448
+ touchRecord = {
449
+ library: "sdl",
450
+ calibration: { xMin: 0, xMax: record.width ?? 320, yMin: 0, yMax: record.height ?? 240 },
451
+ };
452
+ touchEntry = TOUCH_CATALOG.find((entry) => entry.id === "none")!;
453
+ console.log(`${chalk.cyan("?")} Touch: ${chalk.white("mouse via SDL (configured automatically)")}`);
454
+ } else {
455
+ const touchId = await promptSelect(
456
+ rl,
457
+ "Touch controller",
458
+ TOUCH_CATALOG.map((entry) => ({ label: entry.label, value: entry.id, hint: entry.hint })),
459
+ );
460
+ touchEntry = TOUCH_CATALOG.find((entry) => entry.id === touchId)!;
461
+ if (touchId !== "none") {
462
+ touchRecord = await promptTouch(rl, touchEntry, {
463
+ existingTouch,
464
+ panelWidth:
465
+ displayEntry.defaults.nativeWidth
466
+ ?? displayEntry.defaults.width
467
+ ?? (typeof record.width === "number" ? record.width : 320),
468
+ panelHeight:
469
+ displayEntry.defaults.nativeHeight
470
+ ?? displayEntry.defaults.height
471
+ ?? (typeof record.height === "number" ? record.height : 240),
472
+ });
473
+ }
474
+ }
475
+
476
+ // --- 6. Theme ------------------------------------------------------------------
477
+ const themeCss = await promptText(rl, "Theme CSS file path (blank = none)", {
478
+ defaultValue: existingString(existing, "themeCss") ?? "",
479
+ allowBlank: true,
480
+ });
481
+ if (themeCss) record.themeCss = themeCss;
482
+ const themeClass = await promptText(rl, "Theme class (e.g. dark; blank = none)", {
483
+ defaultValue: existingString(existing, "themeClass") ?? "",
484
+ allowBlank: true,
485
+ });
486
+ if (themeClass) record.themeClass = themeClass;
487
+
488
+ // Carry over display keys the wizard does not manage (scroll, scanlineSync,
489
+ // capabilities, …) so a re-run never silently drops user configuration.
490
+ const carried: string[] = [];
491
+ for (const [key, value] of Object.entries(existing)) {
492
+ if (MANAGED_DISPLAY_KEYS.has(key) || value === undefined) continue;
493
+ record[key] = value;
494
+ carried.push(key);
495
+ }
496
+
497
+ if (touchRecord) record.touch = touchRecord;
498
+
499
+ // --- 7. Pin conflict check -------------------------------------------------------
500
+ const conflicts = findPinConflicts(
501
+ {
502
+ cs: typeof record.cs === "number" ? record.cs : undefined,
503
+ dc: typeof record.dc === "number" ? record.dc : undefined,
504
+ rst: typeof record.rst === "number" ? record.rst : undefined,
505
+ backlight: typeof record.backlight === "number" ? record.backlight : undefined,
506
+ },
507
+ {
508
+ cs: existingNumber(touchRecord ?? {}, "cs"),
509
+ irq: existingNumber(touchRecord ?? {}, "irq"),
510
+ resetPin: existingNumber(touchRecord ?? {}, "resetPin"),
511
+ },
512
+ );
513
+ if (conflicts.length > 0) {
514
+ for (const conflict of conflicts) {
515
+ console.log(` ${chalk.yellow("!")} ${conflict}`);
516
+ }
517
+ if (!(await promptConfirm(rl, "Continue with these conflicting pins anyway?", false))) {
518
+ console.log(chalk.dim(" Aborted — nothing was written."));
519
+ return { exitCode: 1 };
520
+ }
521
+ }
522
+
523
+ // --- 8. Summary + write -------------------------------------------------------
524
+ console.log();
525
+ console.log(chalk.dim(" The following display section will be written to"));
526
+ console.log(chalk.dim(` ${path.relative(cwd, configPath)}:`));
527
+ console.log();
528
+ console.log(
529
+ renderDisplayProperty(record, { comments })
530
+ .split("\n")
531
+ .map((line) => ` ${line}`)
532
+ .join("\n"),
533
+ );
534
+ console.log();
535
+ if (displayEntry.wiringNote) {
536
+ console.log(` ${chalk.dim(displayEntry.wiringNote)}`);
537
+ }
538
+ if (carried.length > 0) {
539
+ console.log(
540
+ ` ${chalk.dim(`Kept existing display settings the wizard does not manage: ${carried.join(", ")}`)}`,
541
+ );
542
+ }
543
+ console.log();
544
+
545
+ if (!(await promptConfirm(rl, "Write this configuration?", true))) {
546
+ console.log(chalk.dim(" Aborted — nothing was written."));
547
+ return { exitCode: 0 };
548
+ }
549
+
550
+ const updated = upsertDisplaySection(sourceText, record, { comments });
551
+ const syntaxError = findSyntaxError(updated.text);
552
+ if (syntaxError) {
553
+ console.log(` ${chalk.red("✗")} Refusing to write — the edited config no longer parses: ${syntaxError}`);
554
+ return { exitCode: 1 };
555
+ }
556
+ fs.writeFileSync(configPath, updated.text, "utf-8");
557
+ console.log(
558
+ `${chalk.green("✓")} ${updated.mode === "replaced" ? "Updated" : "Added"} the display section in ${chalk.white(path.relative(cwd, configPath))}`,
559
+ );
560
+
561
+ // --- 9. Starter entry file ------------------------------------------------------
562
+ if (entryPath) {
563
+ const entryAbs = path.resolve(path.dirname(configPath), entryPath);
564
+ if (!fs.existsSync(entryAbs) && entryAbs.endsWith(".ui")) {
565
+ if (await promptConfirm(rl, `Create a starter ${entryPath}?`, true)) {
566
+ fs.mkdirSync(path.dirname(entryAbs), { recursive: true });
567
+ fs.writeFileSync(
568
+ entryAbs,
569
+ renderStarterUi(
570
+ typeof record.width === "number" ? record.width : 320,
571
+ typeof record.height === "number" ? record.height : 240,
572
+ ),
573
+ "utf-8",
574
+ );
575
+ console.log(`${chalk.green("✓")} Created ${chalk.white(entryPath)} — open it and start editing your UI.`);
576
+ }
577
+ }
578
+ }
579
+
580
+ // --- 10. Next steps ---------------------------------------------------------------
581
+ console.log();
582
+ console.log(chalk.cyan("Next steps"));
583
+ console.log();
584
+
585
+ const libraries: string[] = [];
586
+ if (displayEntry.arduinoLibraries) libraries.push(...displayEntry.arduinoLibraries);
587
+ if (touchEntry?.arduinoLibrary && touchRecord?.library) libraries.push(touchEntry.arduinoLibrary);
588
+ if (libraries.length > 0) {
589
+ console.log(" 1. Install the display/touch Arduino libraries (once per machine):");
590
+ console.log();
591
+ console.log(` ${chalk.cyan(`arduino-cli lib install ${libraries.map((lib) => `"${lib}"`).join(" ")}`)}`);
592
+ console.log();
593
+ }
594
+
595
+ const firstStep = libraries.length > 0 ? 2 : 1;
596
+ console.log(` ${firstStep}. Preview your UI on the desktop:`);
597
+ console.log();
598
+ console.log(` ${chalk.cyan("npx @typecad/cuttlefish preview")}`);
599
+ console.log();
600
+ console.log(` ${firstStep + 1}. Compile for your board:`);
601
+ console.log();
602
+ console.log(` ${chalk.cyan("npx @typecad/cuttlefish build --compile")}`);
603
+ console.log();
604
+ console.log(` ${firstStep + 2}. Flash it (set console.port in the config, or pass --port):`);
605
+ console.log();
606
+ console.log(` ${chalk.cyan("npx @typecad/cuttlefish build --compile --upload")}`);
607
+ console.log();
608
+
609
+ if (touchRecord && touchRecord.library === "XPT2046_Touchscreen") {
610
+ console.log(chalk.dim(" Touch tip: the written calibration uses typical raw-ADC values. If taps land"));
611
+ console.log(chalk.dim(" off-target, calibrate your panel and update display.touch.calibration."));
612
+ console.log();
613
+ }
614
+
615
+ return { exitCode: 0, configPath };
616
+ } finally {
617
+ rl.close();
618
+ }
619
+ }