Skip to content

Compose statement-level View operators (e.g. Text + Text)#255

Merged
marcprux merged 1 commit into
skiptools:mainfrom
vincentborko:compose-view-operator-statements
Jun 18, 2026
Merged

Compose statement-level View operators (e.g. Text + Text)#255
marcprux merged 1 commit into
skiptools:mainfrom
vincentborko:compose-view-operator-statements

Conversation

@vincentborko

@vincentborko vincentborko commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

What

A statement-level operator expression that evaluates to a View — most notably Text + Text concatenation — was built into the view tree but never composed.

translateViewBuilder only appended a .Compose(composectx) tail call to APICallExpression statements. A bare Text("a") + Text("b") is an infix-operator (SequenceExpr) statement, so neither the operator nor its operands (whose parent is the operator, not the statement) ever got the tail call. The concatenation Text was constructed and discarded — no Compose node, zero size. Wrapping it in .frame(...) "fixed" it only because the outer .frame(...) call is an APICallExpression that gets composed.

How

  • KotlinBinaryOperator now carries its inferredType (set in translate from the Swift expression's inferred type).
  • translateViewBuilder adds a tail call for statement-level KotlinBinaryOperators whose result isSwiftUIType("View", ...) (and which aren't an assignment), wrapping the operator in KotlinParenthesized so .Compose applies to the whole result rather than binding to the right-hand operand:
(Text("a") + Text("b")).Compose(composectx)

The View-type gate means non-View operators (Int + Int, String + String, …) are unaffected.

Testing

  • swift test — added SwiftUITests.testViewProducingOperatorTailCall; full SkipSyntaxTests suite green (834 tests, 0 failures).
  • Verified end-to-end on an Android emulator (Skip Lite) via the Showcase "Text Concatenation" playground: bare, unconstrained Text + Text (per-run color, weight, italic, monospaced, mixed sizes, decorations, gradient, multi-line wrapping) all render.

Why this is the right layer

This is the Skip Lite (transpiled) fix. It unblocks the bare/unconstrained Text + Text path used by:

skiptools/skip-fuse-ui#118 is the SkipFuse counterpart of the same feature; SkipFuse compiles Swift natively (no translateViewBuilder pass) and is not affected by this change. The missing tail call was the sole reason a bare concatenation collapsed to zero size in transpiled apps.


  • AI was used to assist with this PR. The root cause was diagnosed by inspecting transpiled Kotlin output (operator statements lacked .Compose), the fix and test were authored against the existing translateViewBuilder logic, and verified with the full SkipSyntaxTests suite plus on-device rendering.

🤖 Generated with Claude Code

A statement-level operator expression that evaluates to a View — most
notably `Text + Text` concatenation — was built but never composed: the
ViewBuilder transform only added a `.Compose(composectx)` tail call to
APICallExpression statements, so a bare `Text("a") + Text("b")` was
constructed and discarded (no Compose node, zero size). Wrapping it in
`.frame(...)` "fixed" it only because the outer call then got the tail
call.

Add a tail call for statement-level binary-operator expressions whose
inferred result type conforms to View, wrapping the operator in
parentheses so `.Compose` applies to the whole result rather than its
right-hand operand: `(Text("a") + Text("b")).Compose(composectx)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label Jun 2, 2026
vincentborko added a commit to vincentborko/skip-ui that referenced this pull request Jun 2, 2026
`static func + (Text, Text)` was previously `fatalError()`, so any SwiftUI
that builds a multi-style inline string by concatenation (per-segment
color / weight / size / italic / monospaced / underline / strikethrough /
gradient) could not run on Android.

Both runtimes now feed one ordered `[TextRun]` model:

- Skip Lite (transpiled) via a native `+` operator (`Text.plus`,
  `// SKIP DECLARE: operator fun plus`), capturing each operand's styling
  as `TextRunStyle` data (a styled `Text` applies its style as an
  environment modifier that can't be read back at render time).
- SkipFuse via `init(bridgedRuns:colors:fontSizes:fontWeights:flags:)`,
  which folds primitive per-run descriptors into the same `[TextRun]`.

The concatenation *is* a `_Text` carrying its `runs`, folded into a single
`AnnotatedString` (each run's styling as a `SpanStyle`) and rendered by the
shared `_Text.Render` path — so environment concerns (alignment, line limit
+ truncation, tracking, line spacing, `material3Text`) behave like any
other `Text`, with no duplicated render logic.

Pairs with skiptools/skip-fuse-ui#118 (the SkipSwiftUI run model). Bare,
unconstrained `Text + Text` requires the transpiler fix in
skiptools/skipstone#255 to be composed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marcprux

Copy link
Copy Markdown
Member

Looks good, thanks!

@marcprux marcprux merged commit e0a0a10 into skiptools:main Jun 18, 2026
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants