view-contracts 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dsl.js +2 -0
- package/dist/dsl.js.map +7 -0
- package/dist/preview.mjs +61 -62
- package/dist/preview.mjs.map +4 -4
- package/dist/view-contracts.bundle.mjs +232 -176
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/package.json +5 -4
- package/renderers/react/defaults/theme.yaml +29 -0
- package/renderers/react/defaults/tokens.yaml +61 -1
- package/renderers/react/lowering/lowerToJsx.ts +6 -6
- package/renderers/react/paths.ts +5 -1
- package/renderers/react/runtime/structural.css +38 -0
- package/renderers/react/style/foldLiterals.ts +14 -4
- package/renderers/swiftui/lowering/lowerToSwiftUI.ts +250 -31
- package/renderers/swiftui/lowering/modifiers.ts +90 -11
- package/renderers/swiftui/renderRuntime.ts +48 -0
- package/renderers/swiftui/renderTheme.ts +69 -3
- package/src/dsl/index.ts +16 -0
- package/src/dsl/screen.tsx +54 -0
- package/src/dsl/types.ts +68 -0
- package/renderers/react/runtime/theme.css +0 -57
|
@@ -28,6 +28,45 @@ function indent(level: number): string {
|
|
|
28
28
|
return ' '.repeat(level);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function tokenHexColorExpr(tokens: TokenIR, path: string, fallback: string): string {
|
|
32
|
+
const entry = tokens.entries.find((item) => item.path === path);
|
|
33
|
+
const value = entry?.value ?? fallback;
|
|
34
|
+
const match = value.match(/^#([0-9a-fA-F]{6})$/);
|
|
35
|
+
if (!match) return `Color(${JSON.stringify(value)})`;
|
|
36
|
+
const hex = match[1]!;
|
|
37
|
+
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
|
38
|
+
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
|
39
|
+
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
|
40
|
+
return `Color(red: ${r}, green: ${g}, blue: ${b})`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderGridChildBlock(
|
|
44
|
+
children: IrNode[],
|
|
45
|
+
level: number,
|
|
46
|
+
renderNested: NodeRenderer,
|
|
47
|
+
ctx: SwiftUIRenderContext,
|
|
48
|
+
itemScope: string | undefined,
|
|
49
|
+
): string {
|
|
50
|
+
const childFrameMod = `${indent(level + 2)}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)`;
|
|
51
|
+
const parts = children.map((child) => {
|
|
52
|
+
if (child.kind === 'map') {
|
|
53
|
+
const mapNode = child as IrMapNode;
|
|
54
|
+
const item = mapNode.item;
|
|
55
|
+
let body = renderNested(mapNode.body, level + 2, item);
|
|
56
|
+
body += `\n${childFrameMod}`;
|
|
57
|
+
const source = bindingExpr(mapNode.source, ctx, itemScope);
|
|
58
|
+
return `${indent(level + 2)}ForEach(${source}, id: \\.id) { ${item} in\n${body}\n${indent(level + 2)}}`;
|
|
59
|
+
}
|
|
60
|
+
let rendered = '';
|
|
61
|
+
if (child.kind === 'text') rendered = `${indent(level + 2)}Text(${JSON.stringify((child as IrTextNode).value)})`;
|
|
62
|
+
else if (child.kind === 'binding') rendered = `${indent(level + 2)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
|
|
63
|
+
else rendered = renderNested(child, level + 2, itemScope);
|
|
64
|
+
if (!rendered) return '';
|
|
65
|
+
return `${rendered}\n${childFrameMod}`;
|
|
66
|
+
}).filter(Boolean);
|
|
67
|
+
return parts.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
function propLiteral(props: Record<string, IrValue>, key: string): unknown | undefined {
|
|
32
71
|
const value = props[key];
|
|
33
72
|
if (value === null || value === undefined) return undefined;
|
|
@@ -103,9 +142,11 @@ function mergedModifiersForProps(
|
|
|
103
142
|
instanceLiterals,
|
|
104
143
|
});
|
|
105
144
|
const instanceModifiers = visualPropSetToModifiers(instanceOnly);
|
|
145
|
+
const paddingMods = instanceModifiers.filter((mod) => mod.startsWith('.padding'));
|
|
146
|
+
const otherInstanceMods = instanceModifiers.filter((mod) => !mod.startsWith('.padding'));
|
|
106
147
|
|
|
107
|
-
//
|
|
108
|
-
return [runtimeLookup, ...
|
|
148
|
+
// Padding must sit inside background/border from applyVcStyle.
|
|
149
|
+
return [...paddingMods, runtimeLookup, ...otherInstanceMods];
|
|
109
150
|
}
|
|
110
151
|
|
|
111
152
|
const merged = mergeVisualProps({
|
|
@@ -118,6 +159,87 @@ function mergedModifiersForProps(
|
|
|
118
159
|
return visualPropSetToModifiers(merged);
|
|
119
160
|
}
|
|
120
161
|
|
|
162
|
+
function emitPickerSelection(
|
|
163
|
+
props: Record<string, IrValue>,
|
|
164
|
+
ctx: SwiftUIRenderContext,
|
|
165
|
+
itemScope: string | undefined,
|
|
166
|
+
): string {
|
|
167
|
+
const options = props.options;
|
|
168
|
+
if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
|
|
169
|
+
const items = (options as IrLiteral).value as Array<{ value: string }>;
|
|
170
|
+
return `.constant(${JSON.stringify(items[0]?.value ?? '')})`;
|
|
171
|
+
}
|
|
172
|
+
if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'binding') {
|
|
173
|
+
const expr = bindingExpr(options as IrBinding, ctx, itemScope);
|
|
174
|
+
return `Binding(get: { ${expr}.first?.value ?? "" }, set: { _ in })`;
|
|
175
|
+
}
|
|
176
|
+
return '.constant("")';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function emitPickerOptions(
|
|
180
|
+
props: Record<string, IrValue>,
|
|
181
|
+
ctx: SwiftUIRenderContext,
|
|
182
|
+
itemScope: string | undefined,
|
|
183
|
+
level: number,
|
|
184
|
+
): string {
|
|
185
|
+
const options = props.options;
|
|
186
|
+
if (!options || typeof options !== 'object' || !('kind' in options)) {
|
|
187
|
+
return `${indent(level + 1)}Text("--").tag("")`;
|
|
188
|
+
}
|
|
189
|
+
if (options.kind === 'binding') {
|
|
190
|
+
const expr = bindingExpr(options as IrBinding, ctx, itemScope);
|
|
191
|
+
return `${indent(level + 1)}ForEach(${expr}, id: \\.value) { option in\n${indent(level + 2)}Text(option.label).tag(option.value)\n${indent(level + 1)}}`;
|
|
192
|
+
}
|
|
193
|
+
if (options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
|
|
194
|
+
const items = (options as IrLiteral).value as Array<{ label: string; value: string }>;
|
|
195
|
+
return items.map((opt) =>
|
|
196
|
+
`${indent(level + 1)}Text(${JSON.stringify(opt.label)}).tag(${JSON.stringify(opt.value)})`,
|
|
197
|
+
).join('\n');
|
|
198
|
+
}
|
|
199
|
+
return `${indent(level + 1)}Text("--").tag("")`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function leafExtraModifiers(elementName: string): string[] {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function emitMenuOptions(
|
|
207
|
+
props: Record<string, IrValue>,
|
|
208
|
+
ctx: SwiftUIRenderContext,
|
|
209
|
+
itemScope: string | undefined,
|
|
210
|
+
level: number,
|
|
211
|
+
): string {
|
|
212
|
+
const options = props.options;
|
|
213
|
+
if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'binding') {
|
|
214
|
+
const expr = bindingExpr(options as IrBinding, ctx, itemScope);
|
|
215
|
+
return `${indent(level + 1)}ForEach(${expr}, id: \\.value) { option in\n${indent(level + 2)}Button(option.label) {}\n${indent(level + 1)}}`;
|
|
216
|
+
}
|
|
217
|
+
if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
|
|
218
|
+
const items = (options as IrLiteral).value as Array<{ label: string; value: string }>;
|
|
219
|
+
return items.map((opt) =>
|
|
220
|
+
`${indent(level + 1)}Button(${JSON.stringify(opt.label)}) {}`,
|
|
221
|
+
).join('\n');
|
|
222
|
+
}
|
|
223
|
+
return `${indent(level + 1)}Button("--") {}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function emitSelectLabelText(
|
|
227
|
+
props: Record<string, IrValue>,
|
|
228
|
+
ctx: SwiftUIRenderContext,
|
|
229
|
+
itemScope: string | undefined,
|
|
230
|
+
): string {
|
|
231
|
+
const options = props.options;
|
|
232
|
+
if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'binding') {
|
|
233
|
+
const expr = bindingExpr(options as IrBinding, ctx, itemScope);
|
|
234
|
+
return `${expr}.first?.label ?? ""`;
|
|
235
|
+
}
|
|
236
|
+
if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
|
|
237
|
+
const items = (options as IrLiteral).value as Array<{ label: string }>;
|
|
238
|
+
return JSON.stringify(items[0]?.label ?? '');
|
|
239
|
+
}
|
|
240
|
+
return '""';
|
|
241
|
+
}
|
|
242
|
+
|
|
121
243
|
function bindingExpr(binding: IrBinding, ctx: SwiftUIRenderContext, itemScope?: string): string {
|
|
122
244
|
const root = binding.scope ?? itemScope ?? ctx.propsRoot ?? 'vm';
|
|
123
245
|
return binding.path ? `${root}.${binding.path}` : root;
|
|
@@ -185,25 +307,84 @@ function renderLeaf(
|
|
|
185
307
|
|
|
186
308
|
if (entry.leaf === 'textfield') {
|
|
187
309
|
const placeholder = propLiteral(node.props, 'placeholder');
|
|
188
|
-
const name = propLiteral(node.props, 'name');
|
|
189
310
|
const ph = typeof placeholder === 'string' ? JSON.stringify(placeholder) : '""';
|
|
190
|
-
const
|
|
191
|
-
const line = `${pad}TextField(
|
|
192
|
-
const
|
|
311
|
+
const mutedColor = tokenHexColorExpr(ctx.tokens, 'ui.text-muted', '#6b7280');
|
|
312
|
+
const line = `${pad}TextField("", text: .constant(""), prompt: Text(${ph}).foregroundStyle(${mutedColor}))`;
|
|
313
|
+
const allMods = [...modifiers, ...leafExtraModifiers(node.name)];
|
|
314
|
+
const modLines = allMods.map((mod) => `${pad}${mod}`);
|
|
193
315
|
return [line, ...modLines].join('\n');
|
|
194
316
|
}
|
|
195
317
|
|
|
196
318
|
if (entry.leaf === 'texteditor') {
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
319
|
+
const placeholder = propLiteral(node.props, 'placeholder');
|
|
320
|
+
const ph = typeof placeholder === 'string' ? JSON.stringify(placeholder) : '""';
|
|
321
|
+
const mutedColor = tokenHexColorExpr(ctx.tokens, 'ui.text-muted', '#6b7280');
|
|
322
|
+
const minHeightMod = modifiers.find((mod) => mod.startsWith('.frame(minHeight:'));
|
|
323
|
+
const minHeight = minHeightMod?.match(/minHeight: (\d+)/)?.[1] ?? '72';
|
|
324
|
+
|
|
325
|
+
// Split modifiers: text-related go on TextEditor, visual/layout go on ZStack
|
|
326
|
+
const textMods = modifiers.filter((mod) =>
|
|
327
|
+
mod.startsWith('.foregroundStyle(') || mod.startsWith('.font(') || mod.startsWith('.fontWeight(')
|
|
328
|
+
);
|
|
329
|
+
const containerMods = modifiers.filter((mod) =>
|
|
330
|
+
!mod.startsWith('.foregroundStyle(') && !mod.startsWith('.font(') &&
|
|
331
|
+
!mod.startsWith('.fontWeight(') && !mod.startsWith('.frame(minHeight:')
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const textModLines = textMods.map((mod) => `${pad} ${mod}`);
|
|
335
|
+
const containerModLines = containerMods.map((mod) => `${pad}${mod}`);
|
|
336
|
+
return [
|
|
337
|
+
`${pad}ZStack(alignment: .topLeading) {`,
|
|
338
|
+
`${pad} if true {`,
|
|
339
|
+
`${pad} Text(${ph})`,
|
|
340
|
+
`${pad} .font(.system(size: 14))`,
|
|
341
|
+
`${pad} .foregroundStyle(${mutedColor})`,
|
|
342
|
+
`${pad} .padding(.top, 8)`,
|
|
343
|
+
`${pad} .padding(.horizontal, 4)`,
|
|
344
|
+
`${pad} .allowsHitTesting(false)`,
|
|
345
|
+
`${pad} }`,
|
|
346
|
+
`${pad} TextEditor(text: .constant(""))`,
|
|
347
|
+
`${pad} .scrollContentBackground(.hidden)`,
|
|
348
|
+
...textModLines,
|
|
349
|
+
`${pad}}`,
|
|
350
|
+
`${pad}.frame(minHeight: ${minHeight})`,
|
|
351
|
+
...containerModLines,
|
|
352
|
+
].join('\n');
|
|
200
353
|
}
|
|
201
354
|
|
|
202
355
|
if (entry.leaf === 'picker') {
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
356
|
+
const textColor = tokenHexColorExpr(ctx.tokens, 'ui.text', '#111827');
|
|
357
|
+
const mutedColor = tokenHexColorExpr(ctx.tokens, 'ui.text-muted', '#6b7280');
|
|
358
|
+
const optionsBlock = emitMenuOptions(node.props, ctx, itemScope, level);
|
|
359
|
+
const labelText = emitSelectLabelText(node.props, ctx, itemScope);
|
|
360
|
+
const labelMods = modifiers.filter((mod) => !mod.startsWith('.frame'));
|
|
361
|
+
const widthLit = propLiteral(node.props, 'width');
|
|
362
|
+
const widthMod = typeof widthLit === 'number'
|
|
363
|
+
? `${pad}.frame(width: ${widthLit})`
|
|
364
|
+
: `${pad}.frame(maxWidth: .infinity)`;
|
|
365
|
+
const lines = [
|
|
366
|
+
`${pad}Menu {`,
|
|
367
|
+
optionsBlock,
|
|
368
|
+
`${pad}} label: {`,
|
|
369
|
+
`${pad} HStack(spacing: 0) {`,
|
|
370
|
+
`${pad} Text(${labelText})`,
|
|
371
|
+
`${pad} .foregroundStyle(${textColor})`,
|
|
372
|
+
`${pad} .lineLimit(1)`,
|
|
373
|
+
`${pad} Spacer(minLength: 0)`,
|
|
374
|
+
`${pad} Image(systemName: "chevron.down")`,
|
|
375
|
+
`${pad} .font(.system(size: 12, weight: .semibold))`,
|
|
376
|
+
`${pad} .foregroundStyle(${mutedColor})`,
|
|
377
|
+
`${pad} }`,
|
|
378
|
+
`${pad} .padding(.vertical, 9)`,
|
|
379
|
+
`${pad} .padding(.leading, 12)`,
|
|
380
|
+
`${pad} .padding(.trailing, 12)`,
|
|
381
|
+
...labelMods.map((mod) => `${pad} ${mod}`),
|
|
382
|
+
`${pad}}`,
|
|
383
|
+
widthMod,
|
|
384
|
+
`${pad}.frame(minHeight: 42)`,
|
|
385
|
+
`${pad}.frame(maxWidth: .infinity)`,
|
|
386
|
+
];
|
|
387
|
+
return lines.join('\n');
|
|
207
388
|
}
|
|
208
389
|
|
|
209
390
|
if (entry.leaf === 'spacer') {
|
|
@@ -253,21 +434,23 @@ function renderContainer(
|
|
|
253
434
|
const spacingLit = entry.spacingProp ? propLiteral(node.props, entry.spacingProp) : undefined;
|
|
254
435
|
const spacing = typeof spacingLit === 'number' ? spacingLit : 0;
|
|
255
436
|
const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: ${spacing}) {\n${childBlock}\n${indent(level + 1)}}`;
|
|
256
|
-
return applyModifiers(inner,
|
|
437
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
257
438
|
}
|
|
258
439
|
|
|
259
440
|
if (entry.container === 'hstack') {
|
|
260
441
|
const spacingLit = entry.spacingProp ? propLiteral(node.props, entry.spacingProp) : undefined;
|
|
261
442
|
const spacing = typeof spacingLit === 'number' ? spacingLit : 0;
|
|
262
443
|
const alignLit = propLiteral(node.props, 'align');
|
|
263
|
-
const alignment = alignLit === 'center' ? '.center' : '.top';
|
|
264
444
|
const heightLit = propLiteral(node.props, 'height');
|
|
445
|
+
const alignment = alignLit === 'center' || typeof heightLit === 'number' ? '.center' : '.top';
|
|
265
446
|
const justifyLit = propLiteral(node.props, 'justify');
|
|
266
447
|
const flexLit = propLiteral(node.props, 'flex');
|
|
448
|
+
const wrapLit = propLiteral(node.props, 'wrap');
|
|
267
449
|
const isTableElement = node.name === 'TableHeader' || node.name === 'TableRow';
|
|
268
450
|
const shouldFill = isTableElement || justifyLit !== undefined || typeof flexLit === 'number';
|
|
451
|
+
const hStackFrameAlign = justifyLit === 'end' ? '.trailing' : justifyLit === 'center' ? '.center' : '.leading';
|
|
269
452
|
const frameMods: string[] = [];
|
|
270
|
-
if (shouldFill) frameMods.push(
|
|
453
|
+
if (shouldFill) frameMods.push(`.frame(maxWidth: .infinity, alignment: ${hStackFrameAlign})`);
|
|
271
454
|
if (typeof heightLit === 'number') frameMods.push(`.frame(height: ${heightLit})`);
|
|
272
455
|
|
|
273
456
|
let block = childBlock;
|
|
@@ -283,6 +466,19 @@ function renderContainer(
|
|
|
283
466
|
const rest = childLines.slice(1);
|
|
284
467
|
block = [first, `${indent(level + 2)}Spacer()`, ...rest].join('\n');
|
|
285
468
|
}
|
|
469
|
+
} else if (wrapLit === true) {
|
|
470
|
+
block = node.children.map((child) => {
|
|
471
|
+
if (child.kind === 'map') return renderMap(child, level + 2, renderNested, ctx);
|
|
472
|
+
if (child.kind === 'text') return `${indent(level + 2)}Text(${JSON.stringify((child as IrTextNode).value)})`;
|
|
473
|
+
if (child.kind === 'binding') return `${indent(level + 2)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
|
|
474
|
+
const rendered = renderNested(child, level + 2, itemScope);
|
|
475
|
+
if (!rendered) return '';
|
|
476
|
+
return rendered;
|
|
477
|
+
}).filter(Boolean).join('\n');
|
|
478
|
+
|
|
479
|
+
// Use FlowLayout (custom Layout protocol) for wrapping
|
|
480
|
+
const inner = `${indent(level + 1)}FlowLayout(spacing: ${spacing}) {\n${block}\n${indent(level + 1)}}`;
|
|
481
|
+
return applyModifiers(inner, [...modifiers, ...frameMods], level + 1);
|
|
286
482
|
}
|
|
287
483
|
|
|
288
484
|
const inner = `${indent(level + 1)}HStack(alignment: ${alignment}, spacing: ${spacing}) {\n${block}\n${indent(level + 1)}}`;
|
|
@@ -293,12 +489,12 @@ function renderContainer(
|
|
|
293
489
|
const inner = childBlock
|
|
294
490
|
? `${indent(level + 1)}VStack(alignment: .leading, spacing: 0) {\n${childBlock}\n${indent(level + 1)}}`
|
|
295
491
|
: `${indent(level + 1)}VStack { EmptyView() }`;
|
|
296
|
-
return applyModifiers(inner,
|
|
492
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
297
493
|
}
|
|
298
494
|
|
|
299
495
|
if (entry.container === 'navstack') {
|
|
300
496
|
const inner = childBlock;
|
|
301
|
-
return applyModifiers(inner,
|
|
497
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
302
498
|
}
|
|
303
499
|
|
|
304
500
|
if (entry.container === 'scrollview') {
|
|
@@ -308,8 +504,12 @@ function renderContainer(
|
|
|
308
504
|
|
|
309
505
|
if (entry.container === 'field') {
|
|
310
506
|
const label = propLiteral(node.props, 'label');
|
|
311
|
-
const
|
|
312
|
-
const
|
|
507
|
+
const gapLit = propLiteral(node.props, 'gap');
|
|
508
|
+
const gap = typeof gapLit === 'number' ? gapLit : 4;
|
|
509
|
+
const labelView = typeof label === 'string'
|
|
510
|
+
? `\n${indent(level + 2)}Text(${JSON.stringify(label)})\n${indent(level + 2)} .applyVcStyle(VcTheme.resolve("Text", variant: "field-label"))`
|
|
511
|
+
: '';
|
|
512
|
+
const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: ${gap}) {${labelView}\n${childBlock}\n${indent(level + 1)}}`;
|
|
313
513
|
return applyModifiers(inner, modifiers, level + 1);
|
|
314
514
|
}
|
|
315
515
|
|
|
@@ -349,7 +549,7 @@ function renderContainer(
|
|
|
349
549
|
}
|
|
350
550
|
|
|
351
551
|
const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: 0) {\n${body.join('\n')}\n${indent(level + 1)}}`;
|
|
352
|
-
return applyModifiers(inner,
|
|
552
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
353
553
|
}
|
|
354
554
|
|
|
355
555
|
if (entry.container === 'cellgroup') {
|
|
@@ -358,15 +558,15 @@ function renderContainer(
|
|
|
358
558
|
const alignLit = propLiteral(node.props, 'align');
|
|
359
559
|
const swiftAlign = alignLit === 'end' ? '.trailing' : alignLit === 'center' ? '.center' : '.leading';
|
|
360
560
|
const isFlex = typeof flexLit === 'number';
|
|
361
|
-
const cellMods = [...modifiers, '.
|
|
561
|
+
const cellMods = [...modifiers, '.lineLimit(1)'];
|
|
362
562
|
if (typeof widthLit === 'number') {
|
|
363
563
|
cellMods.push(`.frame(width: ${widthLit}, alignment: ${swiftAlign})`);
|
|
364
564
|
} else if (isFlex) {
|
|
365
565
|
cellMods.push(`.frame(maxWidth: .infinity, alignment: ${swiftAlign})`);
|
|
366
566
|
}
|
|
367
567
|
const inner = childBlock
|
|
368
|
-
? `${indent(level + 1)}
|
|
369
|
-
: `${indent(level + 1)}
|
|
568
|
+
? `${indent(level + 1)}Group {\n${childBlock}\n${indent(level + 2)}.frame(maxWidth: .infinity, alignment: ${swiftAlign})\n${indent(level + 1)}}`
|
|
569
|
+
: `${indent(level + 1)}Group { EmptyView() }`;
|
|
370
570
|
return applyModifiers(inner, cellMods, level + 1);
|
|
371
571
|
}
|
|
372
572
|
|
|
@@ -393,8 +593,10 @@ function resolvedVisualProps(
|
|
|
393
593
|
});
|
|
394
594
|
}
|
|
395
595
|
|
|
396
|
-
function parseGridColumns(gridTemplateColumns: string | number | boolean | undefined): { kind: 'fixed-columns'; widths: string[] } | { kind: 'adaptive'; minWidth: number } | null {
|
|
596
|
+
function parseGridColumns(gridTemplateColumns: string | number | boolean | undefined): { kind: 'fixed-columns'; widths: string[] } | { kind: 'adaptive'; minWidth: number } | { kind: 'equal-columns'; count: number } | null {
|
|
397
597
|
if (!gridTemplateColumns || typeof gridTemplateColumns !== 'string') return null;
|
|
598
|
+
const equalRepeatMatch = gridTemplateColumns.match(/repeat\((\d+),\s*minmax\(0,\s*1fr\)\)/);
|
|
599
|
+
if (equalRepeatMatch) return { kind: 'equal-columns', count: Number(equalRepeatMatch[1]) };
|
|
398
600
|
const adaptiveMatch = gridTemplateColumns.match(/repeat\(auto-fit,\s*minmax\((\d+)px/);
|
|
399
601
|
if (adaptiveMatch) return { kind: 'adaptive', minWidth: Number(adaptiveMatch[1]) };
|
|
400
602
|
const parts = gridTemplateColumns.trim().split(/\s+/);
|
|
@@ -427,6 +629,9 @@ function renderComponent(
|
|
|
427
629
|
if (parsed?.kind === 'adaptive') {
|
|
428
630
|
return renderGridAdaptive(node, level, itemScope, renderNested, ctx, modifiers, parsed.minWidth);
|
|
429
631
|
}
|
|
632
|
+
if (parsed?.kind === 'equal-columns') {
|
|
633
|
+
return renderGridEqualColumns(node, level, itemScope, renderNested, ctx, modifiers, parsed.count);
|
|
634
|
+
}
|
|
430
635
|
if (parsed?.kind === 'fixed-columns') {
|
|
431
636
|
return renderGridFixedColumns(node, level, itemScope, renderNested, ctx, modifiers, parsed.widths);
|
|
432
637
|
}
|
|
@@ -437,7 +642,7 @@ function renderComponent(
|
|
|
437
642
|
const gapLit = propLiteral(node.props, 'gap');
|
|
438
643
|
const gap = typeof gapLit === 'number' ? gapLit : (typeof resolved.gap === 'number' ? resolved.gap : 0);
|
|
439
644
|
const inner = `${indent(level + 1)}HStack(alignment: .top, spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}`;
|
|
440
|
-
return applyModifiers(inner,
|
|
645
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
441
646
|
}
|
|
442
647
|
}
|
|
443
648
|
|
|
@@ -454,9 +659,21 @@ function renderGridAdaptive(
|
|
|
454
659
|
): string {
|
|
455
660
|
const gapLit = propLiteral(node.props, 'gap');
|
|
456
661
|
const gap = typeof gapLit === 'number' ? gapLit : 16;
|
|
457
|
-
const childBlock =
|
|
458
|
-
const inner = `${indent(level + 1)}LazyVGrid(columns: [GridItem(.adaptive(minimum: ${minWidth}))], spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}`;
|
|
459
|
-
return applyModifiers(inner,
|
|
662
|
+
const childBlock = renderGridChildBlock(node.children, level, renderNested, ctx, itemScope);
|
|
663
|
+
const inner = `${indent(level + 1)}LazyVGrid(columns: [GridItem(.adaptive(minimum: ${minWidth}))], spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}\n${indent(level + 1)}.frame(maxWidth: .infinity, alignment: .topLeading)`;
|
|
664
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function renderGridEqualColumns(
|
|
668
|
+
node: IrComponentNode, level: number, itemScope: string | undefined,
|
|
669
|
+
renderNested: NodeRenderer, ctx: SwiftUIRenderContext, modifiers: string[], count: number,
|
|
670
|
+
): string {
|
|
671
|
+
const gapLit = propLiteral(node.props, 'gap');
|
|
672
|
+
const gap = typeof gapLit === 'number' ? gapLit : 16;
|
|
673
|
+
const columns = `Array(repeating: GridItem(.flexible(), spacing: ${gap}), count: ${count})`;
|
|
674
|
+
const childBlock = renderGridChildBlock(node.children, level, renderNested, ctx, itemScope);
|
|
675
|
+
const inner = `${indent(level + 1)}LazyVGrid(columns: ${columns}, spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}\n${indent(level + 1)}.frame(maxWidth: .infinity, alignment: .topLeading)`;
|
|
676
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
460
677
|
}
|
|
461
678
|
|
|
462
679
|
function resolveBackgroundColor(child: IrNode, ctx: SwiftUIRenderContext): string | null {
|
|
@@ -492,10 +709,12 @@ function renderGridFixedColumns(
|
|
|
492
709
|
const pxMatch = w?.match(/^(\d+)px$/);
|
|
493
710
|
if (pxMatch) {
|
|
494
711
|
rendered += `\n${indent(level + 2)}.frame(width: ${Number(pxMatch[1])}, alignment: .topLeading)`;
|
|
495
|
-
rendered += `\n${indent(level + 2)}.frame(maxHeight: .infinity)`;
|
|
712
|
+
rendered += `\n${indent(level + 2)}.frame(maxHeight: .infinity, alignment: .topLeading)`;
|
|
496
713
|
const bgColor = resolveBackgroundColor(child, ctx);
|
|
497
714
|
if (bgColor) rendered += `\n${indent(level + 2)}.background(${bgColor})`;
|
|
498
715
|
} else if (w === '1fr') {
|
|
716
|
+
// Content inside ScrollView must explicitly fill width
|
|
717
|
+
rendered += `\n${indent(level + 3)}.frame(maxWidth: .infinity, alignment: .topLeading)`;
|
|
499
718
|
rendered = `${indent(level + 2)}ScrollView {\n${rendered}\n${indent(level + 2)}}`;
|
|
500
719
|
rendered += `\n${indent(level + 2)}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)`;
|
|
501
720
|
}
|
|
@@ -503,7 +722,7 @@ function renderGridFixedColumns(
|
|
|
503
722
|
}
|
|
504
723
|
const childBlock = parts.join('\n');
|
|
505
724
|
const inner = `${indent(level + 1)}HStack(spacing: 0) {\n${childBlock}\n${indent(level + 1)}}`;
|
|
506
|
-
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, maxHeight: .infinity)'], level + 1);
|
|
725
|
+
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)'], level + 1);
|
|
507
726
|
}
|
|
508
727
|
|
|
509
728
|
function renderViewRef(node: IrViewRefNode, level: number, itemScope: string | undefined, ctx: SwiftUIRenderContext): string {
|
|
@@ -49,6 +49,10 @@ function swiftFontWeight(value: string | number | boolean): string | null {
|
|
|
49
49
|
semibold: '.fontWeight(.semibold)',
|
|
50
50
|
medium: '.fontWeight(.medium)',
|
|
51
51
|
regular: '.fontWeight(.regular)',
|
|
52
|
+
'700': '.fontWeight(.bold)',
|
|
53
|
+
'600': '.fontWeight(.semibold)',
|
|
54
|
+
'500': '.fontWeight(.medium)',
|
|
55
|
+
'400': '.fontWeight(.regular)',
|
|
52
56
|
};
|
|
53
57
|
return map[value] ?? null;
|
|
54
58
|
}
|
|
@@ -56,8 +60,15 @@ function swiftFontWeight(value: string | number | boolean): string | null {
|
|
|
56
60
|
/**
|
|
57
61
|
* Convert merged DSL visual props to a single SwiftUI modifier chain.
|
|
58
62
|
*
|
|
59
|
-
*
|
|
63
|
+
* SwiftUI modifier order for fill elements:
|
|
64
|
+
* foreground / font → padding → frame(fill) → background → clipShape → shadow → overlay → frame(fixed) → opacity
|
|
65
|
+
*
|
|
66
|
+
* For non-fill elements:
|
|
60
67
|
* foreground / font → padding → background → clipShape → shadow → overlay → frame → opacity
|
|
68
|
+
*
|
|
69
|
+
* The key insight: .frame(maxWidth: .infinity) MUST come BEFORE .background()
|
|
70
|
+
* so the background fills the expanded area. Otherwise the background only
|
|
71
|
+
* covers the natural content width.
|
|
61
72
|
*/
|
|
62
73
|
export function visualPropSetToModifiers(merged: VisualPropSet): string[] {
|
|
63
74
|
const modifiers: string[] = [];
|
|
@@ -90,7 +101,43 @@ export function visualPropSetToModifiers(merged: VisualPropSet): string[] {
|
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
//
|
|
104
|
+
// Pre-compute frame/size values needed to determine fill vs fixed
|
|
105
|
+
const width = merged.width;
|
|
106
|
+
const height = merged.height;
|
|
107
|
+
const minHeight = merged.minHeight;
|
|
108
|
+
const maxWidth = merged.maxWidth;
|
|
109
|
+
const maxHeight = merged.maxHeight;
|
|
110
|
+
const margin = merged.margin;
|
|
111
|
+
const hasCenteringMargin = margin === '0 auto';
|
|
112
|
+
|
|
113
|
+
const needsFillWidth = width === '100%' || width === 'fill' || maxWidth === 'fill';
|
|
114
|
+
const needsFillHeight = height === '100%' || height === 'fill' || minHeight === '100vh';
|
|
115
|
+
|
|
116
|
+
// 2.5. Fill frame constraints — BEFORE background so bg fills the expanded area
|
|
117
|
+
// alignment: .leading/.top/.topLeading ensures content stays at top-left (matching CSS block behavior)
|
|
118
|
+
const maxWidthPx = maxWidth === 'fill' ? null : (maxWidth !== undefined ? parsePx(maxWidth) : null);
|
|
119
|
+
|
|
120
|
+
if (needsFillWidth && needsFillHeight) {
|
|
121
|
+
if (maxWidthPx !== null) {
|
|
122
|
+
modifiers.push(`.frame(maxWidth: ${maxWidthPx}, maxHeight: .infinity, alignment: .topLeading)`);
|
|
123
|
+
} else {
|
|
124
|
+
modifiers.push('.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)');
|
|
125
|
+
}
|
|
126
|
+
} else if (needsFillWidth) {
|
|
127
|
+
if (maxWidthPx !== null) {
|
|
128
|
+
modifiers.push(`.frame(maxWidth: ${maxWidthPx}, alignment: .leading)`);
|
|
129
|
+
} else {
|
|
130
|
+
modifiers.push('.frame(maxWidth: .infinity, alignment: .leading)');
|
|
131
|
+
}
|
|
132
|
+
} else if (needsFillHeight) {
|
|
133
|
+
modifiers.push('.frame(maxHeight: .infinity, alignment: .top)');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!needsFillHeight && minHeight === '100vh') {
|
|
137
|
+
modifiers.push('.frame(maxHeight: .infinity, alignment: .top)');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3. Background (fills the frame area — now expanded for fill elements)
|
|
94
141
|
const background = merged.background;
|
|
95
142
|
if (typeof background === 'string' && background !== 'transparent') {
|
|
96
143
|
const color = parseHexColor(background);
|
|
@@ -115,17 +162,49 @@ export function visualPropSetToModifiers(merged: VisualPropSet): string[] {
|
|
|
115
162
|
modifiers.push(`.overlay(RoundedRectangle(cornerRadius: ${radius ?? 0}).stroke(${colorExpr}, lineWidth: ${borderWidth}))`);
|
|
116
163
|
}
|
|
117
164
|
|
|
118
|
-
// 7.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
165
|
+
// 7. Non-fill frame constraints (fixed sizes, maxWidth cap without fill)
|
|
166
|
+
if (!needsFillWidth) {
|
|
167
|
+
if (typeof width === 'number') {
|
|
168
|
+
modifiers.push(`.frame(width: ${width})`);
|
|
169
|
+
} else if (width !== undefined && width !== 'wrap') {
|
|
170
|
+
const px = parsePx(width);
|
|
171
|
+
if (px !== null) modifiers.push(`.frame(width: ${px})`);
|
|
172
|
+
}
|
|
173
|
+
if (maxWidth !== undefined && maxWidth !== 'fill') {
|
|
174
|
+
const px = parsePx(maxWidth);
|
|
175
|
+
if (px !== null) modifiers.push(`.frame(maxWidth: ${px})`);
|
|
176
|
+
}
|
|
123
177
|
}
|
|
124
178
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
179
|
+
if (!needsFillHeight) {
|
|
180
|
+
if (typeof height === 'number') {
|
|
181
|
+
modifiers.push(`.frame(height: ${height})`);
|
|
182
|
+
} else if (height !== undefined && height !== 'wrap') {
|
|
183
|
+
const px = parsePx(height);
|
|
184
|
+
if (px !== null) modifiers.push(`.frame(height: ${px})`);
|
|
185
|
+
}
|
|
186
|
+
if (minHeight !== undefined && minHeight !== '100vh') {
|
|
187
|
+
if (typeof minHeight === 'number') {
|
|
188
|
+
modifiers.push(`.frame(minHeight: ${minHeight})`);
|
|
189
|
+
} else {
|
|
190
|
+
const px = parsePx(minHeight);
|
|
191
|
+
if (px !== null) modifiers.push(`.frame(minHeight: ${px})`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (maxHeight !== undefined && maxHeight !== 'fill') {
|
|
195
|
+
const px = parsePx(maxHeight);
|
|
196
|
+
if (px !== null) modifiers.push(`.frame(maxHeight: ${px})`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// margin: 0 auto → center within parent by expanding outer frame (positioning, not sizing)
|
|
201
|
+
if (hasCenteringMargin) {
|
|
202
|
+
modifiers.push('.frame(maxWidth: .infinity, alignment: .center)');
|
|
203
|
+
} else if (typeof margin === 'number') {
|
|
204
|
+
modifiers.push(`.padding(${margin})`);
|
|
205
|
+
} else if (margin !== undefined) {
|
|
206
|
+
const px = parsePx(margin);
|
|
207
|
+
if (px !== null) modifiers.push(`.padding(${px})`);
|
|
129
208
|
}
|
|
130
209
|
|
|
131
210
|
// 8. Opacity (last — affects everything)
|
|
@@ -9,6 +9,51 @@ import { renderSwiftTheme } from './renderTheme.js';
|
|
|
9
9
|
import { renderSwiftComponent } from './renderComponents.js';
|
|
10
10
|
import { loadPreviewMockYaml, previewMockFilePath, pathExists } from '../../src/preview/previewMock.js';
|
|
11
11
|
|
|
12
|
+
const FLOW_LAYOUT_SWIFT = `import SwiftUI
|
|
13
|
+
|
|
14
|
+
/// CSS flex-wrap equivalent: wraps children to next line when horizontal space runs out.
|
|
15
|
+
struct FlowLayout: Layout {
|
|
16
|
+
var spacing: CGFloat = 8
|
|
17
|
+
|
|
18
|
+
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
19
|
+
let containerWidth = proposal.width ?? .infinity
|
|
20
|
+
var x: CGFloat = 0
|
|
21
|
+
var y: CGFloat = 0
|
|
22
|
+
var rowHeight: CGFloat = 0
|
|
23
|
+
|
|
24
|
+
for subview in subviews {
|
|
25
|
+
let size = subview.sizeThatFits(.unspecified)
|
|
26
|
+
if x + size.width > containerWidth && x > 0 {
|
|
27
|
+
y += rowHeight + spacing
|
|
28
|
+
x = 0
|
|
29
|
+
rowHeight = 0
|
|
30
|
+
}
|
|
31
|
+
x += size.width + spacing
|
|
32
|
+
rowHeight = max(rowHeight, size.height)
|
|
33
|
+
}
|
|
34
|
+
return CGSize(width: containerWidth, height: y + rowHeight)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
38
|
+
var x = bounds.minX
|
|
39
|
+
var y = bounds.minY
|
|
40
|
+
var rowHeight: CGFloat = 0
|
|
41
|
+
|
|
42
|
+
for subview in subviews {
|
|
43
|
+
let size = subview.sizeThatFits(.unspecified)
|
|
44
|
+
if x + size.width > bounds.maxX && x > bounds.minX {
|
|
45
|
+
y += rowHeight + spacing
|
|
46
|
+
x = bounds.minX
|
|
47
|
+
rowHeight = 0
|
|
48
|
+
}
|
|
49
|
+
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
|
50
|
+
x += size.width + spacing
|
|
51
|
+
rowHeight = max(rowHeight, size.height)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
|
|
12
57
|
function swiftuiTemplatesDir(): string {
|
|
13
58
|
return resolve(packageRoot(), 'renderers/swiftui/templates');
|
|
14
59
|
}
|
|
@@ -33,6 +78,9 @@ export async function renderSwiftUIRuntime(
|
|
|
33
78
|
);
|
|
34
79
|
}
|
|
35
80
|
|
|
81
|
+
// FlowLayout helper (CSS flex-wrap equivalent)
|
|
82
|
+
await writeFile(resolve(swiftuiDir, 'FlowLayout.swift'), FLOW_LAYOUT_SWIFT, 'utf-8');
|
|
83
|
+
|
|
36
84
|
if (compiledDocs && design) {
|
|
37
85
|
for (const partial of compiledDocs.partials) {
|
|
38
86
|
try {
|