@typecad/ui 1.0.0-alpha.11 → 1.0.0-alpha.12
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/README.md +21 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +74 -0
- package/dist/ui-engine/model.js +1 -1
- package/dist/ui-engine/runtime-header/canvas-helpers.js +2 -2
- package/dist/ui-engine/runtime-header/node-draw-body.js +10 -1
- package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +1 -0
- package/dist/wizard/config-writer.d.ts +49 -0
- package/dist/wizard/config-writer.js +366 -0
- package/dist/wizard/display-catalog.d.ts +75 -0
- package/dist/wizard/display-catalog.js +207 -0
- package/dist/wizard/index.d.ts +7 -0
- package/dist/wizard/index.js +10 -0
- package/dist/wizard/integration-wizard.d.ts +15 -0
- package/dist/wizard/integration-wizard.js +486 -0
- package/dist/wizard/prompts.d.ts +32 -0
- package/dist/wizard/prompts.js +95 -0
- package/dist/wizard/starter-ui.d.ts +1 -0
- package/dist/wizard/starter-ui.js +56 -0
- package/package.json +11 -4
- package/src/cli.ts +87 -0
- package/src/ui-engine/model.ts +1 -1
- package/src/ui-engine/runtime-header/canvas-helpers.ts +2 -2
- package/src/ui-engine/runtime-header/node-draw-body.ts +10 -1
- package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +1 -0
- package/src/wizard/config-writer.ts +404 -0
- package/src/wizard/display-catalog.ts +273 -0
- package/src/wizard/index.ts +38 -0
- package/src/wizard/integration-wizard.ts +619 -0
- package/src/wizard/prompts.ts +145 -0
- package/src/wizard/starter-ui.ts +58 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Starter .ui file emitted by the integration wizard when the config's entry
|
|
3
|
+
// file does not exist yet. Uses only README-documented syntax: the Svelte-
|
|
4
|
+
// style single-file component shape, ui.mount(screen), a ui.signal, an
|
|
5
|
+
// on:click handler, and {signal} interpolation in the template.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
export function renderStarterUi(displayWidth = 320, displayHeight = 240) {
|
|
8
|
+
// Keep text sizes sane on small mono panels (128×64 OLEDs).
|
|
9
|
+
const compact = displayWidth <= 160 || displayHeight <= 160;
|
|
10
|
+
const titleSize = compact ? 12 : 22;
|
|
11
|
+
const countSize = compact ? 10 : 16;
|
|
12
|
+
const buttonSize = compact ? 10 : 16;
|
|
13
|
+
return [
|
|
14
|
+
"<script>",
|
|
15
|
+
" // Starter screen generated by `npx @typecad/ui --config`.",
|
|
16
|
+
" // Edit freely — this is ordinary cuttlefish source, not generated output.",
|
|
17
|
+
"",
|
|
18
|
+
" import { ui } from '@typecad/ui';",
|
|
19
|
+
"",
|
|
20
|
+
" ui.mount(screen);",
|
|
21
|
+
"",
|
|
22
|
+
" export const taps = ui.signal(0);",
|
|
23
|
+
"",
|
|
24
|
+
" export function onTap() {",
|
|
25
|
+
" taps.set(taps() + 1);",
|
|
26
|
+
" }",
|
|
27
|
+
"</script>",
|
|
28
|
+
"",
|
|
29
|
+
"<style>",
|
|
30
|
+
" screen {",
|
|
31
|
+
" background: #101418;",
|
|
32
|
+
" display: flex;",
|
|
33
|
+
" flex-direction: column;",
|
|
34
|
+
" align-items: center;",
|
|
35
|
+
" justify-content: center;",
|
|
36
|
+
" gap: 16px;",
|
|
37
|
+
" }",
|
|
38
|
+
` #title { color: #e8eef2; font-size: ${titleSize}px; }`,
|
|
39
|
+
` #count { color: #38bdf8; font-size: ${countSize}px; }`,
|
|
40
|
+
" #tap {",
|
|
41
|
+
` padding: 10px 24px;`,
|
|
42
|
+
" border-radius: 8px;",
|
|
43
|
+
" background: #1f6feb;",
|
|
44
|
+
" color: #ffffff;",
|
|
45
|
+
` font-size: ${buttonSize}px;`,
|
|
46
|
+
" }",
|
|
47
|
+
"</style>",
|
|
48
|
+
"",
|
|
49
|
+
"<screen>",
|
|
50
|
+
" <text id=\"title\">@typecad/ui is running</text>",
|
|
51
|
+
" <text id=\"count\">taps: {taps}</text>",
|
|
52
|
+
" <button id=\"tap\" on:click={onTap}>Tap me</button>",
|
|
53
|
+
"</screen>",
|
|
54
|
+
"",
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typecad/ui",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.12",
|
|
4
4
|
"description": "TypeCAD UI authoring library — HTML/CSS-driven graphics for microcontrollers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"typecad-ui": "dist/cli.js"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"types": "./dist/index.d.ts",
|
|
@@ -25,6 +28,10 @@
|
|
|
25
28
|
"./preview/*": {
|
|
26
29
|
"types": "./dist/preview/*.d.ts",
|
|
27
30
|
"default": "./dist/preview/*.js"
|
|
31
|
+
},
|
|
32
|
+
"./wizard": {
|
|
33
|
+
"types": "./dist/wizard/index.d.ts",
|
|
34
|
+
"default": "./dist/wizard/index.js"
|
|
28
35
|
}
|
|
29
36
|
},
|
|
30
37
|
"sideEffects": false,
|
|
@@ -32,13 +39,13 @@
|
|
|
32
39
|
"dist",
|
|
33
40
|
"src"
|
|
34
41
|
],
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"@typecad/cuttlefish": "1.0.0-alpha.11"
|
|
37
|
-
},
|
|
38
42
|
"dependencies": {
|
|
43
|
+
"@typecad/cuttlefish": "1.0.0-alpha.12",
|
|
44
|
+
"chalk": "^4.1.2",
|
|
39
45
|
"css-tree": "^3.2.1",
|
|
40
46
|
"linkedom": "^0.18.12",
|
|
41
47
|
"opentype.js": "^2.0.0",
|
|
48
|
+
"typescript": "^5.7.3",
|
|
42
49
|
"yoga-layout": "^3.2.1"
|
|
43
50
|
},
|
|
44
51
|
"scripts": {
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// @typecad/ui CLI — display integration wizard.
|
|
4
|
+
//
|
|
5
|
+
// npx @typecad/ui --config Run the interactive integration wizard
|
|
6
|
+
// npx @typecad/ui --help Show usage
|
|
7
|
+
// npx @typecad/ui --version Show the installed version
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import { runIntegrationWizard } from "./wizard/integration-wizard.js";
|
|
11
|
+
|
|
12
|
+
const USAGE = `
|
|
13
|
+
@typecad/ui — HTML/CSS-driven graphics for microcontrollers
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npx @typecad/ui --config Configure a display (+ touch) for your
|
|
17
|
+
cuttlefish project interactively. Writes the
|
|
18
|
+
\`display\` section of cuttlefish.config.ts.
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--config Run the integration wizard
|
|
22
|
+
--help, -h Show this help
|
|
23
|
+
--version, -v Print the installed @typecad/ui version
|
|
24
|
+
|
|
25
|
+
The wizard asks which display module you are using (ILI9341 / ST7796S SPI TFT,
|
|
26
|
+
SSD1309 I2C OLED, desktop simulator, or custom), then walks through bus pins,
|
|
27
|
+
SPI/I2C speed, rotation, and touch controller wiring with hardware-aware
|
|
28
|
+
defaults. It never touches the rest of your cuttlefish.config.ts.
|
|
29
|
+
|
|
30
|
+
Docs: https://github.com/justind000/typecode/tree/main/packages/ui
|
|
31
|
+
`.trim();
|
|
32
|
+
|
|
33
|
+
function printUsage(): void {
|
|
34
|
+
console.log(USAGE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main(argv: string[]): Promise<number> {
|
|
38
|
+
const args = argv.slice(2);
|
|
39
|
+
|
|
40
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
41
|
+
printUsage();
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
46
|
+
// Lazily read the version so importing this module stays side-effect free.
|
|
47
|
+
const { readFile } = await import("node:fs/promises");
|
|
48
|
+
const { fileURLToPath } = await import("node:url");
|
|
49
|
+
const { dirname, join } = await import("node:path");
|
|
50
|
+
try {
|
|
51
|
+
const packageJson = JSON.parse(
|
|
52
|
+
await readFile(join(dirname(dirname(fileURLToPath(import.meta.url))), "package.json"), "utf-8"),
|
|
53
|
+
) as { version?: string };
|
|
54
|
+
console.log(packageJson.version ?? "unknown");
|
|
55
|
+
return 0;
|
|
56
|
+
} catch {
|
|
57
|
+
console.log("unknown");
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (args.length === 0 || (args.length === 1 && (args[0] === "--config" || args[0] === "config"))) {
|
|
63
|
+
if (!process.stdin.isTTY) {
|
|
64
|
+
console.error("The integration wizard is interactive and needs a terminal.");
|
|
65
|
+
console.error("Run `npx @typecad/ui --config` from your project directory, or configure");
|
|
66
|
+
console.error("the `display` section of cuttlefish.config.ts manually:");
|
|
67
|
+
console.error("https://github.com/justind000/typecode/tree/main/packages/ui#display-configuration");
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
const result = await runIntegrationWizard(process.cwd());
|
|
71
|
+
return result.exitCode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.error(`Unknown option${args.length > 1 ? "s" : ""}: ${args.join(" ")}\n`);
|
|
75
|
+
printUsage();
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
main(process.argv).then(
|
|
80
|
+
(code) => {
|
|
81
|
+
process.exitCode = code;
|
|
82
|
+
},
|
|
83
|
+
(error) => {
|
|
84
|
+
console.error(error instanceof Error ? error.message : error);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
},
|
|
87
|
+
);
|
package/src/ui-engine/model.ts
CHANGED
|
@@ -661,7 +661,7 @@ function parseBoxShadow(style: CSSProperty, format: ColorFormat): ShadowSpec[] {
|
|
|
661
661
|
// Split into a numbers zone (offset/blur) and a color zone. The color
|
|
662
662
|
// zone starts at the first # hex, rgb(, rgba(, or color name. css-tree
|
|
663
663
|
// may strip spaces between values, so we can't rely on whitespace split.
|
|
664
|
-
const colorStart = part.search(/#|rgba?\(|\b(?:black|white|red|green|blue|gray|grey|yellow|orange|purple|pink|cyan|magenta|silver|gold|brown|tan|navy|teal|maroon|lime|olive|aqua|fuchsia|transparent)\b/i);
|
|
664
|
+
const colorStart = part.search(/#|rgba?\(|hsla?\(|\b(?:black|white|red|green|blue|gray|grey|yellow|orange|purple|pink|cyan|magenta|silver|gold|brown|tan|navy|teal|maroon|lime|olive|aqua|fuchsia|transparent)\b/i);
|
|
665
665
|
const numZone = colorStart >= 0 ? part.slice(0, colorStart) : part;
|
|
666
666
|
const colorZone = colorStart >= 0 ? part.slice(colorStart) : "";
|
|
667
667
|
|
|
@@ -29,9 +29,9 @@ static inline CuttlefishCanvas16* ui_create_canvas_best(int16_t w, int16_t h) {
|
|
|
29
29
|
if (!psram_logged) {
|
|
30
30
|
psram_logged = 1;
|
|
31
31
|
#if defined(ESP32) && defined(ARDUINO)
|
|
32
|
-
Serial.printf("[psram] canvas %dx%d allocated in PSRAM (free=%u)
|
|
32
|
+
Serial.printf("[psram] canvas %dx%d allocated in PSRAM (free=%u)\\n", w, h, ESP.getFreePsram());
|
|
33
33
|
#else
|
|
34
|
-
printf("[psram] canvas %dx%d allocated in PSRAM
|
|
34
|
+
printf("[psram] canvas %dx%d allocated in PSRAM\\n", w, h);
|
|
35
35
|
#endif
|
|
36
36
|
}
|
|
37
37
|
return c;
|
|
@@ -47,7 +47,16 @@ struct UINodeDrawCtx {
|
|
|
47
47
|
// Returns 1 when the node fully handled its own canvas push, decoration,
|
|
48
48
|
// coordinate restore, and dirty-clear (NODE_LIST). Returns 0 otherwise, in
|
|
49
49
|
// which case the caller is responsible for the post-switch epilogue.
|
|
50
|
-
|
|
50
|
+
//
|
|
51
|
+
// The ctx parameter is typed const void* (cast back to UINodeDrawCtx* below):
|
|
52
|
+
// the Arduino .ino preprocessor auto-inserts a forward declaration of every
|
|
53
|
+
// function near the top of the sketch, BEFORE this struct is defined, so a
|
|
54
|
+
// struct-typed parameter makes that generated prototype fail to compile
|
|
55
|
+
// ("'UINodeDrawCtx' does not name a type"). Primitive-only parameters keep
|
|
56
|
+
// the auto-generated prototype valid; call sites still pass &ctx, which
|
|
57
|
+
// converts implicitly to const void*.
|
|
58
|
+
static inline uint8_t ui_draw_node_body(int16_t i, const void* rawCtx) {
|
|
59
|
+
const UINodeDrawCtx* ctx = static_cast<const UINodeDrawCtx*>(rawCtx);
|
|
51
60
|
int16_t drawY = ctx->drawY;
|
|
52
61
|
UI_COLOR_T bColor = ctx->bColor;
|
|
53
62
|
UI_COLOR_T fillBg = ctx->fillBg;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// See docs/superpowers/specs/2026-07-12-split-runtime-header-design.md.
|
|
4
4
|
export function emitTouchKeyboardFwd(): string {
|
|
5
5
|
return `
|
|
6
|
+
#include <cstdlib> // abs() for drag-distance thresholds (self-sufficient slice)
|
|
6
7
|
// ── Touch hit-testing + click dispatch ─────────────────────────────────────
|
|
7
8
|
// Radio groups for mutual exclusion
|
|
8
9
|
struct UIRadioGroup {
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// cuttlefish.config.ts reader/writer for the @typecad/ui integration wizard.
|
|
3
|
+
//
|
|
4
|
+
// The build's config loader (packages/cuttlefish/src/config-loader.ts) is
|
|
5
|
+
// AST-based and deliberately never evaluates user code — only inline literals
|
|
6
|
+
// survive extraction. The writer here plays by the same rules: the wizard
|
|
7
|
+
// emits plain object literals and splices them into the config's default
|
|
8
|
+
// export object via the TypeScript AST, so every other section (and its
|
|
9
|
+
// comments) survives an update byte-for-byte.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import ts from "typescript";
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILENAME = "cuttlefish.config.ts";
|
|
17
|
+
|
|
18
|
+
/** Walk up from `startDir` looking for cuttlefish.config.ts. */
|
|
19
|
+
export function findCuttlefishConfig(startDir: string): string | undefined {
|
|
20
|
+
let dir = path.resolve(startDir);
|
|
21
|
+
for (;;) {
|
|
22
|
+
const candidate = path.join(dir, CONFIG_FILENAME);
|
|
23
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
const parent = path.dirname(dir);
|
|
27
|
+
if (parent === dir) return undefined;
|
|
28
|
+
dir = parent;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A plain record as extracted from a config object literal. */
|
|
33
|
+
export type ConfigRecord = Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// AST helpers (kept aligned with the config loader's extraction rules)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function unwrapTypeCast(node: ts.Expression): ts.Expression {
|
|
40
|
+
let curr = node;
|
|
41
|
+
for (;;) {
|
|
42
|
+
if (ts.isAsExpression(curr) || ts.isTypeAssertionExpression(curr) || ts.isParenthesizedExpression(curr)) {
|
|
43
|
+
curr = curr.expression;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const isSatisfies = (ts as unknown as { isSatisfiesExpression?: (n: ts.Node) => boolean }).isSatisfiesExpression;
|
|
47
|
+
if (typeof isSatisfies === "function" && isSatisfies(curr)) {
|
|
48
|
+
curr = (curr as ts.SatisfiesExpression).expression;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
return curr;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function propertyKeyName(prop: ts.ObjectLiteralElement): string | undefined {
|
|
56
|
+
const name = (prop as { name?: ts.PropertyName }).name;
|
|
57
|
+
if (!name) return undefined;
|
|
58
|
+
return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getScalarValue(node: ts.Expression): string | number | boolean | undefined {
|
|
62
|
+
const unwrapped = unwrapTypeCast(node);
|
|
63
|
+
if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
|
|
64
|
+
return unwrapped.text;
|
|
65
|
+
}
|
|
66
|
+
if (ts.isNumericLiteral(unwrapped)) {
|
|
67
|
+
return Number(unwrapped.text);
|
|
68
|
+
}
|
|
69
|
+
if (ts.isPrefixUnaryExpression(unwrapped)
|
|
70
|
+
&& (unwrapped.operator === ts.SyntaxKind.MinusToken || unwrapped.operator === ts.SyntaxKind.PlusToken)
|
|
71
|
+
&& ts.isNumericLiteral(unwrapped.operand)) {
|
|
72
|
+
const magnitude = Number(unwrapped.operand.text);
|
|
73
|
+
return unwrapped.operator === ts.SyntaxKind.MinusToken ? -magnitude : magnitude;
|
|
74
|
+
}
|
|
75
|
+
if (unwrapped.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
76
|
+
if (unwrapped.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getStringLiteral(node: ts.Expression): string | undefined {
|
|
81
|
+
const unwrapped = unwrapTypeCast(node);
|
|
82
|
+
if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
|
|
83
|
+
return unwrapped.text;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Find the config's default-export object literal. Accepts the same shapes as
|
|
90
|
+
* the build's loader:
|
|
91
|
+
* 1. `export default { ... }`
|
|
92
|
+
* 2. `const config: CuttlefishConfig = { ... }; export default config;`
|
|
93
|
+
* with `as` / `satisfies` / parenthesized wrappers unwrapped.
|
|
94
|
+
*/
|
|
95
|
+
function findConfigObjectLiteral(sourceFile: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
|
|
96
|
+
const variableDecls = new Map<string, ts.VariableDeclaration>();
|
|
97
|
+
let defaultExportName: string | undefined;
|
|
98
|
+
let inlineDefaultObject: ts.ObjectLiteralExpression | undefined;
|
|
99
|
+
|
|
100
|
+
for (const stmt of sourceFile.statements) {
|
|
101
|
+
if (ts.isVariableStatement(stmt)) {
|
|
102
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
103
|
+
if (ts.isIdentifier(decl.name)) {
|
|
104
|
+
variableDecls.set(decl.name.text, decl);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
|
|
109
|
+
const expr = unwrapTypeCast(stmt.expression);
|
|
110
|
+
if (ts.isIdentifier(expr)) {
|
|
111
|
+
defaultExportName = expr.text;
|
|
112
|
+
} else if (ts.isObjectLiteralExpression(expr)) {
|
|
113
|
+
inlineDefaultObject = expr;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (inlineDefaultObject) return inlineDefaultObject;
|
|
119
|
+
if (defaultExportName) {
|
|
120
|
+
const decl = variableDecls.get(defaultExportName);
|
|
121
|
+
if (decl?.initializer) {
|
|
122
|
+
const init = unwrapTypeCast(decl.initializer);
|
|
123
|
+
if (ts.isObjectLiteralExpression(init)) return init;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseSource(sourceText: string): ts.SourceFile {
|
|
130
|
+
return ts.createSourceFile(CONFIG_FILENAME, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function objectLiteralToRecord(obj: ts.ObjectLiteralExpression): ConfigRecord {
|
|
134
|
+
const result: ConfigRecord = {};
|
|
135
|
+
for (const prop of obj.properties) {
|
|
136
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
137
|
+
const key = propertyKeyName(prop);
|
|
138
|
+
if (!key) continue;
|
|
139
|
+
const init = unwrapTypeCast(prop.initializer);
|
|
140
|
+
if (ts.isObjectLiteralExpression(init)) {
|
|
141
|
+
result[key] = objectLiteralToRecord(init);
|
|
142
|
+
} else if (ts.isArrayLiteralExpression(init)) {
|
|
143
|
+
const items: string[] = [];
|
|
144
|
+
for (const elem of init.elements) {
|
|
145
|
+
const s = getStringLiteral(elem);
|
|
146
|
+
if (s === undefined) break;
|
|
147
|
+
items.push(s);
|
|
148
|
+
}
|
|
149
|
+
if (items.length === init.elements.length && items.length > 0) result[key] = items;
|
|
150
|
+
} else {
|
|
151
|
+
const scalar = getScalarValue(init);
|
|
152
|
+
if (scalar !== undefined) result[key] = scalar;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read a top-level section of the config (e.g. `display`) as a plain record.
|
|
160
|
+
* Returns undefined when the section is missing or not an object literal.
|
|
161
|
+
*/
|
|
162
|
+
export function readConfigSection(sourceText: string, section: string): ConfigRecord | undefined {
|
|
163
|
+
const obj = findConfigObjectLiteral(parseSource(sourceText));
|
|
164
|
+
if (!obj) return undefined;
|
|
165
|
+
for (const prop of obj.properties) {
|
|
166
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
167
|
+
if (propertyKeyName(prop) !== section) continue;
|
|
168
|
+
const init = unwrapTypeCast(prop.initializer);
|
|
169
|
+
if (!ts.isObjectLiteralExpression(init)) return undefined;
|
|
170
|
+
return objectLiteralToRecord(init);
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Read the top-level `entry` scalar (the .ui/.ts entry path). */
|
|
176
|
+
export function readEntryPath(sourceText: string): string | undefined {
|
|
177
|
+
const obj = findConfigObjectLiteral(parseSource(sourceText));
|
|
178
|
+
if (!obj) return undefined;
|
|
179
|
+
for (const prop of obj.properties) {
|
|
180
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
181
|
+
if (propertyKeyName(prop) !== "entry") continue;
|
|
182
|
+
const value = getStringLiteral(unwrapTypeCast(prop.initializer));
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Rendering — build the `display` object-literal text
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/** Keys whose numeric values read best in hex (I2C addresses). */
|
|
193
|
+
const HEX_KEYS: ReadonlySet<string> = new Set(["address", "i2cAddress"]);
|
|
194
|
+
|
|
195
|
+
function escapeString(value: string): string {
|
|
196
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isPlainRecord(value: unknown): value is ConfigRecord {
|
|
200
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderValue(key: string, value: unknown): string {
|
|
204
|
+
if (typeof value === "number") {
|
|
205
|
+
if (HEX_KEYS.has(key) && Number.isInteger(value) && value >= 0) {
|
|
206
|
+
return `0x${value.toString(16)}`;
|
|
207
|
+
}
|
|
208
|
+
return String(value);
|
|
209
|
+
}
|
|
210
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
211
|
+
if (typeof value === "string") return `'${escapeString(value)}'`;
|
|
212
|
+
if (Array.isArray(value)) {
|
|
213
|
+
return `[${value.map((item) => renderValue(key, item)).join(", ")}]`;
|
|
214
|
+
}
|
|
215
|
+
if (isPlainRecord(value)) {
|
|
216
|
+
const inner = Object.entries(value)
|
|
217
|
+
.map(([nestedKey, nestedValue]) => `${nestedKey}: ${renderValue(nestedKey, nestedValue)}`)
|
|
218
|
+
.join(", ");
|
|
219
|
+
return `{ ${inner} }`;
|
|
220
|
+
}
|
|
221
|
+
throw new Error(`Cannot render display config value for key "${key}" (${typeof value}).`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface RenderDisplayOptions {
|
|
225
|
+
/** Indentation of the property lines inside the braces (default 2 spaces). */
|
|
226
|
+
indent?: string;
|
|
227
|
+
/** Indentation of the closing brace (default: none). */
|
|
228
|
+
closingIndent?: string;
|
|
229
|
+
/** Line ending used in the rendered block (default "\n"). */
|
|
230
|
+
eol?: string;
|
|
231
|
+
/** Optional `// comment` lines rendered above the matching key. */
|
|
232
|
+
comments?: Record<string, string>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Render the display section as an object-literal body (braces included).
|
|
237
|
+
* Key order follows insertion order of the record, matching the demos
|
|
238
|
+
* (profile/wiring first, touch last). Nested objects render inline.
|
|
239
|
+
*/
|
|
240
|
+
export function renderDisplayBody(display: ConfigRecord, options: RenderDisplayOptions = {}): string {
|
|
241
|
+
const indent = options.indent ?? " ";
|
|
242
|
+
const closingIndent = options.closingIndent ?? "";
|
|
243
|
+
const eol = options.eol ?? "\n";
|
|
244
|
+
const lines: string[] = [];
|
|
245
|
+
for (const [key, value] of Object.entries(display)) {
|
|
246
|
+
const comment = options.comments?.[key];
|
|
247
|
+
if (comment) {
|
|
248
|
+
lines.push(`${indent}// ${comment}`);
|
|
249
|
+
}
|
|
250
|
+
lines.push(`${indent}${key}: ${renderValue(key, value)},`);
|
|
251
|
+
}
|
|
252
|
+
return `{${eol}${lines.join(eol)}${eol}${closingIndent}}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Render the complete `display: { ... }` property text for a property that
|
|
256
|
+
* starts at `options.indent`. The returned text's first line is NOT indented
|
|
257
|
+
* — the caller positions the property start; body lines and the closing
|
|
258
|
+
* brace are indented relative to `options.indent`. */
|
|
259
|
+
export function renderDisplayProperty(display: ConfigRecord, options: RenderDisplayOptions = {}): string {
|
|
260
|
+
const indent = options.indent ?? "";
|
|
261
|
+
return `display: ${renderDisplayBody(display, {
|
|
262
|
+
...options,
|
|
263
|
+
indent: `${indent} `,
|
|
264
|
+
closingIndent: indent,
|
|
265
|
+
})}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Splicing — insert or replace the display section inside the config
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/** Leading whitespace of the line containing `pos` ("" when mid-line). */
|
|
273
|
+
function lineIndentAt(text: string, pos: number): string {
|
|
274
|
+
const lineStart = text.lastIndexOf("\n", pos - 1) + 1;
|
|
275
|
+
const before = text.slice(lineStart, pos);
|
|
276
|
+
return /^[ \t]*$/.test(before) ? before : "";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function detectLineEnding(text: string): string {
|
|
280
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Skip whitespace and comments from `pos`; true when the next char is ','. */
|
|
284
|
+
function spanStartsWithComma(text: string, pos: number, end: number): boolean {
|
|
285
|
+
let i = pos;
|
|
286
|
+
while (i < end) {
|
|
287
|
+
const ch = text[i]!;
|
|
288
|
+
if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
|
|
289
|
+
i++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
293
|
+
const newline = text.indexOf("\n", i);
|
|
294
|
+
if (newline === -1 || newline >= end) return false;
|
|
295
|
+
i = newline + 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
299
|
+
const close = text.indexOf("*/", i);
|
|
300
|
+
if (close === -1 || close + 2 > end) return false;
|
|
301
|
+
i = close + 2;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
return ch === ",";
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface UpsertDisplayResult {
|
|
310
|
+
text: string;
|
|
311
|
+
/** "replaced" — an existing display section was rewritten; "inserted" — none existed. */
|
|
312
|
+
mode: "replaced" | "inserted";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Insert or replace the `display` section in a cuttlefish.config.ts source
|
|
317
|
+
* string, preserving every other section and comment. Throws when the config
|
|
318
|
+
* has no recognizable default-export object literal.
|
|
319
|
+
*/
|
|
320
|
+
export function upsertDisplaySection(
|
|
321
|
+
sourceText: string,
|
|
322
|
+
display: ConfigRecord,
|
|
323
|
+
options: RenderDisplayOptions = {},
|
|
324
|
+
): UpsertDisplayResult {
|
|
325
|
+
const sourceFile = parseSource(sourceText);
|
|
326
|
+
const obj = findConfigObjectLiteral(sourceFile);
|
|
327
|
+
if (!obj) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
"cuttlefish.config.ts has no recognizable config object — expected `export default { ... }` or `const config = { ... }; export default config;`.",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const eol = options.eol ?? detectLineEnding(sourceText);
|
|
334
|
+
const existing = obj.properties.find(
|
|
335
|
+
(prop) => ts.isPropertyAssignment(prop) && propertyKeyName(prop) === "display",
|
|
336
|
+
) as ts.PropertyAssignment | undefined;
|
|
337
|
+
|
|
338
|
+
if (existing) {
|
|
339
|
+
const indent = lineIndentAt(sourceText, existing.getStart());
|
|
340
|
+
const body = renderDisplayBody(display, { ...options, indent: `${indent} `, closingIndent: indent, eol });
|
|
341
|
+
const init = unwrapTypeCast(existing.initializer);
|
|
342
|
+
if (ts.isObjectLiteralExpression(init)) {
|
|
343
|
+
// Replace just the initializer — `display:` name and its comments stay.
|
|
344
|
+
return {
|
|
345
|
+
text: sourceText.slice(0, init.getStart()) + body + sourceText.slice(init.getEnd()),
|
|
346
|
+
mode: "replaced",
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// `display: someExpression` — replace the whole property.
|
|
350
|
+
return {
|
|
351
|
+
text: sourceText.slice(0, existing.getStart()) + `display: ${body}` + sourceText.slice(existing.getEnd()),
|
|
352
|
+
mode: "replaced",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// No display section — insert before the object literal's closing brace.
|
|
357
|
+
const closeBrace = obj.getEnd() - 1;
|
|
358
|
+
const lastProp = obj.properties[obj.properties.length - 1];
|
|
359
|
+
const indent = lastProp
|
|
360
|
+
? lineIndentAt(sourceText, lastProp.getStart())
|
|
361
|
+
: lineIndentAt(sourceText, obj.getStart()) + " ";
|
|
362
|
+
const body = renderDisplayBody(display, { ...options, indent: `${indent} `, closingIndent: indent, eol });
|
|
363
|
+
const closingIndent = lineIndentAt(sourceText, closeBrace);
|
|
364
|
+
|
|
365
|
+
let prefix = sourceText;
|
|
366
|
+
if (lastProp && !spanStartsWithComma(sourceText, lastProp.getEnd(), closeBrace)) {
|
|
367
|
+
prefix = sourceText.slice(0, lastProp.getEnd()) + "," + sourceText.slice(lastProp.getEnd());
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Recompute the brace position if a comma was inserted before it.
|
|
371
|
+
const insertedComma = prefix !== sourceText;
|
|
372
|
+
const closeBraceFinal = closeBrace + (insertedComma ? 1 : 0);
|
|
373
|
+
return {
|
|
374
|
+
text:
|
|
375
|
+
prefix.slice(0, closeBraceFinal)
|
|
376
|
+
// Trailing comma keeps the multi-property style of the demos.
|
|
377
|
+
+ `${indent}display: ${body},${eol}${closingIndent}`
|
|
378
|
+
+ prefix.slice(closeBraceFinal),
|
|
379
|
+
mode: "inserted",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Syntactic sanity check for wizard output: returns the first error message
|
|
385
|
+
* when the text no longer parses as TypeScript, or null when it is clean.
|
|
386
|
+
* (Semantic checking is the build's job — this only guards the splice.)
|
|
387
|
+
*/
|
|
388
|
+
export function findSyntaxError(text: string): string | null {
|
|
389
|
+
const output = ts.transpileModule(text, {
|
|
390
|
+
reportDiagnostics: true,
|
|
391
|
+
compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext },
|
|
392
|
+
});
|
|
393
|
+
const errors = (output.diagnostics ?? []).filter(
|
|
394
|
+
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
|
|
395
|
+
);
|
|
396
|
+
if (errors.length === 0) return null;
|
|
397
|
+
const first = errors[0]!;
|
|
398
|
+
const message = ts.flattenDiagnosticMessageText(first.messageText, "\n");
|
|
399
|
+
if (typeof first.start === "number") {
|
|
400
|
+
const line = text.slice(0, first.start).split("\n").length;
|
|
401
|
+
return `line ${line}: ${message}`;
|
|
402
|
+
}
|
|
403
|
+
return message;
|
|
404
|
+
}
|