Skip to content

Text + Text concatenation via Compose AnnotatedString - #453

Open
vincentborko wants to merge 2 commits into
skiptools:mainfrom
vincentborko:feat/text-concatenation
Open

Text + Text concatenation via Compose AnnotatedString#453
vincentborko wants to merge 2 commits into
skiptools:mainfrom
vincentborko:feat/text-concatenation

Conversation

@vincentborko

@vincentborko vincentborko commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

What

Implements Text + Text concatenation with per-segment styling on Android, rendered as a single Compose AnnotatedString.

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 foreground) could not run on Android.

How

Both runtimes feed one ordered [TextRun] model, folded into a single AnnotatedString (each run's styling as a SpanStyle) and rendered by the shared _Text.Render path — so environment-level concerns (text alignment, line limit + truncation, tracking, line spacing, material3Text) behave like a normal Text, with no duplicated render logic:

  • Skip Lite (transpiled) — a native + operator (Text.plus, // SKIP DECLARE: operator fun plus). A styled Text applies its style as an environment modifier that can't be read back at render time, so each operand's styling is captured as TextRunStyle data where the modifier is applied (.foregroundColor, .font, .bold, …), mirroring SkipSwiftUI's TextRun.
  • SkipFuse// SKIP @bridge public init(bridgedRuns:colors:fontSizes:fontWeights:flags:) accepts primitive-only per-run descriptors and folds them into the same [TextRun]. The foreground resolves to a solid color when it is one, otherwise a Compose brush (gradients via SpanStyle(brush:)). localizedTextString(), asColor, and asBrush are @Composable, so they're resolved in the @Composable scope before the (non-composable) builder lambda.

Note (supersedes the original draft): the native Skip Lite + operator is implemented here now. Earlier this PR left + unimplemented and reached the run model only via the bridge; it has since been unified so Text + Text works in both Skip Lite and SkipFuse through the one _Text-with-runs path.

Dependencies / pairing

Testing

Verified on an Android emulator (Showcase "Text Concatenation" playground): per-run colors, weights, sizes, italic/monospaced, underline/strikethrough, gradient foreground, and multi-line wrapping all render as a single flowing styled block — both bare/unconstrained and width-constrained.


  • AI was used to assist with this PR.

🤖 Generated with Claude Code

@marcprux

marcprux commented Jun 2, 2026

Copy link
Copy Markdown
Member

We might consider merging this work with #434 to support the + operator in Skip Lite as well as Fuse.

@vincentborko

Copy link
Copy Markdown
Contributor Author

Nice idea, I'll update with the best of both worlds shortly! ;)

`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>
@vincentborko

Copy link
Copy Markdown
Contributor Author

all working cleanly now 👍

@marcprux marcprux left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good overall, but I'd love to see one or two test cases in SkipUI to demonstrate the concatenation.

// Text-specific implementations of View modifiers

public func accessibilityLabel(_ label: Text) -> Text {
#if SKIP

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these #if SKIP additions aren't necessary: the #else condition if never used, since this package is never compiled or used in a non-SKIP context. You should be able to just change everything to use the return styled(…) rather than gating them within the #if SKIP blocks.

@vincentborko

Copy link
Copy Markdown
Contributor Author

Added the two test cases in Tests/SkipUITests/TextTests.swift:

  • testTextConcatenationLaysOutAsSingleStringText("Hello") + Text("World") renders identically to Text("HelloWorld"), i.e. the concatenation is one laid-out string rather than two adjacent views.
  • testTextConcatenationAppliesPerSegmentFont — a concatenation of .largeTitle runs draws at the same height as the equivalent styled plain text, so each operand's captured style reaches the SpanStyle instead of falling back to the environment font.

Both stack the two subjects in one VStack(spacing: 0) and compare the halves of a single pixmap, since the compose test rule allows one setContent per test.

Verified by mutation rather than by a green run alone, each case failing only for its own reason:

mutation single-string per-segment font
plus() drops the rhs runs fails passes
flattenedRuns drops capturedStyle passes fails

swift test is green on the unmutated branch: 63 tests, 7 skipped, 0 failures, including the transpiled Robolectric suite, with :SkipUI:compileDebugKotlin succeeding.

One observation from writing these, not addressed here: a concatenation of identically styled runs is a couple of pixels narrower than the equivalent plain Text, so it centres slightly differently — Text("A").font(.largeTitle) + Text("B").font(.largeTitle) against Text("AB").font(.largeTitle). The glyphs are the same size; only the advance width differs, and only once a font is set explicitly per run (the default-font case in the first test is pixel-identical). Native SwiftUI renders the two identically, so it looks like Font.system(size:) in the SpanStyle resolving to slightly different metrics than the environment font does through the material3 path. That is why the second test compares drawn height rather than the whole pixmap. Happy to look into it separately if you would like it addressed.

Two cases demonstrating Text + Text as requested in review: the
concatenation lays out as a single string of the joined content, and
each operand's font survives into the concatenated run.
@vincentborko
vincentborko force-pushed the feat/text-concatenation branch from 0af1623 to 9b41f5c Compare July 31, 2026 17:50
@vincentborko

Copy link
Copy Markdown
Contributor Author

Correction to the note above: as first pushed, both cases failed the connected emulator step, and I have amended them to skip there.

They compare rendered pixmaps, so they run into the same per-emulator font rasterisation differences that testDrawMessage and testDrawTextMonospacedFont already opt out of in this file — on stable_default_28 the drawn height came out one row short of the reference (22 != 23) while rendering at the correct size. Both now carry the same guard and the same wording as the existing cases:

if isAndroid {
    throw XCTSkip("Disabled on Android due to inconsistent font rendering on different emulators")
}

So, precisely: the two cases execute natively and under Robolectric, and are skipped on a connected Android device or emulator. isAndroid is false on the JVM, so the Robolectric run still executes them in full — verified in the gradle report (tests=15 failures=0 errors=0 skipped=0, both cases with non-zero runtime), and the mutation results in the previous comment were measured there and still hold.

Everything else in that run was green on the emulator, including all eleven existing testTextSize* cases against the merge of this branch with current main, so the failure was specific to these two.

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