@dongmingzhixiu/vuexir-compiler 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { dynamicVectorSwiftHelpers, wrapLayoutSwiftHelpers } from './dynamic-vector.js';
1
2
  /**
2
3
  * SwiftUI 生成器 v0(P0.3)
3
4
  *
@@ -21,6 +22,8 @@
21
22
  * P0 简化声明:justifyContent 仅 start/center/spaceBetween;breakpoints 未实现;
22
23
  * flex 仅支持容器主轴方向的 maxHeight/maxWidth:.infinity。
23
24
  */
25
+ import { flattenTextRuns, loopBinding, swiftBoundValue } from './bindings.js';
26
+ import { swiftVector } from './vector.js';
24
27
  /* ============ 颜色表达式 ============ */
25
28
  let currentCssVars;
26
29
  function parseColor(c) {
@@ -44,7 +47,7 @@ function parseColor(c) {
44
47
  return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
45
48
  return { r: 0, g: 0, b: 0, a: 1 };
46
49
  }
47
- function colorExpr(c) {
50
+ export function colorExpr(c) {
48
51
  const p = parseColor(c);
49
52
  if (p.mode === 'theme' && p.name)
50
53
  return `ThemeColor.color("${p.name}")`;
@@ -129,7 +132,7 @@ export function buildTheme(tokens) {
129
132
  return { colorNames, fontNames };
130
133
  }
131
134
  /** R42 生成 Theme.swift(颜色/字体去重常量) */
132
- export const VRN_HELPERS_SWIFT = `import Foundation
135
+ export const VRN_HELPERS_SWIFT = dynamicVectorSwiftHelpers + wrapLayoutSwiftHelpers + `import Foundation
133
136
  import SwiftUI
134
137
 
135
138
  // vuexir 运行时辅助(R78 对象字段取值)
@@ -166,11 +169,43 @@ private func vrnFieldRaw(_ json: String, _ path: String) -> String {
166
169
  }
167
170
  if let d = cur as? [String: Any], let next = d[seg] { cur = next } else { return "" }
168
171
  }
172
+ if cur is NSNull { return "" }
169
173
  if let s = cur as? String { return s }
170
- if let b = cur as? Bool { return b ? "true" : "false" }
171
- if let n = cur as? NSNumber { return n.stringValue }
174
+ if let n = cur as? NSNumber {
175
+ if CFGetTypeID(n) == CFBooleanGetTypeID() { return n.boolValue ? "true" : "false" }
176
+ return n.stringValue
177
+ }
172
178
  return String(describing: cur)
173
179
  }
180
+
181
+ func vrnItemField(_ item: Any, _ path: String) -> String {
182
+ var value: Any = item
183
+ for part in path.split(separator: ".").map(String.init) {
184
+ if let map = value as? [String: Any], let next = map[part] { value = next }
185
+ else if let array = value as? [Any], let index = Int(part), array.indices.contains(index) { value = array[index] }
186
+ else { return "" }
187
+ }
188
+ if value is NSNull { return "" }
189
+ if let number = value as? NSNumber {
190
+ if CFGetTypeID(number) == CFBooleanGetTypeID() { return number.boolValue ? "true" : "false" }
191
+ return number.stringValue
192
+ }
193
+ return String(describing: value)
194
+ }
195
+
196
+ struct VrnFractionWidth: Layout {
197
+ var fraction: Double
198
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
199
+ guard let view = subviews.first else { return .zero }
200
+ let width = (proposal.width ?? view.sizeThatFits(.unspecified).width) * fraction
201
+ let size = view.sizeThatFits(ProposedViewSize(width: width, height: proposal.height))
202
+ return CGSize(width: width, height: size.height)
203
+ }
204
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
205
+ subviews.first?.place(at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(bounds.size))
206
+ }
207
+ }
208
+
174
209
  `;
175
210
  export function generateThemeSource(palette) {
176
211
  const lines = ['import SwiftUI', '', '// GENERATED by vue-render-native compiler · R42 theme tokens', 'enum Theme {'];
@@ -314,10 +349,16 @@ function gapInheritFor(child, parentGap, siblings) {
314
349
  function sizeModifiers(props, axis, ctx) {
315
350
  const mods = [];
316
351
  const { width, height, flex } = props;
352
+ if (props.widthDynamic)
353
+ mods.push(`.frame(width: max(0, Double(String(${swiftBoundValue(props.widthDynamic, ctx.runtime)})) ?? 0))`);
354
+ if (props.heightDynamic)
355
+ mods.push(`.frame(height: max(0, Double(String(${swiftBoundValue(props.heightDynamic, ctx.runtime)})) ?? 0))`);
317
356
  if (width === 'fill')
318
357
  mods.push(frameExpr('fillW'));
319
358
  else if (typeof width === 'number')
320
359
  mods.push(`.frame(width: ${width})`);
360
+ if (height === 'fill')
361
+ mods.push('.frame(maxHeight: .infinity)');
321
362
  if (typeof height === 'number')
322
363
  mods.push(`.frame(height: ${height})`);
323
364
  if (flex !== undefined) {
@@ -420,12 +461,28 @@ function capsuleColorExpr(c) {
420
461
  const name = currentColorNames?.get(hex);
421
462
  return name !== undefined ? `Theme.${name}` : `Color(red: ${r}/255.0, green: ${g}/255.0, blue: ${b}/255.0)`;
422
463
  }
423
- function boxModifiers(props) {
464
+ export function boxModifiers(props) {
424
465
  const mods = [];
466
+ if (props.gradient) {
467
+ const a = props.gradient.angle * Math.PI / 180;
468
+ const x = Math.sin(a) / 2, y = -Math.cos(a) / 2;
469
+ const n = (v) => Number(v.toFixed(5));
470
+ const stops = props.gradient.stops.map((s) => `.init(color: ${colorExpr(s.color === 'transparent' ? 'rgba(0,0,0,0)' : s.color)}, location: ${s.offset})`).join(', ');
471
+ if (props.gradient.kind === 'radial')
472
+ mods.push(`.background(GeometryReader { geo in RadialGradient(stops: [${stops}], center: UnitPoint(x: ${props.gradient.center?.x ?? .5}, y: ${props.gradient.center?.y ?? .5}), startRadius: 0, endRadius: max(geo.size.width, geo.size.height)) })`);
473
+ else
474
+ mods.push(`.background(LinearGradient(stops: [${stops}], startPoint: UnitPoint(x: ${n(.5 - x)}, y: ${n(.5 - y)}), endPoint: UnitPoint(x: ${n(.5 + x)}, y: ${n(.5 + y)})))`);
475
+ }
476
+ if (props.minWidth !== undefined || props.minHeight !== undefined) {
477
+ const args = [props.minWidth !== undefined ? `minWidth: ${props.minWidth}` : '', props.minHeight !== undefined ? `minHeight: ${props.minHeight}` : ''].filter(Boolean);
478
+ mods.push(`.frame(${args.join(', ')})`);
479
+ }
425
480
  if (props.background !== undefined)
426
481
  mods.push(`.background(${colorExpr(props.background)})`);
427
482
  if (props.radius !== undefined)
428
483
  mods.push(`.cornerRadius(${props.radius})`);
484
+ if (props.shadow)
485
+ mods.push(`.shadow(color: ${colorExpr(props.shadow.color)}, radius: ${props.shadow.blur / 2}, x: ${props.shadow.x}, y: ${props.shadow.y})`);
429
486
  if (props.borderWidth !== undefined && props.borderColor !== undefined && props.borderWidth > 0) {
430
487
  mods.push(`.overlay(RoundedRectangle(cornerRadius: ${props.radius ?? 0}).stroke(${colorExpr(props.borderColor)}, lineWidth: ${props.borderWidth}))`);
431
488
  }
@@ -457,6 +514,7 @@ function emitChildren(children, axis, ctx, alignRaw = 'stretch', stretchCross =
457
514
  return children
458
515
  .map((c, i) => {
459
516
  const childCtx = {
517
+ ...ctx,
460
518
  parentAxis: axis,
461
519
  level: ctx.level,
462
520
  definiteHeight: ctx.definiteHeight,
@@ -501,7 +559,7 @@ function emitChildren(children, axis, ctx, alignRaw = 'stretch', stretchCross =
501
559
  // column:仅默认 stretch 时子项填满父宽(div block 语义);显式 start/center/end → 内容宽对齐
502
560
  const explicitCol = alignRaw === 'start' || alignRaw === 'center' || alignRaw === 'end';
503
561
  if (!explicitCol) {
504
- return body + '\n' + indent(ctx.level) + frameExpr('fillW', '.leading');
562
+ return body + '\n' + indent(ctx.level) + frameExpr('fillW', c.props?.textAlign === 'center' ? '.center' : c.props?.textAlign === 'right' ? '.trailing' : '.leading');
505
563
  }
506
564
  return body;
507
565
  }
@@ -517,19 +575,44 @@ function emitChildren(children, axis, ctx, alignRaw = 'stretch', stretchCross =
517
575
  })
518
576
  .join('\n');
519
577
  }
578
+ let sectionNodes = new WeakMap();
579
+ let sectionSources = [];
580
+ function conditionNames(ctx) {
581
+ const names = new Set(ctx.stateNames ?? currentStateNames);
582
+ if (ctx.loop) {
583
+ names.add(ctx.loop.itemKey);
584
+ if (ctx.loop.indexKey)
585
+ names.add(ctx.loop.indexKey);
586
+ }
587
+ return names;
588
+ }
520
589
  function emitNode(node, ctx) {
590
+ const section = sectionNodes.get(node);
591
+ if (section) {
592
+ sectionNodes.delete(node);
593
+ const source = emitNode(node, { ...ctx, level: 2 });
594
+ const aliases = ctx.loopAliases ?? [];
595
+ sectionSources.push(aliases.length ? ` @ViewBuilder private func ${section}(${aliases.map((a) => `_ ${a}: Any`).join(", ")}) -> some View {\n${source}\n }` : ` @ViewBuilder private var ${section}: some View {\n${source}\n }`);
596
+ return indent(ctx.level) + section + (aliases.length ? `(${aliases.join(", ")})` : "");
597
+ }
521
598
  if (node.condition) {
522
599
  const L = ctx.level;
523
600
  const inner = emitNodeInner(node, ctx);
524
601
  const known = ctx.stateNames && ctx.stateNames.size > 0 ? ctx.stateNames : undefined;
525
- const swiftCond = swiftCondExpr(node.condition, known);
602
+ const swiftCond = swiftCondExpr(node.condition, conditionNames(ctx), ctx.runtime);
526
603
  return `${indent(L)}if ${swiftCond} {\n${inner}\n${indent(L)}}`;
527
604
  }
528
605
  return emitNodeInner(node, ctx);
529
606
  }
530
607
  /** JS 条件表达式 → Swift 兼容:===/!== 为 JS 严格比较,Swift 用 ==/!=;字符串字面量单引号 → 双引号 */
531
- function swiftCondExpr(expr, known) {
608
+ function swiftCondExpr(expr, known, runtime = 'Runtime.shared') {
609
+ const binding = expr.startsWith('__vrnCond:') ? expr.slice(10) : expr;
610
+ if (loopBinding(binding))
611
+ return `vrnTruthy(${swiftBoundValue(binding, runtime)})`;
532
612
  // R128:下沉的条件状态 → 判真;不在已知集(组件/无状态上下文)→ true 保留内容
613
+ const field = expr.match(/^__vrnField__:(\w+):(\w+)$/);
614
+ if (field)
615
+ return `vrnTruthy(vrnField(${field[1]}, "${field[2]}"))`;
533
616
  const sunk = expr.match(/^__vrnCond:(\w+)$/);
534
617
  if (sunk) {
535
618
  const nm = sunk[1];
@@ -570,7 +653,7 @@ function swiftCondExpr(expr, known) {
570
653
  if (/&&|\|\|/.test(s) && !/__vrnField__/.test(s) && !/[()]/.test(s)) {
571
654
  const parts = s.split(/(&&|\|\|)/);
572
655
  return parts
573
- .map((seg) => (seg === '&&' || seg === '||' ? seg : swiftCondExpr(seg.trim(), known)))
656
+ .map((seg) => (seg === '&&' || seg === '||' ? seg : swiftCondExpr(seg.trim(), known, runtime)))
574
657
  .join(' ');
575
658
  }
576
659
  if (/^![A-Za-z_$][\w$]*$/.test(s))
@@ -601,6 +684,15 @@ function emitNodeInner(node, ctx) {
601
684
  const L = ctx.level;
602
685
  const pad = (s) => indent(L) + s;
603
686
  const props = node.props ?? {};
687
+ if (props.show) {
688
+ const content = emitNode({ ...node, props: { ...props, show: undefined } }, { ...ctx, level: L + 1 });
689
+ return pad(`if ${swiftCondExpr(props.show, ctx.stateNames, ctx.runtime)} {`) + `\n${content}\n${pad('}')}`;
690
+ }
691
+ if (props.widthFraction !== undefined || props.widthFractionDynamic) {
692
+ const fraction = props.widthFractionDynamic ? `(Double(String(${swiftBoundValue(props.widthFractionDynamic, ctx.runtime)})) ?? 0)` : String(props.widthFraction);
693
+ const inner = emitNodeInner({ ...node, props: { ...props, widthFraction: undefined, widthFractionDynamic: undefined } }, { ...ctx, level: ctx.level + 1 });
694
+ return pad(`VrnFractionWidth(fraction: max(0,min(1,${fraction}))) {`) + `\n${inner}\n${pad('}')}`;
695
+ }
604
696
  const axis = ctx.parentAxis ?? 'column';
605
697
  // box-sizing:border-box(CSS 默认):固定 height 应包含 padding。
606
698
  // 故 height 为数字且存在 padding 时,padding 置于 frame(height) 之前、
@@ -629,9 +721,15 @@ function emitNodeInner(node, ctx) {
629
721
  }
630
722
  }
631
723
  // M5 position:absolute → 脱离文档流(父容器已转 ZStack;offset 实现 top/left 定位)
724
+ if (props.zIndex !== undefined)
725
+ box.push(`.zIndex(${props.zIndex})`);
632
726
  if (props.position === 'absolute') {
727
+ const fullInset = props.absTop === 0 && props.absLeft === 0 && props.absRight === 0 && props.absBottom === 0;
633
728
  const hasRB = props.absRight !== undefined || props.absBottom !== undefined;
634
- if (hasRB) {
729
+ if (fullInset) {
730
+ box.push('.frame(maxWidth: .infinity, maxHeight: .infinity)');
731
+ }
732
+ else if (hasRB) {
635
733
  // R131:right/bottom 定位 → 用对齐 + 内边距表达(此前直接丢弃,导致元素重叠、内容被盖住)
636
734
  const v = props.absBottom !== undefined ? 'bottom' : props.absTop !== undefined ? 'top' : 'center';
637
735
  const h = props.absRight !== undefined ? 'trailing' : props.absLeft !== undefined ? 'leading' : 'center';
@@ -658,7 +756,7 @@ function emitNodeInner(node, ctx) {
658
756
  }
659
757
  // M5 v-show → opacity 动画切换(保留占位;状态名引用页面/容器同名 @State/@Binding)
660
758
  if (props.show !== undefined) {
661
- box.push(`.opacity(${props.show} ? 1 : 0)`);
759
+ box.push(`.opacity(vrnTruthy(String(${props.show})) ? 1 : 0)`);
662
760
  box.push(`.animation(.easeInOut(duration: 0.25), value: ${props.show})`);
663
761
  }
664
762
  // CSS opacity(0~1):静态透明度(与 v-show 的条件透明叠加)
@@ -683,7 +781,8 @@ function emitNodeInner(node, ctx) {
683
781
  warnings: ctx.warnings,
684
782
  path: `${ctx.path}[${i}]`,
685
783
  inheritGap: gapInheritFor(c, props.gap, node.children ?? []),
686
- loop: ctx.loop, // M2:v-for 作用域透传
784
+ loop: ctx.loop,
785
+ loopAliases: ctx.loopAliases, // M2:v-for 作用域透传
687
786
  runtime: ctx.runtime, // M4a:页面运行时引用透传
688
787
  dataProps: ctx.dataProps, // R-VFOBJ-D:数据声明收集透传
689
788
  });
@@ -698,7 +797,7 @@ function emitNodeInner(node, ctx) {
698
797
  // (浅色模式下是白条),表现为「详情页底部颜色与页面不一致、像高度不够」
699
798
  const bgLayer = props.background !== undefined
700
799
  ? indent(L + 1) + `${colorExpr(props.background)}.frame(maxWidth: .infinity, maxHeight: .infinity).ignoresSafeArea()`
701
- : null;
800
+ : props.gradient ? indent(L + 1) + 'Color.clear.ignoresSafeArea()' + boxModifiers({ gradient: props.gradient }).join('') : null;
702
801
  // R-PAGESCROLL:页面根 = 可滚动视口(对位 Web 的 body 默认滚动)
703
802
  // - 内容高于视口 → ScrollView 可滚动(此前仅 external tab 页按按钮数启发式包装,详情页不可滚动)
704
803
  // - 内容不足 → minHeight = 视口高,页面仍铺满,Spacer/flex 贴底语义不变
@@ -721,6 +820,14 @@ function emitNodeInner(node, ctx) {
721
820
  .join('\n');
722
821
  }
723
822
  case 'group': {
823
+ if (props.flexWrap && node.children) {
824
+ const children = node.children.map((child, i) => {
825
+ const cp = child.props ?? {};
826
+ const source = emitNode({ ...child, props: { ...cp, flex: undefined } }, { ...ctx, level: L + 1, parentAxis: 'row', path: ctx.path + '.wrap' + i });
827
+ return indent(L + 1) + 'Group {\n' + source + '\n' + indent(L + 1) + '}' + `.layoutValue(key: VrnWrapFlex.self, value: ${cp.flex ?? 0}).layoutValue(key: VrnWrapMin.self, value: ${cp.minWidth ?? 0}).layoutValue(key: VrnWrapBreak.self, value: ${cp.width === 'fill' ? 'true' : 'false'})`;
828
+ });
829
+ return pad(`VrnWrapLayout(spacing: ${props.gap ?? 0}) {`) + '\n' + children.join('\n') + '\n' + pad('}') + box.map((m) => ' ' + m).join('');
830
+ }
724
831
  // R136:CSS grid(grid-template-columns: repeat(N,1fr))→ LazyVGrid
725
832
  if (props.gridColumns !== undefined && (node.children?.length ?? 0) > 0) {
726
833
  const cols = props.gridColumns;
@@ -734,13 +841,14 @@ function emitNodeInner(node, ctx) {
734
841
  path: `${ctx.path}[${i}]`,
735
842
  inheritGap: undefined,
736
843
  loop: ctx.loop,
844
+ loopAliases: ctx.loopAliases,
737
845
  dataProps: ctx.dataProps,
738
846
  }))
739
847
  .join('\n');
740
848
  return [
741
849
  pad(`LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: ${props.gap ?? 0}), count: ${cols}), spacing: ${props.gap ?? 0}) {`),
742
850
  inner,
743
- pad('}'),
851
+ pad('}') + box.map((m) => ' ' + m).join(''),
744
852
  ].join('\n');
745
853
  }
746
854
  const dir = props.flexDirection ?? 'column';
@@ -776,7 +884,7 @@ function emitNodeInner(node, ctx) {
776
884
  if (hasAbsolute && flowChildren.length > 0) {
777
885
  const flowNode = {
778
886
  type: 'group',
779
- props: { ...props, position: undefined, absTop: undefined, absLeft: undefined, absRight: undefined, absBottom: undefined },
887
+ props: { flexDirection: props.flexDirection, justifyContent: props.justifyContent, alignItems: props.alignItems, gap: props.gap, padding: props.padding },
780
888
  children: flowChildren,
781
889
  };
782
890
  const flowCode = emitNode(flowNode, {
@@ -788,31 +896,19 @@ function emitNodeInner(node, ctx) {
788
896
  path: `${ctx.path}{flow}`,
789
897
  inheritGap: ctx.inheritGap,
790
898
  loop: ctx.loop,
899
+ loopAliases: ctx.loopAliases,
791
900
  dataProps: ctx.dataProps,
792
901
  });
793
- const absCode = absChildren
794
- .map((c, i) => emitNode(c, {
795
- parentAxis: dir,
796
- level: L + 1,
797
- definiteHeight: selfDefinite,
798
- warnings: ctx.warnings,
799
- runtime: ctx.runtime,
800
- path: `${ctx.path}{abs${i}}`,
801
- inheritGap: undefined,
802
- loop: ctx.loop,
803
- dataProps: ctx.dataProps,
804
- }))
805
- .join('\n');
806
- const bg = props.background !== undefined ? pad(`Color(${props.background})`) : null;
807
- return [
808
- pad('ZStack(alignment: .topLeading) {'),
809
- bg ? indent(L + 1) + bg.trim() : null,
810
- flowCode,
811
- absCode || null,
812
- pad('}'),
813
- ]
814
- .filter((x) => x !== null)
815
- .join('\n');
902
+ const firstFlow = children.findIndex((c) => c.props?.position !== 'absolute');
903
+ let result = [pad('ZStack(alignment: .topLeading) {'), flowCode, pad('}')].join('\n');
904
+ for (const [i, c] of children.entries()) {
905
+ if (c.props?.position !== 'absolute')
906
+ continue;
907
+ const mode = i < firstFlow && (c.props.zIndex ?? 0) <= 0 ? 'background' : 'overlay';
908
+ const code = emitNode(c, { ...ctx, level: L + 1, parentAxis: dir, definiteHeight: true, path: `${ctx.path}{abs${i}}`, inheritGap: undefined });
909
+ result += `\n${indent(L)}.${mode}(alignment: .topLeading) {\n${code}\n${indent(L)}}`;
910
+ }
911
+ return result + box.filter((m) => !m.startsWith('.padding(')).map((m) => ' ' + m).join('');
816
912
  }
817
913
  // justifyContent:center → 首尾 Spacer;spaceBetween → 子间 Spacer(仅 column 需高度确定)
818
914
  const body = [];
@@ -822,7 +918,7 @@ function emitNodeInner(node, ctx) {
822
918
  children.forEach((c, i) => {
823
919
  if (just === 'spaceBetween' && justActive && i > 0)
824
920
  body.push(indent(L + 1) + 'Spacer(minLength: 0)');
825
- const childCode = emitNode(c, {
921
+ let childCode = emitNode(c, {
826
922
  parentAxis: dir,
827
923
  level: L + 1,
828
924
  definiteHeight: selfDefinite,
@@ -830,9 +926,18 @@ function emitNodeInner(node, ctx) {
830
926
  runtime: ctx.runtime,
831
927
  path: `${ctx.path}[${i}]`,
832
928
  inheritGap: gapInheritFor(c, props.gap, children),
833
- loop: ctx.loop, // M2:v-for 作用域透传
929
+ loop: ctx.loop,
930
+ loopAliases: ctx.loopAliases, // M2:v-for 作用域透传
834
931
  dataProps: ctx.dataProps, // R-VFOBJ-D
835
932
  });
933
+ if (c.condition && (c.props?.marginBottom || c.props?.marginRight || c.props?.marginLeft))
934
+ childCode = indent(L + 1) + 'Group {\n' + childCode + '\n' + indent(L + 1) + '}';
935
+ if (dir === 'column' && c.props?.marginBottom)
936
+ childCode += `.padding(.bottom, ${c.props.marginBottom})`;
937
+ if (dir === 'row' && c.props?.marginRight)
938
+ childCode += `.padding(.trailing, ${c.props.marginRight})`;
939
+ if (dir === 'row' && c.props?.marginLeft)
940
+ childCode += `.padding(.leading, ${c.props.marginLeft})`;
836
941
  // 盒模型(R29 重建 div 语义):margin 用「透明包装容器」表达,而非独立兄弟占位——
837
942
  // VStack(spacing) 对每个独立兄弟前后各计一次 spacing,独立占位会与 gap 叠加双计
838
943
  // (实测:gap16 + margin16 被算成 16+16+16=48pt,CSS 应为 32pt)。
@@ -872,9 +977,9 @@ function emitNodeInner(node, ctx) {
872
977
  else if (c.props?.width === undefined && c.props?.position !== 'absolute') {
873
978
  // column:仅默认 stretch(未显式声明 align-items)时子项填满父宽(div block 语义);
874
979
  // 显式 start/center/end → 子项按内容宽对齐(CSS 交叉轴对齐语义)
875
- const explicitCol = props.alignItems === 'start' || props.alignItems === 'center' || props.alignItems === 'end';
980
+ const explicitCol = (c.props?.alignSelf !== undefined && c.props.alignSelf !== 'stretch') || props.alignItems === 'start' || props.alignItems === 'center' || props.alignItems === 'end';
876
981
  if (!explicitCol) {
877
- body.push(indent(L + 1) + frameExpr('fillW', '.leading'));
982
+ body.push(indent(L + 1) + frameExpr('fillW', c.props?.textAlign === 'center' ? '.center' : c.props?.textAlign === 'right' ? '.trailing' : '.leading'));
878
983
  }
879
984
  }
880
985
  });
@@ -891,7 +996,7 @@ function emitNodeInner(node, ctx) {
891
996
  if (actBg !== undefined) {
892
997
  const actColor = colorExpr(actBg);
893
998
  const base = props.background !== undefined ? colorExpr(props.background) : 'Color.clear';
894
- actBox = [`.background(${node.active.when} ? ${actColor} : ${base})`];
999
+ actBox = [`.background(${swiftCondExpr(node.active.when, conditionNames(ctx), ctx.runtime)} ? ${actColor} : ${base})`];
895
1000
  }
896
1001
  const effBox = actBox !== null ? [...actBox, ...box.filter((m) => !m.startsWith('.background('))] : box;
897
1002
  let out = [pad(open), ...body, pad('}') + effBox.map((m) => ' ' + m).join('')].join('\n');
@@ -915,9 +1020,25 @@ function emitNodeInner(node, ctx) {
915
1020
  return out;
916
1021
  }
917
1022
  case 'text': {
1023
+ if (props.textRuns) {
1024
+ const allowed = ['text', 'bind', 'bindParts', 'textRuns', 'fieldOf', 'ref', 'dynField', 'bindExpr', 'fontSize', 'fontWeight', 'fontFamily', 'color', 'letterSpacing'];
1025
+ const runs = flattenTextRuns(props.textRuns).map((run) => {
1026
+ const rp = {};
1027
+ for (const key of allowed)
1028
+ if (run[key] !== undefined)
1029
+ rp[key] = run[key];
1030
+ for (const key of ['fontSize', 'fontWeight', 'fontFamily', 'color'])
1031
+ rp[key] ??= props[key];
1032
+ return emitNode({ type: 'text', props: rp }, { ...ctx, level: 0 });
1033
+ });
1034
+ const locals = runs.map((code, i) => `${indent(L + 1)}let _vrnRun${i}: Text = ${code}`).join('\n');
1035
+ const combined = runs.map((_, i) => `_vrnRun${i}`).join(' + ') || 'Text("")';
1036
+ return pad('Group {') + '\n' + locals + '\n' + indent(L + 1) + combined + '\n' + pad('}') + box.map((m) => ' ' + m).join('');
1037
+ }
918
1038
  // R41:组件 View 内 {{ prop }} → props.ref → Text(prop)(Swift 属性引用,非字面量)
919
1039
  // M1:页面级 {{ state }} → props.bind / node.bind → Text(<@State 名>)(状态引用,非字面量)
920
1040
  const isRef = props.ref !== undefined;
1041
+ const loopBound = props.bind && loopBinding(props.bind);
921
1042
  const bindKey = props.bind ?? node.bind?.key;
922
1043
  // R133:bind 指向的名字必须是真实状态(或循环变量),否则说明作用域丢失 →
923
1044
  // 输出空串并告警,绝不能把状态名当文本渲染(日历页出现 Text("_vrnE1") 即是此因)
@@ -925,12 +1046,12 @@ function emitNodeInner(node, ctx) {
925
1046
  (currentStateNames.has(bindKey.split('.')[0]) ||
926
1047
  (ctx.loop !== undefined && (ctx.loop.itemKey === bindKey.split('.')[0] || ctx.loop.indexKey === bindKey.split('.')[0])));
927
1048
  const isBind = bindKey !== undefined && bindValid;
928
- if (bindKey !== undefined && !bindValid) {
1049
+ if (bindKey !== undefined && !bindValid && !loopBound) {
929
1050
  ctx.warnings.push(`${ctx.path}: 绑定「${bindKey}」在原生端无对应状态/循环变量 → 输出空串(R133)`);
930
1051
  }
931
1052
  const content = props.text !== undefined ? props.text : '';
932
1053
  const fs = props.fontSize ?? 15;
933
- let line = isRef
1054
+ let line = loopBound ? pad(`Text(${swiftBoundValue(props.bind, ctx.runtime)})`) : isRef
934
1055
  ? pad(`Text(${props.ref})`)
935
1056
  : props.bindExpr !== undefined
936
1057
  ? pad(`Text(${String(props.bindExpr).includes('.') ? '""' : props.bindExpr})`)
@@ -943,7 +1064,7 @@ function emitNodeInner(node, ctx) {
943
1064
  .map((p) => 'text' in p
944
1065
  ? swiftStringEscape(p.text)
945
1066
  : 'bind' in p
946
- ? currentStateNames.has(p.bind.split('.')[0])
1067
+ ? loopBinding(p.bind) ? `\\(${swiftBoundValue(p.bind, ctx.runtime)})` : currentStateNames.has(p.bind.split('.')[0])
947
1068
  ? `\\(${p.bind})`
948
1069
  : '' // R133:无效绑定 → 空串,避免把状态名当文本
949
1070
  : false
@@ -969,16 +1090,20 @@ function emitNodeInner(node, ctx) {
969
1090
  return `Text("${swiftStringEscape(content)}")`;
970
1091
  })());
971
1092
  line += `.font(${fontExpr(fs, props.fontWeight, 'normal')})`;
1093
+ if (props.fontFamily)
1094
+ line += `.fontDesign(.${props.fontFamily === 'serif' ? 'serif' : props.fontFamily === 'monospace' ? 'monospaced' : 'default'})`;
1095
+ if (props.letterSpacing !== undefined)
1096
+ line += `.tracking(${props.letterSpacing})`;
972
1097
  // M2 :style 动态 → 运行时从 state 读取(fontSize 数值 / color hex 字符串)
973
1098
  if (props.fontSizeDynamic !== undefined) {
974
- line += `.font(.system(size: ${props.fontSizeDynamic}))`;
1099
+ line += `.font(.system(size: ${swiftBoundValue(props.fontSizeDynamic, ctx.runtime)}))`;
975
1100
  }
976
1101
  // :class 动态色 → when ? activeColor : baseColor(base 由 vue2ir 注入,缺省白)
977
1102
  if (props.colorActive) {
978
- line += `.foregroundColor(${props.colorActive.when} ? ${colorExpr(props.colorActive.color)} : ${colorExpr(props.color ?? '#ffffff')})`;
1103
+ line += `.foregroundColor(${swiftCondExpr(props.colorActive.when, conditionNames(ctx), ctx.runtime)} ? ${colorExpr(props.colorActive.color)} : ${colorExpr(props.color ?? '#ffffff')})`;
979
1104
  }
980
1105
  else if (props.colorDynamic !== undefined) {
981
- line += `.foregroundColor(Color(hex: ${props.colorDynamic}))`;
1106
+ line += `.foregroundColor(Color(hex: ${swiftBoundValue(props.colorDynamic, ctx.runtime)}))`;
982
1107
  }
983
1108
  else if (props.color !== undefined) {
984
1109
  line += `.foregroundColor(${colorExpr(props.color)})`;
@@ -986,8 +1111,9 @@ function emitNodeInner(node, ctx) {
986
1111
  if (props.textAlign !== undefined && props.textAlign !== 'left') {
987
1112
  line += `.multilineTextAlignment(${ALIGN_MAP[props.textAlign]})`;
988
1113
  }
989
- if (props.lineHeight !== undefined && props.fontSize !== undefined && props.lineHeight > props.fontSize) {
990
- line += `.lineSpacing(${props.lineHeight - props.fontSize})`;
1114
+ const lineHeight = props.lineHeightMultiplier !== undefined ? fs * props.lineHeightMultiplier : props.lineHeight;
1115
+ if (lineHeight !== undefined && lineHeight > fs) {
1116
+ line += `.lineSpacing(${Number((lineHeight - fs).toFixed(4))})`;
991
1117
  }
992
1118
  // R-FLEXALIGN:text-align 对单行 Text 无效(multilineTextAlignment 只影响多行行内对齐),
993
1119
  // 需通过 frame alignment 生效。flex:1(fillW0)或 fillW 的 frame 默认 .center → 右/左对齐丢失。
@@ -1030,13 +1156,21 @@ function emitNodeInner(node, ctx) {
1030
1156
  // R-BTNEMPTY:无事件的 button 也必须带 action 参数(Button(action:) 必填,空 action 会生成 Button() 不可编译)
1031
1157
  const action = actionExpr(props, ctx) ?? (ctx.warnings.push(`${ctx.path}: button 无 onSet/onClick,生成空 action(不可交互)`), '{}');
1032
1158
  let line = pad(`Button(action: ${action}) {`);
1033
- line += `\n${indent(L + 1)}Text("${swiftStringEscape(label)}")`;
1159
+ if (props.icon) {
1160
+ line += `\n${indent(L + 1)}${swiftVector({ vector: props.icon.vector, color: props.color }, colorExpr).split('\n').join('\n' + indent(L + 1))}.frame(width: ${props.icon.width}, height: ${props.icon.height})`;
1161
+ if (label.trim() && label !== '按钮')
1162
+ line += `\n${indent(L + 1)}Text(${props.bind ? swiftBoundValue(label, ctx.runtime) : `"${swiftStringEscape(label)}"`})`;
1163
+ }
1164
+ else
1165
+ line += `\n${indent(L + 1)}Text(${props.bind ? swiftBoundValue(label, ctx.runtime) : `"${swiftStringEscape(label)}"`})`;
1034
1166
  line += `.font(${fontExpr(fs, props.fontWeight, 'medium')})`;
1167
+ if (props.fontFamily)
1168
+ line += `.fontDesign(.${props.fontFamily === 'serif' ? 'serif' : props.fontFamily === 'monospace' ? 'monospaced' : 'default'})`;
1035
1169
  if (props.fontSizeDynamic !== undefined) {
1036
- line += `.font(.system(size: ${props.fontSizeDynamic}))`;
1170
+ line += `.font(.system(size: ${swiftBoundValue(props.fontSizeDynamic, ctx.runtime)}))`;
1037
1171
  }
1038
1172
  if (props.colorDynamic !== undefined) {
1039
- line += `.foregroundColor(Color(hex: ${props.colorDynamic}))`;
1173
+ line += `.foregroundColor(Color(hex: ${swiftBoundValue(props.colorDynamic, ctx.runtime)}))`;
1040
1174
  }
1041
1175
  else if (props.color !== undefined) {
1042
1176
  line += `.foregroundColor(${colorExpr(props.color)})`;
@@ -1058,11 +1192,15 @@ function emitNodeInner(node, ctx) {
1058
1192
  ? pad(`TextField("${swiftStringEscape(placeholder)}", text: $${props.value})`)
1059
1193
  : pad(`TextField("${swiftStringEscape(placeholder)}", text: .constant(""))`);
1060
1194
  line += `.font(${fontExpr(fs, props.fontWeight, 'normal')})`;
1195
+ if (props.fontFamily)
1196
+ line += `.fontDesign(.${props.fontFamily === 'serif' ? 'serif' : props.fontFamily === 'monospace' ? 'monospaced' : 'default'})`;
1197
+ if (props.letterSpacing !== undefined)
1198
+ line += `.tracking(${props.letterSpacing})`;
1061
1199
  if (props.fontSizeDynamic !== undefined) {
1062
- line += `.font(.system(size: ${props.fontSizeDynamic}))`;
1200
+ line += `.font(.system(size: ${swiftBoundValue(props.fontSizeDynamic, ctx.runtime)}))`;
1063
1201
  }
1064
1202
  if (props.colorDynamic !== undefined) {
1065
- line += `.foregroundColor(Color(hex: ${props.colorDynamic}))`;
1203
+ line += `.foregroundColor(Color(hex: ${swiftBoundValue(props.colorDynamic, ctx.runtime)}))`;
1066
1204
  }
1067
1205
  else if (props.color !== undefined) {
1068
1206
  line += `.foregroundColor(${colorExpr(props.color)})`;
@@ -1086,12 +1224,15 @@ function emitNodeInner(node, ctx) {
1086
1224
  parentAxis: dir,
1087
1225
  level: L + 1,
1088
1226
  definiteHeight: scrollDefinite,
1227
+ dataProps: ctx.dataProps,
1228
+ stateNames: ctx.stateNames,
1089
1229
  runtime: ctx.runtime,
1090
1230
  warnings: ctx.warnings,
1091
1231
  path: ctx.path,
1092
1232
  // 孙级透传源:scroll 自身 gap 优先,其次父级透传 gap
1093
1233
  inheritGap: props.gap ?? ctx.inheritGap,
1094
- loop: ctx.loop, // M2:v-for 作用域透传
1234
+ loop: ctx.loop,
1235
+ loopAliases: ctx.loopAliases, // M2:v-for 作用域透传
1095
1236
  }, alignRaw, props.alignItems === 'stretch');
1096
1237
  const body = pad(`ScrollView(.vertical, showsIndicators: ${props.showsIndicators ?? true}) {`) +
1097
1238
  `\n${indent(L + 1)}VStack(alignment: ${FLEX_START_MAP[alignRaw] ?? '.leading'}, spacing: ${spacing}) {` +
@@ -1101,10 +1242,13 @@ function emitNodeInner(node, ctx) {
1101
1242
  return body + box.map((m) => ' ' + m).join('');
1102
1243
  }
1103
1244
  case 'spacer': {
1104
- const h = props.height;
1105
- // height 只接受数字('fill'/'auto' 原样内插会生成非法 Swift)
1106
- const hNum = typeof h === 'number' ? h : undefined;
1107
- return pad(hNum !== undefined ? `Spacer().frame(height: ${hNum})` : 'Spacer(minLength: 0)');
1245
+ const decorated = props.background !== undefined || props.gradient !== undefined || props.radius !== undefined || props.borderWidth !== undefined;
1246
+ if (!decorated && props.flex !== undefined)
1247
+ return pad('Spacer(minLength: 0)') + box.map((m) => ' ' + m).join('');
1248
+ let code = pad('Color.clear');
1249
+ if (props.height === undefined && props.heightDynamic === undefined && props.minHeight === undefined && props.flex === undefined)
1250
+ code += '.frame(height: 0)';
1251
+ return code + box.map((m) => ' ' + m).join('');
1108
1252
  }
1109
1253
  case 'divider': {
1110
1254
  let line = pad('Rectangle()');
@@ -1113,15 +1257,25 @@ function emitNodeInner(node, ctx) {
1113
1257
  line += frameExpr('fillW');
1114
1258
  return line + box.map((m) => ' ' + m).join('');
1115
1259
  }
1260
+ case 'vector':
1261
+ if (props.aspectRatio)
1262
+ box.unshift(`.aspectRatio(${props.aspectRatio}, contentMode: .fit)`);
1263
+ return pad(swiftVector(props, colorExpr, (name) => swiftBoundValue(name, ctx.runtime)).split('\n').join('\n' + indent(L))) + box.map((m) => ' ' + m).join('');
1116
1264
  case 'image': {
1117
- const w = typeof props.width === 'number' ? props.width : 40;
1118
- const h = typeof props.height === 'number' ? props.height : 40;
1119
- // box(absolute/transform/v-show/padding)与通用分支一致拼接,避免定位/显隐静默失效
1120
- return pad(`RoundedRectangle(cornerRadius: ${props.radius ?? 0})`)
1121
- + `.fill(Color.gray.opacity(0.25))`
1122
- + `.frame(width: ${w}, height: ${h})`
1123
- + `.overlay(Text("img"))`
1124
- + box.map((m) => ' ' + m).join('');
1265
+ const source = props.src ?? '';
1266
+ let image;
1267
+ if (/^data:image\/[^;]+;base64,/.test(source)) {
1268
+ const base64 = source.slice(source.indexOf(',') + 1);
1269
+ image = `Image(uiImage: UIImage(data: Data(base64Encoded: "${base64}") ?? Data()) ?? UIImage()).resizable()`;
1270
+ }
1271
+ else if (/^https?:\/\//.test(source)) {
1272
+ image = `AsyncImage(url: URL(string: "${swiftStringEscape(source)}")) { image in image.resizable() } placeholder: { Color.clear }`;
1273
+ }
1274
+ else {
1275
+ image = `Image(uiImage: UIImage(named: "${swiftStringEscape(source.replace(/^bundle:\/\//, ''))}") ?? UIImage()).resizable()`;
1276
+ }
1277
+ image += props.objectFit === 'contain' ? '.scaledToFit()' : '.scaledToFill()';
1278
+ return pad(image) + box.map((m) => ' ' + m).join('');
1125
1279
  }
1126
1280
  case 'component': {
1127
1281
  // R41 使用点:<Name>View(prop1: "…", prop2: …) + 位置修饰(flex/width 来自组件根布局 props)
@@ -1165,6 +1319,7 @@ function emitNodeInner(node, ctx) {
1165
1319
  warnings: ctx.warnings,
1166
1320
  path: `${ctx.path}.for[${i}]`,
1167
1321
  loop: { itemKey: vf.itemKey, indexKey: vf.indexKey, indexVar },
1322
+ loopAliases: [...(ctx.loopAliases ?? []), vf.itemKey, ...(vf.indexKey ? [vf.indexKey] : [])],
1168
1323
  dataProps: ctx.dataProps, // R-VFOBJ-D
1169
1324
  }));
1170
1325
  let body;
@@ -1180,7 +1335,7 @@ function emitNodeInner(node, ctx) {
1180
1335
  body =
1181
1336
  `Group {\n` +
1182
1337
  indent(L + 1) +
1183
- `if let _vrnArr = try? JSONSerialization.jsonObject(with: Data(${vf.listState}.utf8)) as? [Any] {\n` +
1338
+ `if let _vrnArr = try? JSONSerialization.jsonObject(with: Data(${swiftBoundValue(vf.listState, ctx.runtime)}.utf8)) as? [Any] {\n` +
1184
1339
  indent(L + 2) +
1185
1340
  `ForEach(Array(_vrnArr.enumerated()), id: \\.offset) { _vrnPair in\n` +
1186
1341
  indent(L + 3) +
@@ -1220,6 +1375,8 @@ function emitNodeInner(node, ctx) {
1220
1375
  /** M7 导航栏与沉浸式:navBar 配置 → SwiftUI 修饰符(参数标准 R66) */
1221
1376
  function navBarModifiers(screen, warnings) {
1222
1377
  const nav = screen.navBar;
1378
+ if (nav?.hidden)
1379
+ return ' .toolbar(.hidden, for: .navigationBar)';
1223
1380
  if (!nav) {
1224
1381
  // R66 兜底:页面未声明 navBar 时用 IR title(多页模式 = 路由 meta.title)渲染页面级导航栏,
1225
1382
  // 对位 Android 详情页 TopAppBar(标题 + 系统返回箭头)。iOS 26 容器级 navigationTitle 不生效(R68),
@@ -1293,6 +1450,22 @@ export function generateScreenSource(screen, opts = {}) {
1293
1450
  const warnings = opts.warnings ?? [];
1294
1451
  // R-PAGESCROLL:根容器始终为可滚动视口(见 emitNode root 分支),无需内容高度启发式
1295
1452
  const dataProps = [];
1453
+ sectionNodes = new WeakMap();
1454
+ sectionSources = [];
1455
+ const count = (n) => 1 + (n.children ?? []).reduce((sum, c) => sum + count(c), 0);
1456
+ // 把较大页面的独立顶层段落拆成 ViewBuilder 属性,避免 SwiftUI 泛型表达式超时。
1457
+ if (count(screen.ui) > 6) {
1458
+ let sequence = 0;
1459
+ const split = (node, depth) => {
1460
+ const large = count(node) >= 2 || (node.props?.textRuns?.length ?? 0) > 3 ||
1461
+ (node.props?.vector?.shapes.reduce((sum, s) => sum + s.commands.length, 0) ?? 0) > 12;
1462
+ if (depth >= 2 && large)
1463
+ sectionNodes.set(node, `_vrnSection${sequence++}`);
1464
+ for (const child of node.children ?? [])
1465
+ split(child, depth + 1);
1466
+ };
1467
+ split(screen.ui, 0);
1468
+ }
1296
1469
  const body = emitNode(screen.ui, {
1297
1470
  stateNames: __stateNames,
1298
1471
  parentAxis: 'column',
@@ -1343,6 +1516,7 @@ export function generateScreenSource(screen, opts = {}) {
1343
1516
  : (() => { const nav = navBarModifiers(screen, warnings); return nav && '\n' + nav; })()) +
1344
1517
  (!opts.bindingMode && hasLogic ? '\n' + onAppearBlock(screen, rt) : ''),
1345
1518
  ' }',
1519
+ ...sectionSources,
1346
1520
  '}',
1347
1521
  '',
1348
1522
  ].join('\n');
@@ -1351,6 +1525,8 @@ export function generateScreenSource(screen, opts = {}) {
1351
1525
  * 页面级 toolbarColorScheme 保证标题浅色(iOS 26 容器级不生效);页面级不再挂 navigationTitle,
1352
1526
  * 避免 ZStack 常驻多页面时标题取第一个导致切换不跟随 */
1353
1527
  function externalNavBarTitle(screen) {
1528
+ if (screen.navBar?.hidden)
1529
+ return `\n .toolbar(.hidden, for: .navigationBar)`;
1354
1530
  return `\n .toolbarColorScheme(.dark, for: .navigationBar)`;
1355
1531
  }
1356
1532
  /** M3:onAppear 注册 @State ↔ stateStore 绑定 + 触发 onMounted(有 JS 逻辑时追加到 root 链尾)
@@ -1397,7 +1573,7 @@ function loopFieldExpr(expr, loop) {
1397
1573
  }
1398
1574
  /** M4b dynField 字段取值:元素可能是动态列表解码出的 Any → 统一 as? [String: Any] 取值(静态 [String:String] 字典同样命中) */
1399
1575
  function dictFieldExpr(varName, field) {
1400
- return `((${varName} as? [String: Any])?[\"${field}\"] as? String) ?? \"\"`;
1576
+ return `vrnItemField(${varName}, "${field}")`;
1401
1577
  }
1402
1578
  /** 事件 action 体(onSet 状态赋值优先 / onClick JS 运行时):button 用 `action: <体>`,其余节点用 `.onTapGesture <体>` */
1403
1579
  function actionExpr(props, ctx) {
@@ -1426,6 +1602,10 @@ function loopClickAction(onClick, loop, runtime) {
1426
1602
  if (!loop)
1427
1603
  return null;
1428
1604
  const itemKey = loop.itemKey;
1605
+ if (!onClick.startsWith('router.push(')) {
1606
+ const expression = /^[A-Za-z_$][\w$]*$/.test(onClick) ? onClick + '()' : onClick;
1607
+ return `_ = ${runtime}.evaluateTemplate(${JSON.stringify(expression)}, [${JSON.stringify(itemKey)}: ${itemKey}])`;
1608
+ }
1429
1609
  const re = new RegExp(`${itemKey}\\.([A-Za-z_][\\w$]*)`, 'g');
1430
1610
  if (!re.test(onClick))
1431
1611
  return null;