Skip to content

feat(swift): theme a live view with an app's own colors - #135

Merged
tmm merged 1 commit into
mainfrom
swift-theme-live-view
Nov 14, 2026
Merged

tmm merged 1 commit into
mainfrom
swift-theme-live-view

Conversation

@tmm

@tmm tmm commented Nov 14, 2026

Copy link
Copy Markdown
Member

Closes #111, as approved in the API proposals doc: parts 1 and 2, and no Dynamic Type flag for part 3.

What changes

  • TextKitColor(uiColor:) / TextKitColor(nsColor:) (in LexicalUIKit / LexicalAppKit, so LexicalTextKit still builds on Linux). The color is resolved in the light and dark appearances into the color and its withDark variant, so a dynamic color (.label, an asset catalog color, a SwiftUI Color through UIColor(_:)) keeps following the appearance. A color that is the same in both gets no dark variant. High-contrast variants are lost, and the doc comment says so.
    • Change from the proposal: the initializers are labeled, not TextKitColor(_ color: UIColor). SwiftUI deprecated Color(_: UIColor) / Color(_: NSColor) in favor of Color(uiColor:) / Color(nsColor:), so the labels match current Apple API, and TextKitColor(.label) would read like a CSS color anyway.
  • theme property on LexicalUITextView and LexicalNSTextView. Setting a different theme draws the document again; setting an equal one edits nothing. It works like placeholder. LexicalEditor.theme(_:) now sets it, and the private applyTheme is gone. controller.theme stays for setting a theme before the first render, and its doc comment now points to the view's property.
  • No scalesWithDynamicType flag. On the iOS 27.2 Simulator, in a window pinned to the largest accessibility size (AX5), body text draws at 45 pt. With LexicalEditor(editor).dynamicTypeSize(.large) it draws at 16 pt, and with textView.traitOverrides.preferredContentSizeCategory = .large on the UIKit view it also draws at 16 pt, whether the override is set before or after the view joins the window. So the platform's own opt-out reaches the editor's text. DESIGN.md's Dynamic Type entry and the view and modifier docs now name it, and the native/dynamic-type-theme-scale deviation stays as it is.

Videos (iOS 27.2 Simulator, iPhone 17 Pro)

A UIKit LexicalUITextView gets a brand theme on a live view (19 pt navy body, pink heading, teal quote, all from dynamic UIColors). Then paragraph 2 is edited, and the system appearance switches to dark and back to light.

  • Before, on main (video): main's only way to do this is controller.theme = brand with each color resolved once. Nothing changes when the theme is set. Only paragraph 2 takes the theme once it is edited, and in dark it stays navy on black.
  • After, on this branch (video): textView.theme = brand with TextKitColor(uiColor:) draws the whole document at once. In dark every color switches to its readable dark variant.

Dark appearance, main left, branch right

Checks

  • swift format lint --strict, then swift build --build-tests && swift test --skip-build on Linux and macOS (Xcode 27.3): all green. The two known issues on macOS were already on main.
  • pnpm --filter lexical-conformance run test and check-ledger: green.
  • iOS 27.2 Simulator, -only-testing:LexicalUIKitTests: 37 tests, all pass, including the new ThemeTests.
  • New tests, which fail without the change (main has neither the API nor the redraw):
    • UIKit: UIColor to TextKitColor in light and dark, plus .label; a live theme switch redraws (size and both appearances' colors), an equal theme edits no storage, and switching back works; LexicalEditor.theme changes the live view; .dynamicTypeSize(.large) and traitOverrides keep 16 pt under AX5.
    • AppKit: NSColor to TextKitColor, plus .textColor; a live theme switch and the equal-theme no-op.

Performance (Mac satellite, main against branch, built the same way)

LexicalBenchmarks (release build, median of 3 runs each, alternating, 5,000 blocks): no change past noise. The views aren't in this benchmark, and the change moves no reconciler code.

Benchmark main p50 branch p50 main p95 branch p95
load: JSON to rendered storage 110.69 111.76 113.17 113.30
keystroke: insert a character 0.552 0.567 0.816 0.781
keystroke: split a paragraph (Enter) 0.911 0.906 1.617 1.408
select all and toggle bold 109.7 107.9 229.6 219.8
code: insert in a 200-line block 1.753 1.746 2.458 2.490

Theme switch and redraw through LexicalEditor.theme(_:) (iOS 27.2 Simulator, Debug, 1,000 paragraphs, 30 switches, 2 runs each, alternating):

main branch
switch to a different theme, p50 / p95 34.3 / 40.3 ms, 32.7 / 36.6 ms 32.6 / 38.1 ms, 35.1 / 39.6 ms
same theme again (no redraw) 0.02 ms 0.02 ms

The switch costs the same on both, because it is the same full render.

Not run locally: the iOS 26.5 Simulator (CI runs it on main after merge).

Changeset

minor (new public API), so it goes out in 0.2.0. It must not merge before Swift 0.1.10 is released.

@tmm
tmm force-pushed the swift-theme-live-view branch 2 times, most recently from 48cb366 to e4a8d01 Compare November 14, 2026 16:29
TextKitColor(uiColor:) and TextKitColor(nsColor:) make a theme color from a
UIColor or NSColor, resolved in the light and dark appearances into the
color and its dark variant, so a dynamic color keeps following the
appearance. The labels follow SwiftUI's Color(uiColor:) and Color(nsColor:),
which replaced its unlabeled initializers.

LexicalUITextView and LexicalNSTextView get a theme property that draws the
document again when it changes, and LexicalEditor.theme(_:) now sets it
instead of its private applyTheme.

There is no Dynamic Type opt-out flag: SwiftUI's .dynamicTypeSize(.large)
and UIKit's traitOverrides.preferredContentSizeCategory reach the view, as
the new tests check on the iOS Simulator. DESIGN.md and the view docs say so.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d884-217a-7785-8897-c425a5f1a37d
@tmm
tmm force-pushed the swift-theme-live-view branch from e4a8d01 to f82b028 Compare November 14, 2026 16:39
@tmm
tmm merged commit cf8538b into main Nov 14, 2026
20 checks passed
@tmm
tmm deleted the swift-theme-live-view branch November 14, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

swift: theming from an app's own fonts and colors is roundabout

2 participants