Compare commits

..
98 Commits
Author SHA1 Message Date
Bitsy 30cbbc4be1 fix(demo): mingwX64 compile — currentResidentMb via expect/actual
platform.posix.popen/pclose don't exist on mingw (Windows uses _popen/_pclose),
so the soak-test RSS helper failed :demo:compileKotlinMingwX64 (broke the v0.2.0
publish). Make currentResidentMb() an expect: posix (appleMain/linuxMain) reads
`ps -o rss=`, mingw returns -1 (the soak gate runs on macOS/Linux). Verified
:demo:compileKotlinMacosArm64 green.
2026-07-18 10:21:50 +02:00
Bitsy 4a196c9c23 feat(bridge): expose the Compose versions the port tracks; catalog as single source
The bridge now advertises the official Compose versions its vendored sources
build against, so consumers align their coords without reading compose.properties
by hand (material3 is versioned separately upstream, an easy mismatch).

- version catalog gains composeMaterial3 + composeRuntime alongside compose; the
  three become the single source consumed by the plugin and the JVM parity leg
- plugin stamps version/compose/composeMaterial3/composeRuntime into
  bridge-version.properties and exposes them via a composeDesktopNative { }
  extension (+ project.extra mirror for the settings-apply case)
- demo/apidemo/material-symbols + the ui modules now source runtime + the JVM
  forcing versions from the catalog instead of hardcoding
- fix doc bug: override property is composeDesktopNative.version (was composeSdl.version)
- READMEs updated to use the exposed versions
2026-07-18 01:41:01 +02:00
Bitsy a62a17a7bf docs: add TODO.md — audited no-ops/stubs/hardcodes + what's missing for stable 1.12
Audited project actuals (not vendored) across text input/IME, accessibility,
locale/i18n, cursor, fonts, drag-and-drop, platform info, and the SDL renderer.
Each entry carries file:line + severity. Linked from CLAUDE.md's doc map.
2026-07-18 01:24:49 +02:00
Bitsy b0530cdccb docs: consolidate renderer docs into RENDERER.md; add TOOLING.md + versioning/release runbook; clean README + CLAUDE
- README rewritten in the clean CM-inspired style (concept, quickstart, apps); links the bridge example repo
- new TOOLING.md: build-sdl, vendor sync + drift/clean, parity, probe, profiler, coverage, verify-mac, + version map and ref-bump/release runbooks
- new RENDERER.md consolidates RENDERER_CONVERGE.md + RENDERER_TASKS.md (architecture, decisions, learnings, open work); old planning docs removed
- CLAUDE.md: add a documentation map, trim tooling/build/vendor sections down to pointers
- repoint all code-comment/script references to RENDERER.md
2026-07-18 01:11:26 +02:00
Bitsy e483a4b683 Merge renderer-refactor: upstream-tracking convergence (G1) + leak fix + guardrails
Brings the renderer refactor to main. Highlights (all MAC-VERIFY green, both legs):

- B2/B6 convergence: the Skia leg (macOS/Linux) now draws through upstream Compose's
  OWN engine verbatim — SkiaBackedCanvas + SkiaGraphicsLayer + SkiaGraphicsContext
  (skiko RenderNode). Retired the hand-rolled SkiaCanvas/GraphicsLayer. Bonus perf:
  draw ~1.75ms -> ~0.2ms (real display-list caching). Text stays the port's SdlParagraph
  (skiko-metric-aligned, P3.1 ~2% parity); B6.3 (upstream text) deliberately skipped.

- Guardrails/harness (Phase 0): target-aware parity gate with golden baselines +
  render-to-quiescence; MAC-VERIFY runbook (verify-mac.sh); manual-vendor drift tripwire +
  vendor-clean check. Fixed a real HiDPI parity-harness bug (crop-vs-resize).

- Memory leak FIXED: the port never swept OwnerSnapshotObserver.clearInvalidObservations();
  disposed scopes' observations leaked (worst on ripple draws). Now swept per-frame; soak
  probe added and wired as a verify-mac gate. All-63-screens soak 260->458MB (was leaking)
  -> flat.

- Sync-tax win, proven: O.2 bump beta02 -> beta03+dev4483 needed ZERO manual-vendor
  reconciliation (the upstream graphics/layer files re-sync verbatim now).

- RenderBackend.drawRoot decoupled from ComposeRootHost (removes a ui<->ui-graphics cycle;
  the module split itself is shelved — the sdl3 cinterop is a shared substrate, infeasible
  as CONVERGE §6 specified).

CAVEAT: Windows/mingwX64 is UNVERIFIED — this whole line of work was Mac-only. WIN-SMOKE
(mingw build + scripts/probe + publish dry-run) is still required before shipping.
Stabilization: re-pin to clean v1.12.0 + close the JVM Maven skew when 1.12.0 stable ships.
2026-07-18 00:44:30 +02:00
Bitsy 23eaa5bc8b docs: O.2 second bump (beta03+dev4483) + stabilization plan (clean v1.12.0) 2026-07-18 00:40:22 +02:00
Bitsy 18286f689b chore(vendor): O.2 bump — both pins v1.12.0-beta02 -> v1.12.0-beta03+dev4483
Second real ref-bump through the O.2 runbook. No CLEAN beta03/rc/stable tag exists yet
on either repo, and beta03 isn't on Maven Central, so this is the latest DURABLE
(fetchable-by-name) ref — a beta03 dev snapshot. VENDORED (native) side only; the JVM
parity leg's Maven forcing stays at beta02 / m3 alpha03 (latest published) — a documented
native(beta03+dev) vs JVM(beta02) skew until beta03 publishes to Maven.

Flow: bump compose.properties (both pins) -> sync (1559 files) -> DRIFT-CHECK: all 9
manual vendors' upstream bases UNCHANGED beta02..beta03+dev4483 -> re-stamped their
VENDOR-BASE refs, ZERO hand-reconciliation (the B2/B6 sync-tax win, validated for real:
the newly-upstream Canvas/GraphicsLayer/GraphicsContext re-synced verbatim) -> builds
green (ui both legs + metadata + material3) -> full MAC-VERIFY ALL GREEN both legs
(demo+apidemo, 10 probes incl. soak gate, parity PASS both legs, perf, vendor-clean).

Windows WIN-SMOKE still pending (no Windows host this session).

STABILIZATION PLAN (user): cut a stable compose-desktop-native when Compose 1.12.0
STABLE releases — at that point re-pin both refs to the clean `v1.12.0` tag and bump the
vComposeJvmVersion forcing (demo/apidemo/material-symbols) to match, closing the skew.
2026-07-18 00:39:41 +02:00
Bitsy 319c550af4 docs: module split re-shelved — sdl3 cinterop is a shared substrate (infeasible as §6 specified) 2026-07-18 00:03:33 +02:00
Bitsy c7608b6404 docs: module split un-shelved — cycle finding + staged plan, step 1 (decouple) done 2026-07-17 23:51:07 +02:00
Bitsy c71febbb23 refactor(ui): decouple RenderBackend.drawRoot from ComposeRootHost (module-split step 1)
Prerequisite for extracting :ui-graphics. The render backends (SkiaRenderBackend /
Sdl3RenderBackend, which would move to :ui-graphics) referenced
com.compose.sdl.node.ComposeRootHost (which stays in :ui), while :ui node code
references androidx.compose.ui.graphics.Canvas (:ui-graphics) — a ui<->ui-graphics
dependency cycle §6 didn't account for (it assumed pure relocation).

RenderBackend.drawRoot now takes a (Canvas) -> Unit instead of the host: the backend
builds the frame Canvas and hands it to the callback; ComposeWindow passes
`{ canvas -> host.drawRoot(canvas) }`. Backends + RenderBackend no longer reference the
node/host layer, so they can live in :ui-graphics with no back-edge to :ui.

Behaviour-preserving (inDraw == host::drawRoot). Verified: both legs + metadata compile,
demo builds, nav3/click/scroll probes PASS, skia parity PASS.
2026-07-17 23:50:21 +02:00
Bitsy 5ab3bd6b97 docs+ci: P2.2 done — leak fixed, soak wired into verify-mac gate 2026-07-17 23:34:14 +02:00
Bitsy c59caf7272 fix(ui): sweep invalid snapshot observations each frame — fixes the P2.2 leak
ROOT CAUSE (found via the --soaktest reproducer + macOS heap/vmmap/leaks + a
component bisection): the port never called
`OwnerSnapshotObserver.clearInvalidObservations()`. Upstream RootNodeOwner runs it
after every measure pass; without it, snapshot read-observations whose scope became
invalid on dispose (detached nodes, destroyed layers, disposed DRAW scopes) linger in
the observer forever. Each lingering observation pins its observed object graph via a
K/N ExternalRCRef, so every composition dispose leaked — most visibly on ripple
indication draws (each Button read animatedAlpha/colors in draw, whose layer draw-scope
observation was never swept), pinning the whole button subtree.

FIX: ComposeRootHost.measureAndLayout() now calls
`fOwner.snapshotObserver.clearInvalidObservations()` after the measure/layout pass,
mirroring upstream. (Complements the earlier onDetach `snapshotObserver.clear(node)`.)

RESULT (soak, current RSS which drops after GC): the leak is GONE.
  Counter   123->176MB  =>  113->113MB
  Images    132->199MB  =>  118->119MB
  LazyGrid  133->204MB  =>  113->115MB
  ALL 63 screens x6  260->458MB (FAIL) => 238->240MB (PASS)
Diagnosis narrowed it: not layers/RenderNodes (live counts flat), not nodes
(attach/detach balanced), not per-frame (static flat), not caches (bounded), not true
malloc leaks (`leaks` clean) — the growth was entirely the K/N GC heap (vmmap
Memory Tag 246), pinned by ExternalRCRefs from unswept observations.

MAC-VERIFY ALL GREEN both legs (10/10 probes, parity PASS, perf unchanged —
clearInvalidObservations is a cheap per-frame validity sweep). Soak diagnostics
(--soaktest: current RSS, CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES) retained.
2026-07-17 23:32:59 +02:00
Bitsy c36f11ab4f docs: P2.2 focused session — onDetach observation-clear fix + residual leak characterized 2026-07-17 22:24:27 +02:00
Bitsy 7189433e5b fix(ui): clear detached node's snapshot observations (partial P2.2)
ComposeOwner.onDetach now calls snapshotObserver.clear(node), exactly as upstream
RootNodeOwner.onDetach does. Without it, every disposed LayoutNode's measure/layout/
draw read-observation scopes accumulated in the OwnerSnapshotObserver forever — an
unbounded (if small) growth over a long session. Matches upstream verbatim; MAC-VERIFY
ALL GREEN both legs (10/10 probes, parity PASS, perf unchanged).

Also lands the P2.2 leak-hunt diagnostics on --soaktest:
  - current RSS (via `ps`, drops after GC) instead of getrusage peak (monotonic) —
    the correct leak metric; verdict now uses it.
  - CDN_SOAK_SCREEN=<name> to bisect one screen; CDN_SOAK_STATIC=1 to isolate
    per-frame vs per-mount.

Leak-hunt status (documented in RENDERER_TASKS.md): the residual leak is real, linear,
~141KB/mount (Counter), referenced (macOS `leaks` shows no malloc leaks in our code),
survives GC, and is NOT nodes/layers/RenderNodes (all balanced by exact count),
per-frame (static flat), or the skia GrContext budget (linear, no plateau). It needs a
skia-aware heap profiler to pin exactly — a follow-up. This observation-clear fix is one
confirmed contributor fixed.
2026-07-17 22:23:56 +02:00
Bitsy dadb9e45d4 docs: P2.2 leak hunt characterized + D2-D6 evaluated (all deferred, rationale) 2026-07-17 21:44:20 +02:00
Bitsy c1fabc5b5c test(demo): P2.2 leak hunt — soak single-screen bisect mode + characterization
Added CDN_SOAK_SCREEN=<name> to the soak (repeat ONE screen 40x/cycle) + key(vShown)
to force a full dispose+recompose each advance, to bisect the leak the soak found.

Leak hunt findings (a real, pre-existing, slow leak — reproducer in --soaktest):
  - Layers are CLEAN: a live-OwnerLayer counter stayed flat (5/7/21/24) while RSS
    climbed — destroy()->releaseGraphicsLayer() runs correctly. (Instrumentation used
    to prove this, then reverted to keep the vendored GraphicsLayerOwnerLayer clean.)
  - Not GC/cleaner lag: Counter over 10 cycles climbs ~linearly 114->176MB (no plateau).
  - Not animation churn: persists with infinite animations disabled.
  - Bisected: a BASELINE composition-machinery leak (~0.15MB/mount, hits even trivial
    Counter) + CONTENT amplification on image/vector/list screens (Images/LazyGrid
    ~0.55MB/mount). Both legs; SDL ~5x worse (offscreen textures).
  - Ruled out: the image decode caches (SkiaImageCache, components-resources
    ResourceCaches) are BOUNDED (keyed by path/id; revisits hit, no growth).
  - Remaining suspects (need heap tooling, out of scope here): composition/snapshot
    observation retention (baseline) + DrawCache/vector offscreen bitmap lifetime
    (content). A precise fix is a dedicated focused session.

Soak stays a standalone diagnostic (not a verify-mac gate) until the leak is fixed.
2026-07-17 21:41:23 +02:00
Bitsy 5def89f182 docs: Phase 2 — P2.1 audit done, P2.3 done via B6.2, P2.2 soak added (found pre-existing leak) 2026-07-17 21:18:00 +02:00
Bitsy 4bd9ca234e test(demo): P2.2 soak probe — cycle all screens x N, track peak RSS
--soaktest cycles through every registered screen kCycles times (CDN_SOAK_CYCLES
override, default 3) in ONE process, disposing each via a changing key() so
composition + layer (skiko RenderNode / SDL node + texture) allocate/release churn
is exercised repeatedly. Disables infinite animations + uses virtual frame time so
RSS reflects lifetime, not animation churn. After each full cycle: GC.collect() then
record peak RSS (getrusage ru_maxrss, MB; macOS-bytes/Linux-KB disambiguated by
magnitude). Verdict: PASS iff last-cycle peak stays within a ceiling of cycle-1 peak.

FINDING (both legs): the probe reveals a real pre-existing memory leak. Peak RSS
climbs ~linearly and does NOT plateau over 6 cycles: skia 264->458MB (~40MB/cycle),
SDL 523->916MB over 3 (~195MB/cycle). SDL (untouched by B6) leaks ~5x worse, so it
is NOT B6-introduced. Persists with animations disabled, so it is composition/layer/
cache teardown, not animation-state churn; SDL's larger delta points at offscreen
textures. The layer release path (destroy -> releaseGraphicsLayer -> close) is wired,
so it's deeper — needs a dedicated heap-profiled hunt. Kept as a standalone diagnostic
(NOT wired into verify-mac's gate) until the leak is fixed.
2026-07-17 21:16:57 +02:00
Bitsy 924229fafe docs: B6.3 skip decision + P1.8 sync-tax measured (~8.5x cut) — B2 convergence complete 2026-07-17 20:57:51 +02:00
Bitsy 2314fca8ee docs: B6.2 done (= P1.4-P1.7) — upstream GraphicsLayer on skia; perf 1.75ms->0.2ms 2026-07-17 20:14:11 +02:00
Bitsy 2210d1fa01 feat(ui): B6.2 — Skia leg uses upstream GraphicsLayer + GraphicsContext
The core B2 convergence (RENDERER_CONVERGE.md §4; = P1.4/P1.5/P1.6/P1.7). Now the
skiko canvas is upstream's SkiaBackedCanvas (B6.1), un-refuse + vendor upstream's
own layer engine VERBATIM: SkiaGraphicsLayer.skiko.kt (actual class GraphicsLayer
backed by org.jetbrains.skiko.node.RenderNode), SkiaGraphicsContext.skiko.kt
(owns a RenderNodeContext), Blur.skiko.kt. ChildLayerDependenciesTracker was already
vendored (commonMain), so SkiaGraphicsLayer compiles verbatim — no manual edit.

Per-renderer wiring (disjoint targets):
  - GraphicsContext seam (P1.3): skiko createGraphicsContext -> SkiaGraphicsContext;
    SDL keeps ProjectGraphicsContext (moved nativeMain -> sdlRendererMain along with
    the createProjectGraphicsLayer factory, now a plain internal fun; the commonMain
    expect is dropped — only SDL uses it).
  - Blur.native.kt (port no-op) moved nativeMain -> sdlRendererMain; skiko gets
    upstream Blur.skiko.
  - Deleted the transient skiko port cluster: GraphicsLayer.native.kt,
    NativeRenderNode.kt, DeferredRenderNode.kt, NativeRenderNode.skia.kt,
    GraphicsLayerFactory.native.kt.

GraphicsLayerOwnerLayer stays SHARED and compiles against BOTH GraphicsLayer actuals
(the API-parity invariant, P1.7 — enforced by the metadata compile). Hit-testing
(shared prepareLayerTransformationMatrix) agrees with the new skiko-RenderNode draw
transform — clicktest/scrolltest/backtest PASS on transformed layers.

MAC-VERIFY ALL GREEN both legs: builds (demo+apidemo), 10/10 probes, parity PASS
(layer screens GraphicsLayer/Shadows/DragAndDrop/Animation byte-stable — the swap is
visually transparent), metadata + drift + vendor-clean pass. PERF WIN: skia draw
1.75ms->0.2ms on LazyColumn/Tabs (skiko RenderNode record-once/replay display-list
caching vs the port's per-frame replay).
2026-07-17 20:12:36 +02:00
Bitsy 746d3cca9c docs: B6.1 done — Skia leg on upstream SkiaBackedCanvas, MAC-VERIFY green 2026-07-17 00:24:21 +02:00
Bitsy 7f5773fc3c feat(ui): B6.1 — Skia leg draws through upstream SkiaBackedCanvas
Full upstream Skia GRAPHICS pipeline on the skiko leg (RENDERER_CONVERGE.md §4,
the enabler for B2). The composition's frame + offscreen drawing now goes through
upstream's SkiaBackedCanvas / SkiaBackedPaint / SkiaShader / SkiaImageAsset /
ImageBitmap.skiko instead of the project SkiaCanvas.

Split the port graphics actuals per-renderer (createRenderBackend disjoint-target
trick): CanvasPaintActuals.native.kt (Paint/Shader/all gradient shaders/
ActualImageBitmap/createImageBitmap/ActualCanvas/NativeCanvas/NativePaint) and
BlendMode.native.kt move nativeMain -> sdlRendererMain (SDL keeps the port flavor);
the skiko leg gets the un-refused upstream files (manifest).

Text/painter/shadow keep the port engine: SkiaBackedCanvas is MANUAL-VENDORED to
also implement NativeTextCanvas/NativePainterCanvas/NativeShadowCanvas/
NativeFinishableCanvas, forwarding to the port SkiaTextRenderer (P3.1 metrics
preserved) + SkiaImageCache via a global skiaLeafDrawer over internalSkiaCanvas.
SkiaImageAsset manual-vendored too (flattened toBitmap/putBytesInto expect/actuals
that would collide in our single native source set). SkiaCanvas.kt deleted; the
offscreen (VectorPainter/DrawCache) now uses upstream's ActualCanvas + the encoded
decoder produces an upstream ImageBitmap.

skiaLeafDrawer is per-WINDOW state re-pointed each frame in drawRoot (+ cleared on
destroy) — mirrors ComposeWindow.installGlobals(); a ctor-set version dangled at a
closed window's destroyed renderer and crashed the surviving window (caught by
multiwintest).

MAC-VERIFY ALL GREEN both legs: builds (demo+apidemo), 10/10 probes, parity PASS
(56/57 byte-identical to baseline; Images within noise), perf within/below baseline,
metadata compile clean, drift + vendor-clean checks pass. B2 (P1.4/1.6) unblocked.
2026-07-17 00:23:31 +02:00
Bitsy 90f56143c4 docs: B6 decision (Option B) + staged plan (B6.1 graphics / B6.2 layer / B6.3 text) 2026-07-16 23:39:40 +02:00
Bitsy ab4b82e492 docs: P1.3 done; P1.4 blocker — SkiaBackedCanvas closure gap + 3 options 2026-07-16 23:27:56 +02:00
Bitsy 4a3d8e5ca2 refactor(ui): P1.3 — fork GraphicsContext behind a per-renderer factory seam
B2 convergence step 2 (RENDERER_CONVERGE.md §4). ComposeOwner.graphicsContext
was an ad-hoc anonymous GraphicsContext calling createProjectGraphicsLayer().
Extracted into createGraphicsContext() — an internal expect in nativeMain with an
actual per renderer source set (disjoint targets, the createRenderBackend trick):

  - ProjectGraphicsContext (shared nativeMain): the project record/replay context,
    layer create/release via the createProjectGraphicsLayer factories.
  - sdl actual -> ProjectGraphicsContext (permanent SDL home).
  - skiko actual -> ProjectGraphicsContext (TRANSIENT; P1.6 swaps in the vendored
    upstream SkiaGraphicsContext + skiko RenderNodeContext).

Behaviour UNCHANGED — both legs return the same ProjectGraphicsContext this step.
MAC-VERIFY ALL GREEN (both legs build, metadata compile clean, 10/10 probes PASS,
parity PASS both legs, perf within baseline).
2026-07-16 23:26:14 +02:00
Bitsy 7f7f57d06d docs: P1.2 done — SDL node cluster relocated, MAC-VERIFY green 2026-07-16 23:12:45 +02:00
Bitsy c45aed105c refactor(ui): P1.2 — relocate SDL node cluster nativeMain -> sdlRendererMain
B2 convergence step 1 (RENDERER_CONVERGE.md §4). The port's actual class
GraphicsLayer lived in shared nativeMain; it cannot coexist there and in
skikoRendererMain, so the whole node cluster moves down into the renderer
source sets (which attach to disjoint targets — the createRenderBackend trick):

  GraphicsLayer.native.kt, NativeRenderNode.kt (interface + context),
  DeferredRenderNode.kt, GraphicsLayerFactory.native.kt (createProjectGraphicsLayer
  actual) -> sdlRendererMain.

The internal expect fun createNativeRenderNode in nativeMain is dropped (its
return type NativeRenderNode is no longer shared); the two per-renderer factories
become plain internal funs. createProjectGraphicsLayer stays a commonMain expect
with an actual now in each renderer set.

Behaviour UNCHANGED: both legs still run the port GraphicsLayer this step — the
Skia leg keeps a TRANSIENT copy of the cluster in skikoRendererMain (deleted in
P1.6 when it switches to upstream SkiaGraphicsLayer + skiko RenderNode).
GraphicsLayerOwnerLayer / ComposeOwner stay SHARED (they only touch GraphicsLayer's
public expect API + createProjectGraphicsLayer).

MAC-VERIFY green: Skia + SDL legs build, metadata compile clean (Windows-publish
trap), 10/10 probes PASS, parity PASS both legs, perf within baseline.
2026-07-16 23:12:15 +02:00
Bitsy 7e829ef87b docs: P1.1 done — skiko:0.150.1 exposes node.RenderNode API (no bump needed) 2026-07-16 22:52:02 +02:00
Bitsy cc9940c705 docs: P0.2 done + first-Mac-session log (P0.3/P0.4 Mac keys, HiDPI harness fix) 2026-07-16 22:49:12 +02:00
Bitsy bb0b5a9a27 test(parity): seed macosArm64/sdl3 golden baselines (57 screens)
Both Mac legs now gate. SDL leg on macOS shows the same ~2% golden-master
median as Skia after the P3.1 text-metric fixes + the HiDPI align fix;
Pickers 19.5 the known TimeInput auto-scroll outlier.
2026-07-16 22:48:17 +02:00
Bitsy 5686e8785f fix(parity): normalize native HiDPI render to JVM reference size before diff
On a Retina Mac the native leg renders at physical density (2000x1400,
DPR 2.0) while the JVM ImageComposeScene reference is fixed at density 1.0
(1000x700). Both represent the same 1000x700-dp viewport, but diff_pair /
side_by_side cropped the top-left min(w,h) instead of resizing, so the
native leg was compared as a 2x-magnified top-left quarter. Invisible on
Windows (both legs 1000x700); on macOS it inflated every screen to 24-78%.

align_native() resizes native down to the JVM size (LANCZOS, no-op at
DPR 1.0). Skia-leg parity collapses to the expected golden-master: Buttons
35->1.96%, Counter 61->0.65%, LazyGrid 78->1.77%; ~2% median.

Seeds macosArm64/skia baselines (57 screens).
2026-07-16 22:38:44 +02:00
Bitsy 3da62b654e docs: P3.1 tracker — post-metrics ranking + h=1.0 boundary log entry 2026-07-16 22:08:05 +02:00
Bitsy b6fb974264 fix(text): lineHeight applies only when STRICTLY greater than the font size
Probe-verified boundary: upstream ignores lineHeight at exactly 1em
(24sp/24lh -> the 33px cell; 24sp/25lh -> 25) — the >= gate applied the
band at h=1.0, off-by-cell on every Text(fontSize = lineHeight) row.
Text screen parity 9.19 -> 3.08; all other screens byte-identical.
Boundary cases added to both metrics probes; baselines re-seeded.
2026-07-16 22:07:32 +02:00
Bitsy 07bfd84810 feat(text): P3.1 — match upstream text metrics exactly; parity median 17% -> 2%
The port's whole 'font-drift baseline' turns out to be line-metric drift,
not typeface difference. Driven by a new matched metrics-probe pair
(demo --metricsprobe native / --metrics JVM), three changes make
SdlParagraph's numbers agree with upstream Paragraph on every probed
config:

1. Font cell = round(hhea_ratio x size), ratios read once per family from
   the face tables via a metrics-only FreeType library (SDL3_ttf only
   exposes grid-fitted CEILED ints — +1px at the M3 body sizes 12/14,
   ~1px per stacked label of accumulating drift). Icon families keep the
   TTF path so icon boxes don't shift.
2. lineHeight application is MODE-dependent: raw styles (lineHeightStyle
   unset) use compat trim — first line keeps the tight font cell,
   lineHeight is the advance between lines; M3 styles (Trim.None, which
   every M3 Typography style sets) use UNIFORM bands — every line exactly
   lineHeight, even compressing below the font cell (probe: 22sp/28lh -> 28).
   Threaded through both renderers' draw paths as inTrimFirstLine.
3. lineHeight smaller than the font size is ignored (probe: 48sp/24lh ->
   65px cell) — upstream skia only applies height multipliers >= 1em.

Parity collapse: Tabs 33.3->1.8, GridsExtra 28.5->1.3, Shadows 24.6->1.5,
Brushes 22.3->2.2, Animation 24.5->3.0; 'gradient ramp' and 'shadow
falloff' were also mostly drift. Remaining outliers: Pickers 19.5
(auto-scroll positioning), Images 16.6, Text 9.2, AnnotatedString 8.1.

Verified on Windows: full 57-screen parity PASS, 9/9 interaction probes
PASS, baselines re-seeded at the collapsed levels. skiko-side mirrors
(SkiaCanvas/SkiaTextRenderer) compile-pend the first Mac run.
2026-07-16 22:02:15 +02:00
Bitsy 2db54e0e19 feat(text): P3.1 fix #1 — honor TextStyle.lineHeight (compat-trim), the top parity gap
SdlParagraph ignored TextStyle.lineHeight entirely (every M3 Typography
style sets one, e.g. bodyMedium 14sp/20sp), so multi-line paragraph
heights diverged from upstream by ~1px per wrapped line — the
accumulating vertical drift that dominates every text-bearing screen's
parity diff.

Implemented with upstream's compat-trim semantics (LineHeightStyle.Trim
of the first line): the first line keeps the tight font cell; lineHeight
is the advance for every following line. Single-line labels are
unchanged — a uniform-band first attempt regressed Counter 2.53->9.18
in the parity gate and was rejected.

The band is threaded as inLineHeightPx through NativeTextCanvas into
both renderers' draw paths (Sdl3Canvas + Sdl3TextRenderer; SkiaCanvas +
SkiaTextRenderer textually — skiko leg compile pends the first Mac run)
so glyph rows stack exactly where layout put the line boxes.

Verified on Windows: full 57-screen parity PASS — LazyExtra 30.00->27.74,
AnnotatedString 24.88->24.59, all 55 other screens byte-identical;
paragraph/key/click/scroll/back probes PASS; baselines re-seeded.
Remaining text-drift term (single-line label cells: TTF integer font
height vs Skia fractional ascent+descent) recorded as the next P3.1
candidate in RENDERER_TASKS.md.
2026-07-16 21:35:58 +02:00
Bitsy a2252a2ede chore(vendor): VENDOR-BASE provenance mandatory + retroactive (all manual vendors)
Per review guidance: every manually-vendored (copy-edited) file must carry
// VENDOR-BASE: <upstream-path> @ <pinned-tag> so it can be diffed against
its base and against later upstream versions. CLAUDE.md vendoring rule 3
now codifies it.

Retro-annotated the 4 manual vendors that predated the convention — both
Synchronization.kt copies (K2 @PublishedApi edit), foundation's
SelectionLayout.kt (OOB clamp fix), components-resources' DomXmlParser.kt
(fresh reimpl; base marks the upstream API it tracks) — each stamped
v1.12.0-beta02 after verifying its upstream base is unchanged
beta01..beta02 in the repo's clone.

check-vendor-drift.py extended: VENDOR-BASE(<VARNAME>) form resolves
non-core pins (COMPOSE_REF -> ../cmp-ref-compose-multiplatform clone),
and a ls-tree existence guard turns a renamed/moved base into 'diff
unavailable' instead of a silent 'unchanged'. 7 files tracked, all OK;
simulated stale umbrella ref fails exit 1 with the correct clone verdict.
2026-07-16 21:22:02 +02:00
Bitsy fcc581d48b chore(vendor): bump both upstream pins to v1.12.0-beta02 (O.2 first run)
compose-multiplatform-core + compose-multiplatform umbrella both pinned to
the v1.12.0-beta02 release tags; Maven side moved in lockstep (catalog
compose=1.12.0-beta02; JVM parity forcing 1.12.0-beta02 core groups /
1.12.0-alpha03 material3 — released pairs confirmed on Maven Central, no
+dev suffix needed anymore).

sync.py fix the bump exposed: fetching a pin that is a TAG created after
the clone only reached FETCH_HEAD, so the checkout failed. Now fetches
refs/tags/x:refs/tags/x (keeping future syncs offline) with a plain-fetch
+ FETCH_HEAD fallback for branches/SHAs.

DRIFT-CHECK: all 3 manual vendors flagged by the P0.6 tripwire; the clone
diff proved each upstream base UNCHANGED base..pin, so their VENDOR-BASE
refs were re-stamped (no reconciliation needed).

Verified on Windows: demo+apidemo build on both legs with zero code
fallout; 8/8 interaction probes PASS; full parity PASS against the
beta01-era baselines with 56/57 screens at their exact percentage (only
GraphicsLayer +0.01pt); baselines re-seeded. Mac legs pend P0.2's first
run. :ui manifest annotation refresh picked up 2 new upstream desktopMain
files as commented gaps (AWT-only; not vendored).
2026-07-16 21:04:44 +02:00
Bitsy ec3bc51346 chore(vendor): O.1 — pin COMPOSE_CORE_REF to the durable tag v1.12.0-beta01+dev4324
Tag verified to dereference to the previously pinned hash 1be9d64 and to
exist on upstream's remote, so any fresh clone can fetch the pin by name
(a bare unreferenced SHA could stop being fetchable if upstream GCs).
VENDOR-BASE headers re-spelled to the tag so check-vendor-drift's
ref-compare stays green. Re-sync at the tag is byte-identical
(check-vendor-clean PASS, 1553 files).
2026-07-16 20:51:48 +02:00
Bitsy 08a14d79cb feat(vendor): P0.7 — vendor-clean check (src/vendor must match the pinned refs)
check-vendor-clean.py hashes every module's src/vendor tree, re-runs
sync.py (which regenerates byte-for-byte from the pinned clones, deleting
stale files), re-hashes and fails exit 1 on any changed/stale/missing
file or manifest-annotation churn — the tree ends up restored to the pin
either way. Wired into verify-mac.sh as step 0 alongside the P0.6
manual-vendor provenance tripwire.

Verified on Windows: clean PASS exit 0 (1553 files / 15 modules / both
upstream repos at their pins); simulated hand-edit + orphan file FAIL
exit 1 listing both, immediate re-run PASS.
2026-07-16 20:48:12 +02:00
Bitsy 812063a320 feat(verify): P0.2 (WIP) — one-command MAC-VERIFY runbook (scripts/verify-mac.sh)
Chains the MAC-VERIFY primitive for both renderer legs on a macOS/Linux
host: build :demo+:apidemo -> nav3/back/click/scroll/multiwin probes
(gated on PASS output) -> parity.py per leg -> CDN_PROFILE draw-ms
spot-check on LazyColumn/Tabs against a self-seeded perf baseline
(build/verify-mac/perf-baseline.txt, +20% regression gate,
--update-perf-baseline to reseed). Non-zero exit on any failure.

Authored + bash -n / parse-logic smoked on Windows; end-to-end run
pending the first Mac session (which also seeds the perf baselines and
the parity macosArm64/{skia,sdl3} baseline keys).
2026-07-16 20:44:55 +02:00
Bitsy 983cafc9b9 feat(parity): P0.5 — render-to-quiescence capture, deterministic on both legs
Native: windowHasInvalidations() (needsFrame + recomposer.hasPendingWork +
both BroadcastFrameClocks' hasAwaiters) queryable from onFrame probes;
disableInfiniteAnimations injects a cancelling InfiniteAnimationPolicy into
each window's Recomposer context (upstream's test mechanism); and
useVirtualFrameTime steps the composition/animation clocks a fixed 16.6ms
per loop iteration — the native mirror of the JVM leg's render(nanos).
Quiescence alone left Pickers drifting ±10px per run (TimeInput focus →
bring-into-view scroll racing real-time frames); virtual time makes the
race resolve identically every run.

JVM: screenshotAllScreens steps a virtual 60fps clock via render(nanos)
until hasInvalidations() clears (cap 300), same cancelling policy through
ImageComposeScene's coroutineContext.

Demo: --screenshot captures after 3 consecutive quiescent frames;
--frames=N is now the quiescence CAP (default 300), not a fixed capture
point.

Verified on Windows: Pickers/Animation/Buttons byte-identical across 3
native runs (Pickers settles at exactly frame 12); all 57 JVM screens
byte-identical across 2 sweeps; 5 interaction probes PASS (flags inert
outside screenshot mode); parity PASSed against the OLD frame-6 baselines
(55/57 identical %), re-seeded mingwX64/sdl3, fresh full run PASS exit 0.
2026-07-16 20:42:09 +02:00
DWA 5e05f0d354 feat(parity): P0.4 — parity.py is now a gate (baselines + non-zero exit)
Per-(target/renderer) golden baselines in scripts/parity/baselines.json, a
--update-baselines seeder, and a non-zero exit when a screen exceeds baseline+max(3pts,25%)
or NATIVE FAILED — so parity can actually gate a change instead of only ranking. Report now
shows baseline + verdict per screen. Seeded a full 57-screen mingwX64/sdl3 baseline.
Output made ASCII-only (Windows cp1252 console crashed on the em-dash/arrow).

Verified on Windows: PASS exit 0, simulated regression exit 1, restore clean.
2026-07-16 17:58:00 +02:00
DWA 73d98a3dc3 feat(parity): P0.3 — font-align the JVM parity leg with the bundled NotoSans
Stage font/NotoSans.ttf onto the demo JVM classpath (jvmProcessResources) and load it in
MainJvm's ScreenHost — applied via an M3 Typography copy (all core styles) + LocalTextStyle
so both Material and BasicText render NotoSans, matching the native leg's default font.

Verified on Windows: staged + loads + applies (no not-aligned warning). Finding: on the SDL
leg parity is dominated NOT by typeface but by an accumulating vertical line-metric delta
(SDL3_ttf NotoSans line height vs Skia) — so the SDL % barely moves; the ~0% golden-master
payoff is Skia-leg (Mac) only. Confirms the CONVERGE §1 layout-engine delta.
2026-07-16 17:52:22 +02:00
DWA 7a78e5e7a6 feat(vendor): P0.6 — manual-vendor drift tripwire
Add machine-readable VENDOR-BASE: <upstream-path> @ <ref> provenance to the three renderer
manual-vendors (GraphicsLayerOwnerLayer, GraphicsLayer.native, LayerTransformationMatrix) +
scripts/compose-fork/check-vendor-drift.py: scans for VENDOR-BASE lines, flags any whose
recorded ref lags the current COMPOSE_CORE_REF pin (optional upstream base..pin diff when a
../cmp-ref clone is present), exits non-zero on staleness so it can gate a ref bump. Also
refreshed the stale RENDERER_REFACTOR.md refs in those headers → RENDERER_CONVERGE.md.
Verified on Windows: clean exit 0, simulated stale exit 1.
2026-07-16 17:42:53 +02:00
DWA f6164e7cf0 feat(parity): P0.1 — make parity.py target-aware (both renderers, any host)
Unhardcode the mingwX64 exe: infer the host's native target by default (Windows→mingwX64
so existing usage is byte-identical), add --target= and --renderer= so macOS/Linux can run
BOTH the Skia leg (default) and the SDL leg (-Prenderer=sdl3). Compute the link task + exe
path (.exe vs .kexe) + default GPU driver from those. Verified on Windows: default run
intact (Buttons 16.47%); macOS legs resolve correctly. RENDERER_TASKS.md P0.1 checked.
2026-07-16 17:37:28 +02:00
DWA 43f88c7866 docs: add RENDERER_TASKS.md (executable checklist) + lock goal to G1 upstream-tracking
RENDERER_CONVERGE.md = study file (why/decisions/analysis); RENDERER_TASKS.md = pure
checkbox task tracker an agent progresses over time (status marks, done-criteria, blocked-by,
Mac-verify primitive, progress log). §0.5 goal locked to G1 (upstream-tracking): B5+B2 lead,
guardrails first, JVM = Windows fidelity tier, B3 capped, Track A shelved, B1 + module split
deferred.
2026-07-16 17:28:15 +02:00
DWA 70602e4c91 docs(renderer-converge): macOS/Linux run BOTH renderers — one box verifies everything
Correct the verification framing: macOS/Linux build the Skia leg (default) AND run the SDL
leg under -Prenderer=sdl3, so the Mac (or a Linux box) is a single machine that verifies
both renderers — not just the Skia leg. Windows is only needed for the shipped mingw target
binary, the Windows-only PrintWindow probe, and the tag-time metadata publish. Updated §5
runbook, the why-it-suffices note, and §8.
2026-07-16 17:22:18 +02:00
DWA 653f32e08c docs(renderer-converge): fold in 5-agent council + switch verification gate to a local Mac runbook (no CI)
Council corrections folded into RENDERER_CONVERGE.md: add §0.5 goal/audience (the root
finding — priority is undefendable without it); restate Rule 3 (bespoke vs vendored-upstream
surface); correct the North Star (Skia-leg text is the shared SdlParagraph, not upstream —
new B6 option / stated axiom); re-rate B2 from file-flip to a source-set MIGRATION (relocate
the SDL node cluster out of nativeMain, fork the ad-hoc GraphicsContext, +Matrices.skiko/
Blur.skiko closure, skiko-version DoD, keep owner layer shared); DECIDE the lifetime model
(shared GC on both legs); enumerate the module-split build fan-out + cohesion cost; make §8
verification concrete (font-aligned golden-master, render-to-quiescence, cross-platform
self-tests, drift tripwire, actual-API-parity invariant); add §9 risks (skiko drift, +dev-ref
fragility, permanent ~3,800-line SDL surface); add §13 council synthesis.

Maintainer opted out of CI (slow); the Phase-0a gate is now a LOCAL MAC VERIFY RUNBOOK
(the Mac builds the Skia leg the Windows box can't) instead of a GitHub CI floor.
2026-07-16 17:19:13 +02:00
DWA a98068ba09 docs: consolidate RENDERER_REFACTOR into RENDERER_CONVERGE (Appendix A), delete the old doc
The retained-layer engine model, the requiresLayer() compositing contract, what
landed (the geo-node story), and the hard-won learnings are preserved as Appendix A
of RENDERER_CONVERGE.md; the commit-by-commit journal is dropped (endpoints kept).
RENDERER_CONVERGE.md is now the single living renderer doc.
2026-07-16 16:32:35 +02:00
DWA b2a23d60b8 docs(renderer-refactor): image-capture investigated + deferred (demo uses drawImageRect, not the capturable drawNativePainter) 2026-07-16 15:51:37 +02:00
DWA 8fddb0a94c docs(renderer): refresh geo-node doc comments for spanned-text + icon capture
The SdlDisplayList / SdlDisplayListRenderNode headers still described the geometry-only
first increment (spanned/icon text listed as deferred). Update them to the current
coverage: GeometryBatch + TextRun (plain AND spanned) + IconRun; unsupported now trips
on image blits, saveLayer, rounded/generic layer clip, compositing, and span
backgrounds. Comment-only.
2026-07-16 15:46:52 +02:00
DWA 15e12ec2b9 docs(renderer-refactor): icon-font glyph capture (Lists/NavRails cache; -23%) 2026-07-16 15:35:50 +02:00
DWA c5945f1a94 feat(renderer): capture icon-font glyphs (Material Symbols) in the geo node
Icon-font glyphs recorded as an IconRun (leaf-local box + params) instead of
deferring the leaf. Replay maps the box through the layer affine and re-draws via
FreeTypeIcons' glyph cache (eviction-safe), with the same SDL_ttf fallback the
immediate path uses for a codepoint absent from the FreeType/subset font.

This unblocks the ubiquitous icon + text list-item / nav-item leaf: Lists text
draws 24 -> 14, NavRails 28 -> 16 (draw -23%). Full 57-screen sweep clean (56 at
0.000% geo-vs-default, Pickers = its settle-timing artifact), nav3test PASS. Icon-only
monolithic grids (Icons / MaterialSymbols) still defer for layer granularity.
2026-07-16 15:35:37 +02:00
DWA d4cb543090 docs(renderer-refactor): spanned-text capture (Tabs -38%) + all flip residuals explained 2026-07-16 15:23:31 +02:00
DWA 63b430873d feat(renderer): capture spanned text runs in the geo node
Styled text (colour / weight / italic / size / decoration spans) now records into
the display list as one TextRun per run — mirroring the plain path — instead of
deferring the whole leaf to block-replay. Replay re-looks-up each segment texture
by params (eviction-safe). A run with a SpanStyle.background still defers (the
background fill isn't a captured command yet; rare — highlight/selection).

Verified: full 57-screen sweep clean (no regression; styled screens Text/Annotated
String/Lists/Chips/Tabs all 0.000% geo-vs-default), and caching now reaches styled
labels where layer granularity allows — Tabs text draws 38 -> 16, draw 4.29 -> 2.65ms
(-38%). Screens whose text sits in one monolithic layer (Text/Lists) still defer for
other reasons in that leaf (child layer / icon) — layer-granularity, as upstream.
2026-07-16 15:22:08 +02:00
DWA 3a584bbe8b docs(renderer-refactor): perf characterization of the geo flip — no regression, win scales with static content 2026-07-16 14:50:20 +02:00
DWA 2873df25ae docs(renderer-refactor): Carousel clip fix + default flipped to geo (-57% draw, sweep clean) 2026-07-16 14:43:06 +02:00
DWA bff6b484e5 feat(renderer): flip SDL default to the cached-geometry node (geo)
CDN_LAYERCACHE default is now SdlDisplayListRenderNode. A full 57-screen sweep of
geo vs the previous block-replay default is clean: 55 at 0.000%, GraphicsLayer 0.122%
and ModShortcuts 0.095% (both the same sub-pixel rotated-edge AA — cosmetic,
deterministic). Former blockers gone: Carousel fixed by the clip change; Pickers was
an entry-animation settle artifact (geo-vs-geo and geo-vs-default both 0.000% once
settled). Perf: LazyColumn steady-state draw 3.55 -> 1.52ms (-57%), text 22 -> 6.

Escape hatches: CDN_LAYERCACHE=off|defer|0 -> DeferredRenderNode (previous default);
=1|texture -> SdlRenderNode (legacy texture cache). nav3test PASS.
2026-07-16 14:42:29 +02:00
DWA 4371335b37 fix(renderer): geo node applies layer clip — Carousel item masks no longer overflow
The geo fast path re-emits raw geometry via SDL_RenderGeometry, which the SDL
canvas clips only to a RECT; its rounded-corner mask is a lazy offscreen realized
from drawRect, which replayBatch bypasses. So a rounded/path LAYER clip went uncut
on the fast path — the Carousel item's morphing rounded mask was ignored and the
coloured card overflowed its silhouette (the deterministic 4.4% geo-vs-default diff).

Fix: apply a rectangular layer clip on the fast path (honoured by SDL_RenderGeometry
via the clip rect), and force rounded/generic-clipped leaves to the block-replay
(which realizes the mask correctly, == DeferredRenderNode). Carousel 4.4% -> 0.000%.
2026-07-16 14:42:29 +02:00
DWA 468c347357 docs(renderer-refactor): Phase 4 text capture landed — -59% draw; flip-blocker analysis 2026-07-16 14:22:41 +02:00
DWA 65503e60b8 feat(renderer): Phase 4 text capture — cache static text runs (geo node)
The real perf win. The geo capture path now records PLAIN text runs (params, not the
texture pointer — mirrors skiko recording a glyph-run into a Picture); replay
re-looks-up via Sdl3TextRenderer's per-run texture LRU (our glyph-atlas analog) and
blits at the transformed origin. Eviction-SAFE: if the LRU dropped the run, the
lookup re-rasterises — no dangling pointer. Spanned / icon text bails (unsupported →
block-replay).

- SdlDisplayList is now an ORDERED command stream (GeometryBatch | TextRun) so
  text↔shape z-order is preserved; replay flushes geometry before each text blit.
- Sdl3TextRenderer: runSink captures the plain run at its blit; blitRun replays it.
- Sdl3Canvas.drawNativeText: capture mode sets the sink for plain text (icons gated).
- SdlDisplayListRenderNode: added cache-leaves-defer-parents (fRecordingStack) — a
  child layer drawn during record defers the parent, and a child's drawInto during a
  parent's record draws NOTHING (no GPU leak into the target-less capture pass). This
  was the fix for the nesting regressions text capture first exposed.

Measured (LazyColumn, forced continuous): draw 3.56ms -> 1.47ms (-59%), text draws
22 -> 6 — static text stops being re-shaped/re-blit every frame.

Verified (geo vs default): Text/LazyColumn/Shapes/Icons/Cards/Chips 0.000%, Buttons
0.006%, GraphicsLayer 0.157% (rotated-square AA). KNOWN =geo issues (flag-gated,
default unaffected): Pickers renders correctly but its scrollable settles to a
different position nondeterministically (capture perturbs the frame timing → scroll
animation) — a timing/app-state sensitivity, not a render bug; Carousel 4.4%
(pre-existing). Resolve both before flipping the default to geo.
2026-07-16 14:13:43 +02:00
DWA a832de5ce1 docs(renderer-refactor): scope text capture + the glyph-texture lifetime constraint 2026-07-16 13:45:52 +02:00
DWA 5caef6b2b3 docs(renderer-refactor): Phase 4 geo node works — pixel-equal + deterministic; text capture next 2026-07-16 13:40:44 +02:00
DWA b50081014e feat(renderer): Phase 4 cached-geometry node — capture + replay (CDN_LAYERCACHE=geo)
The robust retained node. A capture-mode Sdl3Canvas records a leaf's tessellated
geometry ONCE into an SdlDisplayList (layer-local, NO GPU / no render target);
SdlDisplayListRenderNode.drawInto re-emits those cached vertices through the layer
transform via Sdl3DrawScope.replayBatch. Crisp under ANY transform (vertices
re-transformed, not a resampled texture), bit-exact (no texture round-trip), and —
crucially — NO per-frame texture state, so none of the texture node's timing
nondeterminism.

- Sdl3Canvas: capture mode (fCaptureList) routes shapes to the scope's recordingSink
  and gates text/image/clip/saveLayer/shadow to mark the list `unsupported` (that
  leaf falls back to a crisp block-replay — nothing leaks to the GPU). replayGeometryList
  re-emits under the current transform. Sdl3OffscreenRenderer.createCaptureCanvas.
- SdlDisplayListRenderNode behind CDN_LAYERCACHE=geo (1 = texture node, default =
  DeferredRenderNode).

Geometry-only so far: text/image/clip leaves DEFER (correct, not yet cached) — so the
perf win is currently limited to shape-only leaves; extending capture to text/image/
clip is the next increment (the full win). But correctness/robustness is proven:

Verified (geo vs default): Shapes/Text/Buttons/Pickers/Icons/Carousel/LazyColumn/
Canvas/Path ALL 0.000% (incl. every screen the texture node failed — Pickers 13-17%→0,
Carousel 4.4%→0). Pickers DETERMINISTIC (run1-vs-run2 0.000%, vs texture's 0↔17% swing).
nav3test PASS. Only residual: GraphicsLayer 0.134% — deterministic sub-pixel AA fringe
on ROTATED squares (tessellate-then-rotate vs tessellate-rotated), cosmetic.
2026-07-16 13:39:58 +02:00
DWA 5dc3a4525a docs(renderer-refactor): journal Phase 4 start (geometry recording foundation) 2026-07-16 13:23:27 +02:00
DWA 716a005da6 feat(renderer): Phase 4 foundation — geometry recording sink (no behaviour change)
First step of the cached-geometry display list (the robust replacement for texture
caching — no offscreen texture, so no fixed-resolution softness and no timing-
dependent nondeterminism; RENDERER_REFACTOR.md §3/§13).

- SdlDisplayList / GeometryBatch / GeometrySink: capture the tessellated untextured
  triangle batches a layer produces, in layer-local coords (an `unsupported` flag
  trips when a not-yet-captured op — text/image/clip — is hit, so the node can fall
  back to a crisp block-replay; nothing leaks to the GPU).
- Sdl3DrawScope: flush() routes to a `recordingSink` (capture) instead of
  SDL_RenderGeometry when set; `currentDeviceMatrix()` exposes the CTM for replay.
  Recorded with an identity base CTM ⇒ layer-local vertices; replay re-applies the
  layer matrix (crisp under any transform, bit-exact).

Sink defaults null ⇒ zero behaviour change. Next increments: the capture-mode canvas
+ SdlDisplayListRenderNode (record/replay) behind CDN_LAYERCACHE=geo, then text/image/
clip capture. Verified: :ui:compileKotlinMingwX64 BUILD SUCCESSFUL.
2026-07-16 13:22:18 +02:00
DWA 5890dd0fe3 docs(renderer-refactor): Pickers bug — clear fix + nondeterminism finding; Phase 4 is the robust path 2026-07-16 13:13:52 +02:00
DWA 29856d9bea fix(renderer): clear offscreen render targets on begin (reused-texture ghosting)
beginOffscreen set the render target but never cleared it. Fresh SDL target
textures happen to be zeroed (so static leaves were pixel-equal), but a
retained-layer texture (SdlRenderNode) is REUSED across re-records — an un-cleared
re-record ghosted prior content, which showed as multi-frame instability on churny
screens (Pickers: cached changed 16.8% between frame 6 and 60 while default was
stable). Clear to transparent (RenderClear fills with the draw colour ignoring
blend, so 0,0,0,0 = transparent). Shared with the vector-icon offscreen path, which
redraws fully each time, so clearing is correct there too; negligible cost (only on
offscreen begin).

Improves caching robustness (fixed the Pickers instability in controlled tests) but
does NOT fully resolve it: Pickers still renders nondeterministically across launches
(0% or ~17% vs default, even isolated) — a timing-dependent issue in the texture-cache
path on complex screens. Caching stays flag-gated; the robust fix is Phase 4's
cached-geometry list (no texture state → no timing dependence). Static screens remain
pixel-equal.
2026-07-16 13:13:14 +02:00
DWA 5184d3ec24 docs(renderer-refactor): #1 full sweep — DO NOT flip default yet (Pickers/Carousel caching bug) 2026-07-16 12:05:38 +02:00
DWA 0610460ebb docs(renderer-refactor): journal #2 done + #3 (Phase 4) cached-geometry design 2026-07-16 11:46:38 +02:00
DWA 8081fc13f6 fix(renderer): don't texture-cache image/icon leaves (AA-fringe fix)
Content with partial alpha — a decoded image (painterResource), a tinted
vector/icon — does not round-trip bit-exact through the 8-bit premultiplied
offscreen texture (the heart painterResource rendered visibly wrong, Icons
diff 0.36%). Detect it cheaply via a DrawStats.imageBlits delta around the
record block: a leaf that draws any image bails to the crisp block-replay.
Text / shapes / solid fills bump other counters and stay cached (pixel-equal).

Costs almost no perf — icons are blits, not tessellation; the big tessellation
cost is text, which still caches.

Verified (cached vs default): Icons 0.36% -> 0.014% (visible heart gone; residual
is one icon's AA), Buttons 0.006% (disabled semi-transparent fill), Text /
LazyColumn / GraphicsLayer / Shapes still 0.000%. The sub-perceptual residual is
the inherent texture-round-trip rounding floor — removed entirely by Phase 4's
cached-geometry display list (no round-trip).
2026-07-16 11:44:42 +02:00
DWA 3fec62fbc2 docs(renderer-refactor): journal the SDL caching node + measured perf win 2026-07-16 11:19:00 +02:00
DWA 3f76f77e5b feat(renderer): retained texture-cached SdlRenderNode (opt-in CDN_LAYERCACHE=1)
The SDL perf payoff: records a leaf layer's drawing ONCE into an offscreen
texture; replay is a cheap blit, not a re-tessellation.

Correct-by-construction nesting, no upstream divergence: SDL textures composite
children by value (unlike skiko's by-reference Picture), so we CACHE LEAVES,
DEFER PARENTS — a node auto-detects on first record whether its block drew any
child layer (via a recording stack) and, if so, replays the block instead
(children composite their own live textures → no staleness). The texture
fast-path is further restricted to opaque, untransformed, effect-free leaves;
scale/rotation (would bilinear-resample soft) and alpha/blend/colorFilter/
renderEffect (blit can't reproduce offscreen compositing) fall back to a crisp
block-replay — so caching is strictly non-regressing.

Gated behind CDN_LAYERCACHE=1; default stays DeferredRenderNode. Flip the default
after a full parity-harness sweep.

Verified (SDL leg, cached vs default): GraphicsLayer/Shapes/Text/LazyColumn/
Scroll/Brushes/Canvas pixel-equal (0.000%); Buttons 0.006% + Icons 0.36% (AA
fringe from the texture round-trip at rounded-clip/glyph edges — not structural);
nav3test + backtest PASS. Profiler (LazyColumn, forced continuous): per-frame
verts 2958->1158 (-61%), text draws 22->6, draw 4.0->3.4ms — static leaves blit
instead of re-tessellate.
2026-07-16 11:18:08 +02:00
DWA 1f33506c13 docs(renderer-refactor): journal the GraphicsLayer façade + scope the caching node 2026-07-16 10:46:50 +02:00
DWA 44e09aa89f feat(renderer): GraphicsLayer as a façade over NativeRenderNode
Restructure to mirror upstream: GraphicsLayer is now a copy-edit of
SkiaGraphicsLayer.skiko.kt that mirrors every visual property onto a backing
NativeRenderNode and delegates record/replay to it — instead of an ad-hoc
lambda-replay. This is the correct Compose-faithful shape: the per-renderer
caching node (skiko RenderNode on Skia, offscreen/display-list on SDL) will slot
in behind createNativeRenderNode without touching GraphicsLayer.

- NativeRenderNode: reshaped to record(block)/drawInto(canvas)/close() + the full
  skiko-RenderNode property surface (transform/shadow/clip/compositing).
- DeferredRenderNode (nativeMain, renderer-agnostic): the node both renderers use
  today — replay-the-block, UNIFYING the old GraphicsLayer.native lambda-replay
  AND ProjectOwnedLayer's transform/shadow/clip logic in one place. Transform via
  prepareLayerTransformationMatrix + canvas.concat (same matrix as hit-testing, so
  draw and hit-test agree). Behaviour-preserving; no re-tessellation win yet — that
  arrives when createNativeRenderNode returns a caching node (Phase 2a/2b).
- toImageBitmap: implemented (was throwing) — renders the layer into a fresh
  ImageBitmap; flushed via new NativeFinishableCanvas (SDL offscreen must commit
  its texture; Skia finish() is a no-op). Both canvases implement it.

Verified SDL leg: :ui green; demo renders GraphicsLayer (rotation/scale/alpha
identical), Buttons/Shapes correct; nav3test PASS.
2026-07-16 10:45:00 +02:00
DWA 0870cd923a docs(renderer-refactor): journal the navigation crash fix + full flip verification 2026-07-16 10:19:16 +02:00
DWA 49b14337eb fix(renderer): don't shrink dirtyLayers mid-draw (crash on navigation)
The Phase 1 flip crashed with IndexOutOfBoundsException on any navigation
(--nav3test exit 3; sidebar click killed the window). Cause: my hand-written
notifyLayerIsDirty removed a layer from dirtyLayers unconditionally, but
renderRoot iterates `for (i in 0 until dirtyLayers.size)` calling
updateDisplayList(), which sets isDirty=false -> notifyLayerIsDirty(false) ->
removed the layer mid-iteration, so dirtyLayers[i] ran off the end.

Aligned notifyLayerIsDirty + renderRoot to upstream OwnedLayerManagerImpl
verbatim: while isDrawingContent, a clear (isDirty=false) is a no-op — the
dirtyLayers.clear() after the loop drops them all at once, so the list can't
shrink during iteration.

Verified: --nav3test PASS (was exit 3), --backtest PASS, and the sidebar click
navigates to GraphicsLayer correctly (hit-test lands + layer churn, no crash).
2026-07-16 10:18:40 +02:00
DWA 74f186136b docs(renderer-refactor): add progress journal (Phase 0 + Phase 1 flip) 2026-07-16 09:52:29 +02:00
DWA bbaa04354c feat(renderer): flip createLayer to retained GraphicsLayerOwnerLayer
Phase 1 behavioural step: every layout layer now goes through upstream's
dirty-gated record/replay OwnedLayer instead of the immediate-mode
ProjectOwnedLayer stand-in.

- ComposeOwner implements OwnedLayerManager (dirtyLayers + notifyLayerIsDirty
  + recycle + voteFrameRate); createLayer returns GraphicsLayerOwnerLayer;
  invalidate() -> window needsFrame via a callback hook.
- renderRoot(): re-records dirty layers, then walks the tree so clean layers
  replay. ComposeRootHost.drawRoot delegates to it; both backends call it
  instead of rootNode.draw.
- ComposeWindow wires the invalidation callback so a draw-only state change
  still schedules a frame.
- Move ComposeOwner + ComposeRootHost commonMain -> nativeMain (they now touch
  native-only OwnedLayerManager / GraphicsLayerOwnerLayer; :ui commonMain only
  ever compiles to native anyway). The feed*Processor expect/actual collapses
  to plain nativeMain functions.

NOTE: still backed by the existing lambda-replay GraphicsLayer, so this is
behaviour-preserving (no re-tessellation win yet); the storage swap to a
caching NativeRenderNode is the next step. Verified on the SDL leg: demo exe
renders GraphicsLayer (rotation/scale/alpha correct), Buttons (rounded/shape
clip correct), Shapes/Scroll/Text/Counter all non-blank. Skia leg builds on
mac/CI. Hit-testing through the new layer matrix wants a probe pass next.
2026-07-16 09:52:11 +02:00
DWA 86c97cbad1 feat(renderer): vendor GraphicsLayerOwnerLayer (compiles unused, SDL leg)
Phase 1 shared step: bring in upstream's real OwnedLayer (dirty-gated
record/replay over a GraphicsLayer) that will replace the immediate-mode
ProjectOwnedLayer.

- nativeMain: GraphicsLayerOwnerLayer.kt manually vendored from skiko
  (setLightingInfo tail dropped; not wired to createLayer yet).
- nativeMain: prepareLayerTransformationMatrix — the one helper that was
  skikoRenderer-only (Matrices.skiko.kt), lifted to shared nativeMain under a
  distinct name (parent source sets can't see child-set symbols; distinct
  name avoids clashing with the vendored skiko copy on the Skia leg).

Additive — createLayer still returns ProjectOwnedLayer, so no behaviour
change. Verified :ui:compileKotlinMingwX64 BUILD SUCCESSFUL (SDL leg).
2026-07-15 17:38:20 +02:00
DWA 5a05663050 feat(renderer): Phase 0 — NativeRenderNode seam (retained-layer scaffolding)
Introduce the renderer-agnostic retained display-list node the whole
retained-layer engine will hang off (RENDERER_REFACTOR.md §4):

- nativeMain: NativeRenderNode interface + NativeRenderNodeContext +
  createNativeRenderNode expect. Surface mirrors skiko RenderNode /
  SkiaGraphicsLayer (topLeft/size/pivot, transforms, elevation shadow,
  clip rect/rrect/path, layerPaint, beginRecording/endRecording/drawInto/
  close) so the Skia actual can wrap skiko RenderNode / skia Picture and
  the SDL actual can build a command-list + offscreen twin.
- skikoRendererMain / sdlRendererMain: TODO() stub actuals (Phase 1 / 2).

Additive only — nothing constructs a node yet, so no behaviour change.
Verified: :ui:compileKotlinMingwX64 BUILD SUCCESSFUL (SDL leg). Skia leg
compiles on mac/CI (native skiko klibs absent on Windows).
2026-07-15 17:26:54 +02:00
DWA ebe1d67620 docs(renderer-refactor): add minimize-divergence rule for the top renderer layer 2026-07-15 17:19:26 +02:00
DWA 7eaebeb787 docs(renderer-refactor): make scope explicit — whole per-renderer stack, not just retained layers 2026-07-15 17:09:46 +02:00
DWA 2f1f7d5ba5 docs: replace ROADMAP + NEXT-SESSION with RENDERER_REFACTOR plan
Plan to adopt upstream skiko's retained-layer engine (record-once/replay
GraphicsLayer) in place of the current immediate-mode renderer, which
re-tessellates the whole tree every frame.

- Skia leg ~= vendored skiko target (GraphicsLayerOwnerLayer +
  SkiaGraphicsLayer over skiko RenderNode / Picture fallback); SDL leg
  builds a faithful SdlRenderNode twin (display list + offscreen).
- Keep ComposeOwner + SDL loop; wire it as the OwnedLayerManager.
- Adds a feature-parity audit matrix vs skiko (the acceptance gate) and
  Track V: relocate graphics actuals per-renderer, vendor the skiko set,
  delete hand-rolled Skia duplicates.
- Delete ROADMAP.md / NEXT-SESSION.md (superseded; still-valid items
  folded in); repoint CLAUDE.md.
2026-07-15 17:05:20 +02:00
Bitsy 92454ae8a1 fix(window): FPS title shows within ~2 frames instead of needing a page change
The FPS title only updated after a full 1000ms of wall-clock accumulated
since the last update — and the idle-skip branch called resetFpsWindow()
(zeroing the counter + window start) every time the app went idle. Since
the UI renders in short bursts and idles between them, that 1-second
window kept restarting before it ever completed, so the first title
update never fired. A page change was the only interaction whose
transition animation renders continuously long enough (~700ms+) to let
the window complete — hence 'I have to change page for it to work'.

Replace the fixed-window frame counter with an EMA-smoothed
instantaneous rate (from the inter-frame delta), refreshed ~4x/sec. It
shows within ~2 rendered frames of any activity. A dt outside 1..100ms
(first frame / resume-from-idle) isn't sampled, so idle gaps don't tank
the reading and the title holds the last active rate while static.

Verified: demo shows FPS in the title 1.6s after launch, no interaction.
2026-07-14 20:45:36 +02:00
Bitsy aad70f342b fix(sdl): stop re-rasterizing text at device size — popup/dialog text no longer steps size/weight
Regression from 120abbad's glyph device-scaling: drawNativeText scaled
the font size by the layer affine (inFontSize * scaleY, int-quantized)
and re-rasterized. Under an animated layer scale — every Material
DropdownMenu/AlertDialog enter transition scale-animates (Menu.kt
scaleX/scaleY) — text re-rasterized at each integer size every frame, so
it visibly stepped through sizes and (via SDL3_ttf re-hinting) weights
as the popup grew in. User-reported, seen across dropdowns and dialogs.

Revert to rasterising at LOGICAL font size + 1:1 blit at the
affine-mapped position (the pre-120abbad behaviour): the layer scale
reaches text POSITION but not glyph size, so animated-scale popups are
stable. Keeps 120abbad's separate clip-mask AA/overlap fixes. Trade-off:
statically graphicsLayer-scaled text renders at logical size (a rare,
minor parity gap) — smoothly scaling glyphs needs blit-scaling a
logical rasterisation (noted for a future text-renderer pass).

Verified: demo dropdown opens with clean uniform text; text no longer
scales with the layer, so mid-animation == settled by construction.
2026-07-14 20:19:40 +02:00
Bitsy cf597c12e0 cleanup: delete dead modifier scaffolding + fix demo's fake cacheKey claims
ModifierElements.kt (ClipModifier/ClipNode + GraphicsLayerModifier/
GraphicsLayerNode with the never-implemented cacheKey) was entirely
unreferenced — apps use the upstream clip/graphicsLayer; nothing read
these. Deleted the file (compiles clean across :ui and :demo).

The demo GraphicsLayer screen advertised caching that never existed
('Cached subtree (cacheKey)', 'Combined (cache + rotation)', a
'(cached)' label) over plain graphicsLayer() calls. Removed those two
fabricated sections and the cacheKey wording; the honest Rotation
(live) / Scale / Alpha sections already show what graphicsLayer does.
2026-07-14 20:02:30 +02:00
Bitsy cbddf8ca0b docs: RESOLVED — demo-70/apidemo-144 was mixed-refresh monitors, not a renderer bug
User has a 75Hz main + 144Hz second screen; each app's fps is just the
refresh of the monitor its window opened on (SDL vsync follows the
display). Instrumentation already showed both apps at ~3.7ms draw —
well under both the 13.3ms (75Hz) and 6.7ms (144Hz) budgets, so no perf
problem exists. Records that retained-layers/dirty-region/cacheKey are
speculative with no demonstrated need; only the dead-scaffolding cleanup
is worth doing regardless.
2026-07-14 19:54:04 +02:00
Bitsy ad738a643e docs(CLAUDE): profiler now file-output + draw counters + CDN_FORCERENDER; vsync/display caveat
Documents the DrawStats per-frame counters, file output, and
CDN_FORCERENDER added this session, plus the load-bearing caveat: present
is vsync-bound so all timings are capped by the dev display's refresh —
profile on the target rate before diagnosing a frame-rate gap.
2026-07-14 19:53:12 +02:00
Bitsy 8f5c59bd64 feat(window): per-frame DrawStats in profiler + CDN_FORCERENDER; correct the 70fps theory
Extends CDN_PROFILE with per-frame draw counters (DrawStats: geometry
submits, vertices, mask realizations, text/image blits) and a
CDN_FORCERENDER=1 flag to render every frame for steady-state timing on
idle screens.

Used them to test the demo-70fps theory and DISPROVE it: forced-render,
both apps pin at 75 fps (this dev display's refresh), present ~9ms; the
demo's draw (~3.7ms, geo=40, masks=0) is actually LIGHTER than apidemo's
(~2.9ms, geo=146, masks=74). So the demo is NOT draw-bound — the
70-vs-144 split is specific to the user's 144Hz monitor and can't be
reproduced on a 75Hz box. Recorded the correct next step (profile on the
144Hz machine) in NEXT-SESSION.md; noted the vsync/display confound in
CLAUDE.md's profiler docs. Thread A (spurious continuous render) resolved
as a non-issue — the demo idles when static.
2026-07-14 19:52:24 +02:00
Bitsy 914bab840d docs: handoff notes for the renderer-caching session
Captures the demo-70fps investigation for the next session: (A) the
likely-cheaper continuous-render / hover-self-loop thread to chase first,
and (B) the cacheKey/retained-layer work — dead scaffolding today,
blocked on a content-draw-redirect primitive (the 'renderer rewrite')
that also underpins dirty regions. Lists the tooling + existing
infrastructure to build on.
2026-07-14 19:34:39 +02:00
Bitsy 8f96ba9478 docs(roadmap): record demo 70fps cause (draw-bound sidebar re-tessellation)
Investigated the demo-70fps / apidemo-144fps question with CDN_PROFILE:
both apps use vsync(1), so it's NOT a vsync-mode difference. The full
demo is DRAW-bound (draw ~34ms cold / ~14ms steady, present ~1.3ms) —
it re-tessellates the whole always-present sidebar (30+ rows of text +
icons) every rendered frame, capping ~70 fps. apidemo's lighter chrome
fits the 6.9ms/144Hz budget and hits the vsync ceiling. '70≈144/2' is
coincidence. Concrete motivation for retained layers / dirty regions.
2026-07-14 18:43:23 +02:00
Bitsy 5a9e7b0f7f docs(roadmap): mark layer rotation verified-working; warn item-4 list is stale
Layer rotation renders correctly (demo GraphicsLayer Rotation-live +
Combined rows) — like gradients, the claimed gap was already fixed.
Added a note to verify item-4 claims against the parity heatmaps before
acting, since the list predates several fixes.
2026-07-14 18:28:55 +02:00
Bitsy 5dfd9200f8 fix(sdl): grid-mesh gradient fills so radial/sweep aren't flat
The brush samplers (linear/radial/sweep with stops) were fully
implemented, but fills sampled the brush only at the shape's CORNERS and
let SDL interpolate. A square's four corners are equidistant from its
centre → identical radial/sweep t → the fill rendered as ONE flat colour
(the demo Brushes radial was solid green: center==corner pixel). Linear
survived because corners differ along the axis.

Gradient fills (brush !is SolidColor) now tessellate rect and round-rect
bodies into an ~8px-cell grid (emitRectMesh, adaptive 1..24 cells/axis),
so the sampler is evaluated across the interior and SDL interpolates
per-cell. Solid fills keep the single-quad fast path. Round-rect corner
arcs already fan from their centre.

Verified: demo Brushes radial now shows centre→edge falloff matching the
JVM render (was flat); parity Brushes 22.75%→21.72% (swatches are a
small pixel fraction; the rest is font drift).
2026-07-14 18:27:07 +02:00
Bitsy 8c7168a1a9 feat(window): profiler render sub-phases + file output; reprioritize roadmap from data
FrameProfiler is now a named-phase singleton writing to a FILE
(CDN_PROFILE=1 -> cdn_profile.log, or =<path>) not stdout — so
GUI-subsystem apps (the demo links --subsystem,windows) profile too.
renderFrame splits into layout / draw / present sub-phases.

Measured: light screen = 75 Hz, present ~10ms (vsync wait), draw ~1.4ms
(GPU-idle); heavy 84-bubble sheet ~32ms = DRAW, not masks. ROADMAP item
2 re-ordered: dirty-region rendering + retained layers promoted (the
real lever); clip-target right-sizing demoted to memory-only/low.
2026-07-14 18:16:27 +02:00
Bitsy 377f3aa775 docs: consolidated tooling index in CLAUDE.md + simple redirecting table in README
CLAUDE.md gains a '## Tooling — what to reach for' section: a when-to-use
table covering build-sdl, compose-fork sync, compose-coverage,
material-symbols, parity, probe, the CDN_PROFILE profiler, the bridge
plugin and the demo CLI probes — each pointing at its own README. The
parity/profiler/probe entries are expanded inline (parity keeps the
'what the %differ means' detail). README gets a simpler table redirecting
to each tool's README + the bridge/CLAUDE detail.
2026-07-14 17:18:11 +02:00
Bitsy 662975c802 feat(probe): reusable native-window input+capture driver (ROADMAP item 5)
scripts/probe/ — launch a native app, send WINDOW-CLIENT-relative input
(click/hover/hold, fractional coords addressed by process name so it
ignores window position/focus), capture via PrintWindow (works occluded).
The packaged form of the ad-hoc rigs that reproduced the square-on-click
and TLS-chain bugs. Verified: demo --screen=Shapes hover+capture yields
real 1000x700 content.
2026-07-14 17:15:19 +02:00
86 changed files with 5973 additions and 1573 deletions
+3
View File
@@ -61,3 +61,6 @@ bin/
### Python ###
__pycache__/
*.pyc
# Probe artifacts written to the repo root by demo --nav3test / --searchesctest / --dialoganimtest
/*.bmp
+51 -63
View File
@@ -3,6 +3,21 @@
Guidance for Claude Code working in this repository. This is the primary
context — read it first, then look at the files it points to.
## Documentation map
- [README.md](README.md) — public overview + quickstart (bridge plugin,
`nativeComposeWindow`, sample apps).
- [RENDERER.md](RENDERER.md) — the rendering layer: architecture, the retained-
layer engine, convergence decisions, learnings, and open work. Read it before
touching any renderer, graphics-actual, or layer-engine code.
- [TOOLING.md](TOOLING.md) — build/vendor/verify scripts and workflows
(build-sdl, sync + drift checks, parity, probe, profiler, coverage,
verify-mac) + the version map and the ref-bump / release runbooks.
- [TODO.md](TODO.md) — audited list of no-ops, stubs, and hardcodes left in the
port, with severity, plus what's missing for a stable 1.12 release.
- This file — architecture, module layout, vendoring rules, source-set
hierarchy, density flow, conventions, and common pitfalls.
## What this project is
**ComposeNativeSDL3** — a Kotlin/Native port of Compose Multiplatform running
@@ -228,10 +243,23 @@ Two categories of code live in each module:
NON-IDEMPOTENT.** Move it OUT of `src/vendor/` into the corresponding
`src/{commonMain,nativeMain,…}` tree, add a header comment noting
which upstream file it derived from and what changed, and comment
its line out of `compose-fork.txt`. Now it's a project file — the
next sync won't overwrite it, and future upstream changes to that
file need to be reconciled by hand. This is fine; do it when the
edit is small and the file is unlikely to churn upstream.
its line out of `compose-fork.txt`. **ALWAYS also add the
machine-readable provenance line** so the copy can be diffed against
its base and against later upstream versions:
```
// VENDOR-BASE: <upstream-repo-relative-path> @ <pinned-tag>
// VENDOR-BASE(COMPOSE_REF): <path> @ <tag> ← for umbrella-repo files
```
`scripts/compose-fork/check-vendor-drift.py` reads these at every ref
bump: it flags any file whose recorded base lags the current pin and
(with the local clone) reports whether the upstream base ACTUALLY
changed base..pin — i.e. whether the copy needs hand-reconciling or
just a ref re-stamp. Now it's a project file — the next sync won't
overwrite it, and future upstream changes to that file need to be
reconciled by hand. This is fine; do it when the edit is small and
the file is unlikely to churn upstream.
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`, with
an SDL3 equivalent in `:ui/src/sdlRendererMain/`.** When upstream
@@ -354,19 +382,10 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
SDL3 + SDL3_ttf + SDL3_image + FreeType (+ image codecs) are **built from
source as static libraries on every OS** and linked straight into the
executable — no brew/apt SDL packages, no runtime .dll/.so/.dylib;
a distributable is `<app>` + `data.kres`. One script does it all:
`python scripts/build-sdl/build-all.py` (plain Python 3, no Git Bash needed)
builds into the gitignored, in-repo `libs/` folder. Versions/URLs pinned in
`scripts/build-sdl/build-sdl.properties`. Steps run in order freetype → sdl3
→ sdl3-image → sdl3-ttf; pass step names to rebuild a subset
(`python scripts/build-sdl/build-all.py sdl3-ttf`).
Needs everywhere: git, cmake, python 3 (ninja fetched automatically). Per
host: macOS = Xcode CLT (Skia default; Skiko klibs from Maven); Linux =
gcc/g++ + the X11/Wayland/audio dev headers SDL3's configure detects (apt
list in .github/workflows/publish.yml); Windows = mingw-w64 g++ on PATH
(C compiles with K/N's bundled mingw).
executable — no brew/apt SDL packages, no runtime .dll/.so/.dylib; a
distributable is `<app>` + `data.kres`. Build them once per host with
`python scripts/build-sdl/build-all.py`. Per-host toolchain requirements and
the step breakdown are in [TOOLING.md](TOOLING.md#native-libraries).
## Runtime bundling — data.kres
@@ -392,19 +411,13 @@ executable, loaded via `SDL_GetBasePath()`). Contents:
```bash
# Sync every module's compose-fork.txt against the pinned upstream ref.
scripts/compose-fork/sync.sh
# Sync one module by direct manifest path (module names use ':' → '/' → skip
# 'compose/' prefix — nested modules like compose/sdl/window pass the full path).
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt
# Re-format a manifest (align columns, group by upstream folder).
scripts/compose-fork/format-manifest.py --discover ../cmp-ref \
--manifest compose/ui/compose-fork.txt
```
Upstream ref: `scripts/compose-fork/compose.properties` — set to a specific commit
Upstream ref: `scripts/compose-fork/compose.properties` — set to a durable tag
of `JetBrains/compose-multiplatform-core`. Bump the ref → re-sync → let the
build tell you what broke.
build tell you what broke. Per-module sync, manifest re-formatting, and the
drift / vendor-clean guardrails are in
[TOOLING.md](TOOLING.md#vendoring-upstream-compose).
## Key files by area — start here when you need to find something
@@ -458,45 +471,20 @@ build tell you what broke.
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
ship drop-in equivalents for our anchor / scrim patterns).
## Parity harness — native-vs-JVM screenshot diff
## Tooling
`:demo` renders the **same commonMain screens** on two stacks: native
(SDL/Skia, Kotlin/Native) and a `jvm()` target on upstream Compose Desktop.
`scripts/parity/parity.py` screenshots every screen on both and pixel-diffs
them, so a screen that visually diverges is a **port regression** (missing
content, wrong shape/colour, broken clip). Several past renderer regressions
would have been caught here.
The full tooling reference is [TOOLING.md](TOOLING.md): the static-lib build,
vendor sync + drift/clean guardrails, API coverage, the `verify-mac` runbook,
the native-vs-JVM parity harness, the interaction probe, and the frame
profiler. Quick rules of thumb:
```bash
python scripts/parity/parity.py # all screens (builds first)
python scripts/parity/parity.py Buttons Shapes # a subset
python scripts/parity/parity.py --no-build # reuse the last renders
```
- Whole-project renderer/layout change → run **parity** (`scripts/parity/parity.py`),
the broad net.
- Chasing one reported interaction → run the **probe** (`scripts/probe/`), targeted.
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
- Any renderer change → the **`verify-mac`** runbook gates both legs before commit.
Mechanics: the JVM leg renders all screens headlessly via `ImageComposeScene`
in ONE process (`:demo:run --args=--screenshot-all=<dir>`, wired in
`MainJvm.kt`); the native leg launches the exe once per screen
(`--screen=<Name> --screenshot=<x>.bmp`). Output lands in **`build/parity/`
(gitignored)**: `<pct>_<Name>_diff.png` (amplified difference heatmap),
`<pct>_<Name>_compare.png` (native ∣ jvm ∣ diff, side by side), and
`report.txt` ranked worst-first. Windows-only for the native leg today; needs
Pillow. See `scripts/parity/README.md`.
**What the number means — read this before trusting it.** The `%differ` is the
fraction of pixels whose per-channel difference exceeds a tolerance. It is NOT
a pass/fail score and pixel-perfection is not the goal: the two stacks use
different default fonts, so **every screen carries a steady baseline
difference** — in the heatmap, text shows as a faint *doubled ghost* from
slightly different line metrics/baselines. A healthy full sweep is a smooth
gradient (~2% for a sparse screen like Counter, up to ~32% for a text-dense
one like Tabs). Known SDL parity gaps also inflate specific screens
predictably (Brushes ~23% — gradients render solid on SDL; Shadows / Canvas /
GraphicsLayer — effect differences). **The signal is the RANKING and the
delta from a screen's own history**, not the absolute value: a text-light
screen suddenly reading 60%, or a screen jumping far above its neighbours, is
the bug. In a `_diff.png`: ghosted/doubled text + dark shapes = normal font
drift; a **solid bright block, or a shape present on only one side** = a real
regression — open the `_compare.png` to see which stack is wrong.
See [RENDERER.md](RENDERER.md) for the renderer work these support.
## Conventions
+136 -221
View File
@@ -1,265 +1,180 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.4.0-blue.svg?style=flat&logo=kotlin)](https://kotlinlang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md)
# Compose Desktop Native
A Kotlin/Native port of **Compose Multiplatform** running on **SDL3** for
windowing and input — **no JVM**. Compiles to native binaries for macOS
(arm64), Linux (x64/arm64), Windows (mingwX64).
Run [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform)
as a native binary, with no JVM. Compose Desktop Native compiles to a single
executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
[SDL3](https://libsdl.org) for windowing and input.
Rendering is pluggable behind one `RenderBackend`:
<img src="screenshots/demo.png" width="100%" alt="Compose Desktop Native demo" />
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
when `-Prenderer=sdl3` is passed.
## How it works
The Compose **runtime** is the official
`org.jetbrains.compose.runtime:runtime` klibs from Maven — this project
re-implements the layers on top of it (`androidx.compose.ui.*`,
`.foundation.*`, `.animation.*`, `.material3.*`) by **vendoring
upstream Compose Multiplatform verbatim** whenever possible and hand-rolling
project actuals + SDL3 / Skia glue only where needed.
- **The runtime is the real one.** Composition, snapshots, and the recomposer
are the official `org.jetbrains.compose.runtime` klibs from Maven, never
reimplemented.
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
`animation`, and `material3` are copied from Compose Multiplatform verbatim
wherever they compile as is, with project code filling in only the native glue.
- **Rendering is pluggable** behind one `RenderBackend`: Skia (via Skiko) on
macOS and Linux, and a from-scratch SDL3 renderer (`SDL3_ttf` +
`SDL_RenderGeometry`) on Windows, or anywhere with `-Prenderer=sdl3`.
- **The platform is SDL3.** Windowing, input, audio, filesystem, file dialogs,
and clipboard all go through SDL3, so one code path covers every OS.
- **No runtime dependencies.** SDL3 and its codecs are built as static
libraries and linked in. A distributable is just the executable plus a
`data.kres` resource bundle.
## Module layout
| Platform | Gradle target | Default renderer |
|----------|---------------|------------------|
| macOS arm64 | `macosArm64` | Skia (Metal) |
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) |
| Windows | `mingwX64` | SDL3 |
One Gradle module per upstream Compose artifact — the directory tree mirrors
upstream's `compose/` layout, gradle paths stay short via `projectDir`
redirects.
## Quickstart
```
compose/
├── ui/
│ ├── ui/ → :ui (androidx.compose.ui.* + renderers + cinterops)
│ ├── ui-util/ → :ui-util
│ ├── ui-geometry/ → :ui-geometry
│ ├── ui-unit/ → :ui-unit
│ ├── ui-backhandler/ → :ui-backhandler
│ └── ui-tooling-preview/ → :ui-tooling-preview (common @Preview + PreviewParameterProvider —
│ IDE-only metadata; previews render via the apps' jvm targets)
├── animation/
│ ├── animation-core/ → :animation-core
│ ├── animation/ → :animation
│ └── animation-graphics/ → :animation-graphics
├── foundation/
│ ├── foundation/ → :foundation
│ └── foundation-layout/ → :foundation-layout
├── material3/material3/ → :material3
├── material/material-ripple/ → :material-ripple
└── sdl/ (project modules — the SDL integration layer)
└── window/ → :window (nativeComposeApp { Window(...) } + SDL3 main loop,
per-window Lifecycle/ViewModelStore/SavedState owners)
utils/
└── material-symbols/ → :material-symbols (Material Symbols: 4200+ codepoints + Outlined/Rounded/
Sharp composables with the variable-font axes as
parameters (FILL/wght/GRAD/opsz). Common API — native
draws via the port's icon-font engine, the jvm target
via Skiko, so shared app code uses it on both stacks)
components/
└── resources/library/ → :components-resources (vendored official compose resources runtime —
painterResource/stringResource/Res codegen; the Maven
artifact ships no mingwX64/linux klibs)
navigation3/
└── navigation3-ui/ → :navigation3-ui (vendored NavDisplay + scene machinery — the one
Navigation 3 layer without a K/N desktop artifact)
gradle-plugin/
└── compose-desktop-native-bridge/ → :compose-desktop-native-bridge (the consumer-side bridge, published
as a Gradle plugin — see "Using it from your own build")
demo/ → :demo (showcase + CLI probe suite; multiplatform — the same screens
also build for stock JVM Compose Desktop as a parity reference)
apidemo/ → :apidemo (Postman-style REST client; multiplatform — same UI on JVM too)
```
## demo — widget & feature showcase
<img src="screenshots/demo.png" width="100%" alt="ComposeNativeSDL3 demo" />
`:demo` is a full tour of the re-implemented Compose + Material 3 surface —
30+ sidebar screens covering buttons, text fields, layout, modifiers, shapes,
images, state & recomposition, scrolling & lazy lists, dialogs, icons, canvas,
graphics layers, custom layout, animation and gestures.
```bash
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
./gradlew :demo:run # stock JVM Compose Desktop (parity reference)
```
The same shared screens drive both stacks — the JVM target runs them on
upstream Compose Desktop unchanged, so any visual or behavioural difference
against the native build is a porting bug, not a demo artifact.
CLI: `--gpu=…`, `--screen=<Name>` (one screen, no sidebar),
`--screenshot=out.bmp --frames=N`, `--width=W --height=H`.
## apidemo — API Manager
<img src="screenshots/apidemo.png" width="100%" alt="ComposeNativeSDL3 API Manager" />
`:apidemo` is a Postman-style REST client built entirely on the library:
request collections (**packs**, nested sub-packs, linked copies), a
**Session → Pack → Request** inheritance ladder for variables / headers /
query params / client certs, syntax-highlighted JSON / XML / YAML / HTML body
editors, a response viewer with timing, size and TLS-chain inspection, mTLS
client certificates, drag-and-drop tree management, request history and
file-based sessions.
```bash
./gradlew :apidemo:runDebugExecutableMacosArm64
./gradlew :apidemo:runDebugExecutableLinuxX64
gradlew.bat :apidemo:runDebugExecutableMingwX64
./gradlew :apidemo:run # stock JVM Compose Desktop (parity reference)
```
Like the demo, the whole UI lives in `commonMain` and also runs on upstream
Compose Desktop (JVM); only the mTLS / TLS-chain features are native-only —
they drive the bundled libcurl directly.
Add `-Prenderer=sdl3` on macOS/Linux to drop Skiko and use the pure-SDL3
renderer everywhere.
## Minimal app
### A window
```kotlin
import androidx.compose.material3.Text
import com.compose.sdl.nativeComposeWindow
fun main() = nativeComposeWindow(title = "Hello") {
Text("Hello from ComposeNativeSDL3")
Text("Hello from Compose Desktop Native")
}
```
The lambda runs with a `ComposeWindowScope` receiver exposing
`window: ComposeNativeWindow` (`setTitle` / `setSize` / `minimize` /
`maximize` / `setFullscreen` / `close` / …); the same handle is reachable
from any nested composable via `LocalComposeNativeWindow.current`.
The content lambda runs with a `ComposeWindowScope` receiver that exposes
`window: ComposeNativeWindow` (`setTitle`, `setSize`, `minimize`, `maximize`,
`setFullscreen`, `close`). The same handle is reachable from any nested
composable via `LocalComposeNativeWindow.current`.
Add these to your module's `commonMain.dependencies`:
For multiple windows, wrap them in `nativeComposeApp`, where the set of
`Window(...)` calls is state driven (Compose Desktop style):
```kotlin
implementation(project(":window")) // window + main loop
implementation(project(":material3")) // Material 3 widgets
implementation(project(":material-symbols")) // icon-font composables (optional)
fun main() = nativeComposeApp {
Window(title = "Main", onCloseRequest = ::exitApplication) { /* ... */ }
if (showInspector) {
Window(title = "Inspector", onCloseRequest = { showInspector = false }) { /* ... */ }
}
}
```
## Using it from your own build — the bridge plugin
Each window gets its own `Lifecycle`, `ViewModelStore`, and `SavedStateRegistry`
owners, driven by real SDL focus and visibility events, so ViewModels and
saved state behave as they do on Android.
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`, and
a companion Gradle plugin ships the "redirect trick" this repo uses
internally: apply **`com.bitsycore.compose-desktop-native.bridge`** once in
`settings.gradle.kts` and declare the **official Compose Multiplatform
coordinates** in `commonMain` — the plugin substitutes the port's klibs on
every native desktop configuration (mingwX64 / linuxX64 / linuxArm64 /
macosArm64), while android / jvm / iOS / wasm keep resolving the official
artifacts. One dependency list, all CMP platforms + the port's new ones.
### Building in this repo
```kotlin
// settings.gradle.kts
plugins { id("com.bitsycore.compose-desktop-native.bridge") version "<release>" }
// build.gradle.kts — official coords, everywhere
commonMain.dependencies {
implementation("org.jetbrains.compose.runtime:runtime:<runtime-version>")
implementation("org.jetbrains.compose.ui:ui:<cmp-version>")
implementation("org.jetbrains.compose.foundation:foundation:<cmp-version>")
implementation("org.jetbrains.compose.material3:material3:<cmp-m3-version>")
implementation(project(":window")) // window shell + main loop
implementation(project(":material3")) // Material 3 widgets
implementation(project(":material-symbols")) // icon-font composables (optional)
}
```
Setup details (repositories, credentials, version pinning):
### Building from your own project: the bridge plugin
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`. Apply
the bridge Gradle plugin once, declare the **official** Compose Multiplatform
coordinates, and the plugin swaps in the port's klibs on native desktop targets
while android, jvm, iOS, and wasm keep resolving the official artifacts.
```kotlin
// build.gradle.kts, official coordinates everywhere
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("com.bitsycore.compose-desktop-native.bridge") version "<release>"
}
commonMain.dependencies {
// The plugin exposes the exact Compose versions the port tracks, so you
// never hand-match them (material3 is versioned separately upstream).
implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
}
```
Repositories, credentials, and version pinning:
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
For a complete project that applies the bridge and builds one shared UI for
Android, JVM, and native desktop, see the example repo:
[bitsycore/compose-desktop-native-bridge-example](https://github.com/bitsycore/compose-desktop-native-bridge-example).
## Sample apps
Both live in `commonMain` and also build for stock JVM Compose Desktop, which
serves as the visual and behavioural reference: any difference against the
native build is a porting bug.
**`:demo`** is a tour of the re-implemented Compose and Material 3 surface,
30-plus screens covering text, layout, shapes, images, state, lazy lists,
dialogs, canvas, graphics layers, animation, and gestures.
```bash
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
./gradlew :demo:run # JVM Compose Desktop (reference)
```
**`:apidemo`** is a Postman-style REST client built entirely on the library:
request collections, a session inheritance ladder, syntax-highlighted body
editors, a response viewer with timing and TLS-chain inspection, and mTLS
client certificates.
<img src="screenshots/apidemo.png" width="100%" alt="Compose Desktop Native API Manager" />
```bash
./gradlew :apidemo:runDebugExecutableMacosArm64
./gradlew :apidemo:run # JVM Compose Desktop (reference)
```
Pass `-Prenderer=sdl3` on macOS or Linux to drop Skiko and use the pure SDL3
renderer everywhere.
## Modules
One Gradle module per upstream Compose artifact, mirroring the upstream
`compose/` tree. The renderer and cinterops live in `:ui`; `:window` is the SDL
integration layer. `:material-symbols`, `:components-resources`, and
`:navigation3-ui` are project or vendored modules that the official Maven
artifacts do not cover for Kotlin/Native desktop.
Most of the androidx architecture stack (lifecycle, viewmodel, navigation3,
savedstate, navigationevent) ships real Kotlin/Native desktop klibs and runs on
the port unmodified. The full module map, dependency graph, and the list of
compatible artifacts are in [CLAUDE.md](CLAUDE.md).
## Building
All four native dependencies — **SDL3**, **SDL3_ttf** (the in-house
variable-font-axes fork), **SDL3_image** (+ vendored PNG/WEBP codecs) and
**FreeType** — are built from source as **static** libraries by one script,
on every OS, and linked straight into the executable. No `brew install`, no
`apt install libsdl3-dev`, no runtime `.dll` / `.so` / `.dylib` next to the
binary — a distributable is just `<app>` + `data.kres`.
Build the native libraries once per machine, then build any app target:
```bash
python3 scripts/build-sdl/build-all.py # freetype → sdl3 → sdl3-image → sdl3-ttf
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild a subset
python3 scripts/build-sdl/build-all.py # SDL3, SDL3_ttf, SDL3_image, FreeType (static)
./gradlew :demo:runDebugExecutableMacosArm64
```
Library versions / URLs are pinned in `scripts/build-sdl/build-sdl.properties`;
output lands in the gitignored in-repo `libs/`. Required on every host: `git`,
`cmake`, Python 3 (`ninja` is fetched automatically when absent). Per host:
Every host needs `git`, `cmake`, and Python 3. Per host: macOS needs the Xcode
command line tools; Linux needs gcc/g++ and the X11 / Wayland / audio dev
headers; Windows needs a mingw-w64 g++ on PATH. See [TOOLING.md](TOOLING.md) for
the full build and verification workflow.
- **macOS** — Xcode command line tools. Skia is the default renderer; Skiko
klibs come from Maven.
- **Linux** — gcc/g++ plus the X11 / Wayland / audio dev headers SDL3's
configure detects (the exact apt list is in
`.github/workflows/publish.yml`).
- **Windows** — a mingw-w64 g++ on PATH (the C side compiles with
Kotlin/Native's bundled mingw).
## Documentation
Then build any app target — see the demo / apidemo commands above.
## Vendoring
The bulk of the `androidx.compose.*` code is vendored byte-for-byte from
`JetBrains/compose-multiplatform-core` and lives under
`<module>/src/vendor/` (gitignored — you re-sync on demand). Each module
carries a `compose-fork.txt` manifest listing which upstream files it pulls
in.
```bash
scripts/compose-fork/sync.sh # sync every module
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt # one module
```
Upstream ref pinned in `scripts/compose-fork/compose.properties`; manifests can
pin additional upstream repos inline (`SET_REPO=<url>@<ref>` — the resources
runtime vendors from the compose-multiplatform umbrella repo this way).
How much of upstream's public API the port actually covers is measured, not
guessed — `scripts/compose-coverage.py` diffs this repo's klib ABI dumps
against upstream's, per vendored module:
```bash
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
python3 scripts/compose-coverage.py --missing ui-text # list the exact uncovered decls
```
See [CLAUDE.md](CLAUDE.md) for the full architecture, source-set hierarchy,
vendoring rules, density flow (physical-pixel Option B), and per-area file
map.
## Known Compatible — official artifacts that just work
A surprising amount of the androidx architecture stack ships real
Kotlin/Native desktop klibs (mingwX64, linuxX64/arm64, macosArm64) and runs
on this port **unmodified** — no reimplementation, no vendoring. Sometimes
the Google coordinates (`androidx.*`) carry the K/N variant, sometimes the
JetBrains ones (`org.jetbrains.*`) — check both.
Verified working in-tree:
| Artifact | Notes |
|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| `org.jetbrains.compose.runtime:runtime`, `runtime-saveable` | the Compose runtime itself — never reimplemented |
| `androidx.compose.runtime:runtime-retain` | google coordinates |
| `androidx.lifecycle:lifecycle-runtime-compose` | `LocalLifecycleOwner`, `rememberLifecycleOwner`, `repeatOnLifecycle` |
| `androidx.lifecycle:lifecycle-viewmodel(-compose, -savedstate)` | full ViewModel + SavedStateHandle stack |
| `androidx.lifecycle:lifecycle-viewmodel-navigation3` | per-entry ViewModel scoping for Navigation 3 |
| `androidx.savedstate:savedstate`, `savedstate-compose` | |
| `androidx.navigation3:navigation3-runtime` | backstack / NavEntry / decorators (only the UI layer needed vendoring → `:navigation3-ui`) |
| `androidx.navigationevent:navigationevent-compose` | predictive back / BackHandler plumbing |
| `androidx.collection:collection` | |
The window layer supplies what these expect from a host: per-window
`LifecycleOwner` (driven by real SDL focus/visibility events — focused →
RESUMED, unfocused → STARTED, minimized → CREATED), `ViewModelStoreOwner`
(the `activityViewModels()` analog, `SavedStateHandle`-enabled) and
`SavedStateRegistryOwner`, plus a `Dispatchers.Main` with a true `immediate`.
ViewModels scope per nav entry, per window, or anywhere in between — same
semantics as Android.
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
- [CLAUDE.md](CLAUDE.md): architecture, source-set hierarchy, vendoring rules,
density flow, and a per-area file map.
- [gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md):
consuming the port from another build.
## License
+263
View File
@@ -0,0 +1,263 @@
# RENDERER.md
The durable reference for the rendering layer: how it is structured, the
convergence work that shaped it, the decisions worth remembering, and what is
left. Read this before touching anything under `renderer/`, the graphics
actuals, or the layer engine.
For build and verification commands see [TOOLING.md](TOOLING.md). For module
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
## 1. The two renderer legs
Rendering is pluggable behind one `RenderBackend` interface. Exactly one leg is
active per target:
- **Skia leg** (`skikoRendererMain`, macOS + Linux). Draws through upstream
Compose Multiplatform's own Skia stack, vendored verbatim: `SkiaBackedCanvas`,
`SkiaBackedPaint`, `SkiaShader`, `actual class GraphicsLayer` backed by
`org.jetbrains.skiko.node.RenderNode`, `SkiaGraphicsContext`. On this leg the
layer and draw engine internals are literally upstream.
- **SDL leg** (`sdlRendererMain`, Windows always; macOS/Linux under
`-Prenderer=sdl3`). A from-scratch renderer on `SDL_RenderGeometry` +
`SDL3_ttf` + FreeType. SDL is a triangle blitter, so this leg carries a
permanent bespoke surface (about 3,800 lines): `Sdl3Canvas`,
`Sdl3TextRenderer`, `FreeTypeIcons`, and the SDL render nodes.
Both legs share one project-owned text and image pipeline (`SdlParagraph`,
`NativeTextMeasurer`, `IconFont`). Text layout, line-breaking, metrics, and
hit-test are the port's engine on both legs; only glyph rasterization differs
(Skia vs SDL3_ttf/FreeType). This is a deliberate axiom, not a gap: see the
text decision in section 4.
The seam is kept as narrow and low as possible. Code flows
`Common (upstream) -> shared native engine -> Skia actual / SDL actual`. What we
minimize is hand-rolled actual surface, not actual-side line count: a fat
vendored-upstream actual (the Skia leg carrying upstream's whole GraphicsLayer
stack) is preferred over a thin hand-written one.
## 2. The retained-layer engine (the model we copied)
Upstream skiko skips work at three levels. The port has all three.
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
`SDL_WaitEventTimeout` when idle.
- **L2 measure/layout only dirty nodes.** Vendored `MeasureAndLayoutDelegate`
used verbatim through `ComposeOwner.measureAndLayout()`.
- **L3 draw record-once / replay** (the expensive one). Each isolated
`LayoutNode` gets an `OwnedLayer` (`GraphicsLayerOwnerLayer`) owning a
`GraphicsLayer`. The display list re-records only when content is dirty;
otherwise it replays.
The critical property: **transform, alpha, and clip changes do NOT re-record.**
Moving, scaling, rotating, or fading a layer replays cached content under a new
transform. Only a genuine content change (a state read inside the draw block, or
a resize) re-records. Upstream replays the whole scene every frame with no
dirty-region present; the entire win is not re-recording clean layers, not
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
The compositing-strategy contract (`requiresLayer()`, from
`SkiaGraphicsLayer.skiko.kt`) must match on both legs. It decides when a layer
needs an offscreen:
| Condition | Auto | Offscreen | ModulateAlpha |
|---|---|---|---|
| `alpha < 1` | offscreen | offscreen | per-op alpha multiply (no offscreen) |
| `colorFilter != null` | offscreen | offscreen | offscreen |
| `blendMode != SrcOver` | offscreen | offscreen | offscreen |
| `renderEffect != null` | offscreen | offscreen | offscreen |
| none of the above | replay in place | always offscreen | replay in place |
## 3. Current state
- **Skia leg runs upstream's engine.** Canvas (`SkiaBackedCanvas`) and
GraphicsLayer/GraphicsContext (`org.jetbrains.skiko.node.RenderNode`) are
vendored verbatim. The Skia leg gets real display-list caching and correct
clip/shadow/renderEffect for free from upstream. After this landed, Skia
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
- **SDL leg ships the geo node as the Windows default.** `SdlDisplayListRenderNode`
records a leaf's layer-local tessellated geometry, text runs, and icon glyphs
by params (not texture pointers), then re-emits through the layer transform.
Crisp under any transform, bit-exact, deterministic (no render-target state),
and eviction-safe (replay re-looks-up via the run LRU / FreeType glyph cache).
0.000% across a 57-screen self-consistency sweep bar a sub-0.06% rotated-edge
AA fringe. LazyColumn steady-state `draw` fell 57% versus the un-cached
baseline. Fallbacks: `CDN_LAYERCACHE=off|texture`.
- **Parity is a golden-master on the Skia leg** (about 2% median, dominated by
the shared text engine's small metric delta) and a bounded, ranked signal on
the SDL leg. See TOOLING.md for how to read it.
- **Memory is stable.** The historical composition leak is fixed (see section 6)
and guarded by the `--soaktest` gate in the verify runbook.
## 4. Decisions to remember
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
reconciliation cost when following upstream, not Windows pixel-parity or
feature completeness. The JVM Compose Desktop target (`:demo:run`) is the
documented Windows fidelity/feature tier: real Skia, full fidelity, on any
host. That makes SDL-leg fidelity work (B3) and real-Skia-on-Windows (Track A)
optional polish, not obligations.
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
Edit-to-compile becomes a manual vendor with a `// VENDOR-BASE:` header so the
drift tripwire can track it. The litmus test for any divergence: "Is this what
upstream does? If not, what real platform constraint forces the difference?"
Valid answers name a constraint (no Skiko on Windows K/N; SDL is a triangle
blitter). "It was easier" is not valid.
- **B2 convergence is complete.** The Skia leg was migrated off the port's
hand-rolled GraphicsLayer onto upstream's own. This was a source-set migration,
not a file-flip: the SDL node cluster relocated `nativeMain -> sdlRendererMain`,
`GraphicsContext` forked per-leg behind a `createGraphicsContext()` seam, and
the upstream skiko files were un-refused in `compose-fork.txt`. Measured
sync-tax reduction on the skiko path: about 8.5x (roughly 1240 hand-rolled
lines down to about 145 lines of drift-tracked edits; roughly 1006 lines now
auto-sync verbatim on a ref bump). The beta02 -> beta03 bump proved it: zero
reconciliation, all manual-vendor bases unchanged.
- **Text stays the port's engine (B6.3 skipped).** Making Skia-leg text truly
upstream (`SkiaParagraph`) is a font-subsystem replacement, not a canvas swap:
two coexisting font-identity models (the port's name-to-bytes `IconFont` +
no-op resolver vs upstream `PlatformFont`/`FontCache`/`FontCollection`), about
13 skiko files to re-vendor, about 10 shared actuals to split, a
`data.kres`-to-`FontCache` bridge, no green intermediate, and it discards the
P3.1 metrics work. ROI is low: the Skia leg already measures text with skiko
`Font` metrics (via `currentTextMeasurer` -> `SkiaTextRenderer`), which is how
parity reached about 2%. Revisit only if complex-script/bidi fidelity on the
native Skia leg becomes a hard requirement.
- **`GraphicsLayerOwnerLayer` stays shared.** The fork point is `GraphicsLayer` /
`GraphicsContext`, not the owner layer. The shared owner layer compiles
against both `GraphicsLayer` actuals (upstream skiko + port SDL) only if both
satisfy the commonMain `expect class GraphicsLayer` API. That API-parity
invariant is enforced by `:ui:compileCommonMainKotlinMetadata` on every build.
- **Lifetime model.** The SDL leg uses GC / release-queue for layer lifetime and
does NOT vendor `ChildLayerDependenciesTracker`. The Skia leg uses upstream's
`SkiaGraphicsLayer`, which inherently uses the tracker (already vendored in
commonMain). A per-leg lifetime divergence is the class of bug that caused an
early navigation crash, so any change here goes to both legs plus a soak gate.
- **Module split (`:ui-graphics` / `:ui-text`) is shelved as infeasible.** Not
cosmetic churn as originally scoped: the `sdl3` cinterop is a shared substrate
used by 11 graphics-side files AND 12 platform/windowing/node/resources files
that stay in `:ui`. Kotlin/Native cannot cleanly share a cinterop klib across a
module boundary, which is exactly why they were merged. A real split needs a
new non-upstream `:ui-cinterop` base module plus cross-module cinterop
api-exposure, which is bigger and more divergent than the "match upstream
artifacts" goal it was meant to serve. The one keeper from the attempt:
`RenderBackend.drawRoot` now takes `(Canvas) -> Unit`, decoupling the backends
from `ComposeRootHost` (a genuine improvement, retained).
- **Track A (real Skia on Windows K/N) is shelved.** Kotlin/Native mingwX64 is
GNU-ABI; skia-pack Windows is MSVC-ABI, which cannot be statically fused into a
GNU-ABI binary. Building current C++20 Skia to a GNU/mingw static archive is an
open-ended fork (only abandoned Mozilla-era precedent). The only working route
is a runtime DLL, which breaks the no-DLL invariant and adds tens of MB on the
one platform it targets. The SDL leg is the permanent Windows renderer; JVM is
the fidelity escape hatch.
## 5. Convergence status
| Item | Status |
|------|--------|
| Guardrails (parity gate, verify-mac runbook, drift + vendor-clean checks) | Done |
| B2: Skia leg on upstream GraphicsLayer/GraphicsContext | Done |
| B6.1: Skia leg on upstream `SkiaBackedCanvas` + paint/shader | Done |
| B6.2: upstream `GraphicsLayer` + delete transient port cluster | Done |
| B6.3: upstream text (`SkiaParagraph`) on the Skia leg | Skipped (decision) |
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
| P2.2: composition memory leak | Fixed + soak-gated |
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
| P3.1: SDL text metrics parity (17% median -> 2%) | Done |
| B3: further SDL fidelity | Capped (parity-ranked wins only) |
| Module split | Shelved (infeasible as specified) |
| Track A: real Skia on Windows | Shelved |
## 6. Remaining and future work
Nothing here blocks day-to-day work. These are the open threads worth
remembering.
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook covers both renderers
but cannot cover the shipped mingwX64 binary, the Windows-only `PrintWindow`
probe, or the common-metadata publish job. Run these on a Windows host before
any release. This is the only outstanding verification.
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
`v1.12.0-beta03+dev4483` (no clean beta03 tag exists yet, and it is not on
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
published) by a documented skew. When 1.12.0 stable ships: re-pin both refs to
the clean tag, bump the `vComposeJvmVersion` forcing in demo/apidemo/
material-symbols to close the skew, then cut the release. The concrete
step-by-step (the version map, the ref-bump flow, and the release flow) lives
in [TOOLING.md](TOOLING.md#versioning-and-releasing).
- **Deferred engine deltas (D2-D6, all low ROI under G1).**
- D2: split `GraphicsLayerOwnerLayer` per-leg to restore upstream's exact
`setLightingInfo` shadow lighting. Feasible (the old RootNodeOwner-coupling
blocker was outdated) but high blast radius for a marginal gain; elevation
shadows already match JVM.
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
both-legs matrix fn).
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
accessibility or complex-script roadmap.
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
vendoring them saves nothing.
- **SDL performance (opportunistic).** Shared glyph atlas to replace per-run
textures; `drawImageRect` capture in the geo node (journal rates it low-ROI).
- **SDL_GPU backend (long-term).** Real stencil clipping, pipelined batching,
shader gradients. The `NativeRenderNode` seam makes a GPU node a clean
drop-in.
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
`SdlImageBitmap.close()` fully into cache eviction and the renderer
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
## 7. Hard-won learnings (do not relearn)
- **Minimize-divergence is load-bearing.** A hand-written `notifyLayerIsDirty`
that diverged from upstream `OwnedLayerManagerImpl` removed a layer from
`dirtyLayers` mid-loop and crashed on any navigation. Screenshots missed it;
the interaction probe caught it. Match upstream verbatim; do not hand-roll
engine plumbing from a summary.
- **The composition memory leak (root cause).** The port never called
`OwnerSnapshotObserver.clearInvalidObservations()`, which upstream
`RootNodeOwner` runs after every measure. Snapshot read-observations for
scopes invalidated on dispose lingered forever, each pinning its observed
object graph via a Kotlin/Native `ExternalRCRef` (a K/N-heap leak, vmmap
Memory Tag 246). Most visible on ripple/indication draws. Fix:
`ComposeRootHost.measureAndLayout()` sweeps `clearInvalidObservations()` after
layout, plus `ComposeOwner.onDetach` clears the detached node's observations.
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
budget heap tooling for this class.
- **Offscreen-texture caching is timing-nondeterministic on complex screens.**
This is why the geo (no-render-target) node is the robust default, not the
texture node.
- **Rounded/path layer clips must be applied or deferred on the fast path.** The
geo path submits raw geometry, which clips only to a rect; rounded clips are a
lazy offscreen mask the fast replay bypasses. Rect clip on the fast path;
rounded/generic clips fall back to block-replay.
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
animated/settling screens give false signals. Use render-to-quiescence +
virtual frame time for parity, the probe for interaction/crash coverage.
- **The profiler `present` phase is vsync-capped by the display refresh.**
Profile on the target monitor before concluding a frame-rate gap.
## 8. Explicit non-goals
- **Dirty-region / partial present.** Upstream replays the whole scene; the win
is not re-recording, not redrawing less.
- **A custom `cacheKey` API.** Superseded by the real per-node display list.
- **Vendoring `RootNodeOwner` / the `ComposeScene` stack.** Coupled to skiko's
`SkiaLayer` and windowing; it fights the `:window` SDL loop. The port borrows
only the layer engine.
## 9. Key files
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3RenderBackend.kt`.
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/FreeTypeIcons.kt`:
variable-font axis rasterization.
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
hit-test, event dispatch, snapshot observer sweep.
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
`Owner` + `GraphicsLayerOwnerLayer` bridge.
- `compose/ui/ui/src/nativeMain/.../ui/text/SdlParagraph.native.kt`: the bridged
`Paragraph` (measurement, hit-test, line metrics, span painting).
-91
View File
@@ -1,91 +0,0 @@
# Renderer roadmap — fixes, performance, memory
Grounded in the bubble-wrap findings (2026-07): lazy clips took the stress
sheet from 23 → 39 FPS, but 75 Hz needs structural work; issue #2 showed that
Cleaner-managed native memory starves when the Kotlin heap is quiet.
## 1. Deterministic native-resource lifecycle
Ownership closes resources the moment they're finished; GC stays as the
safeguard, not the mechanism.
- [x] **`NativeReleaseQueue`** — SDL calls are main-thread-only, so nothing may
destroy directly from a Cleaner or worker: release actions ENQUEUE, the main
loop DRAINS between frames. Cleaners become safe from any thread because
they only enqueue. Verified end-to-end (direct enqueue + a real
Cleaner→enqueue→drain cycle).
- [x] **`SdlImageBitmap` textures** — previously never destroyed (manual SDL
memory, invisible to the GC nudge). A holder + Cleaner now enqueues
`SDL_DestroyTexture` / `SDL_DestroySurface` for the texture, render target
and never-realized decoded surface; explicit `close()` frees promptly.
- [ ] **Wire `close()` into cache eviction** — the Cleaner is the current
release path for decoded/vector bitmaps (correct but GC-timed). Call
`SdlImageBitmap.close()` on image-cache eviction / `removeMemoryResource`
for prompt release (needs a cross-module hook — the caches hold the
`ImageBitmap` interface, not the concrete type).
- [ ] **Cache-eviction closes (rest)** — every image/text cache closes what it
evicts (Sdl3ImageCache already does; sweep the rest, both renderers).
- [ ] **Renderer `destroy()` chain** — window close synchronously frees every
pool (clip targets already do; text caches, image caches, shadow cache,
typefaces to follow).
- [ ] **`SkiaImageBitmap.close()`** (surface + snapshot) + close-on-eviction in
the Skia caches. *Skia source sets build only on macOS/Linux — needs a mac
or CI leg to verify.*
- [ ] **Demote the GC nudge** — once ownership does the work, stretch the 10s
interval / gate it on churn; it remains the backstop.
Verification: `demo --leaktest`-style probe (cycle screens N times, print
RSS; expect a plateau without GC sawtooth).
## 2. SDL renderer performance (the 39 → 75 Hz path)
- [x] **Frame profiler** — `CDN_PROFILE=1` env flag prints per-phase timings
(events / app pump / window pump / render / present) every ~2 s. Measure
first, optimize second.
- [ ] **Right-size clip scratch targets** — the pool allocates window-sized
textures per mask depth; allocate at the clip bbox (size-bucketed pool) to
cut fill-rate for the masks that remain after lazy clips.
- [ ] **Dirty-region rendering** — the big structural item: accumulate damage
from invalidated layers, scissor redraw to it. One bubble's spring should
not re-tessellate 84. Most likely single item to reach 75 Hz.
- [ ] **Glyph atlas** — text draws currently break geometry batches (z-order
flush per run); an atlas texture lets glyphs ride the vertex batches.
- [ ] **Retained layer textures** (later) — RenderNode-style caching of static
subtrees keyed by draw content.
## 3. Skia renderer
- [ ] Bounded `SkiaImageCache` (LRU + eviction close) — unbounded HashMap today.
- [ ] Eager-close audit of the text pipeline (mirror of the SDL sweep).
- [ ] `saveLayer` huge-bounds clamp (GPU offscreen memory spikes — see
CLAUDE.md pitfall).
## 4. Correctness / parity gaps (future bug reports waiting)
Ordered by likelihood of a user hitting them:
- [ ] **Gradient brushes on SDL** — `Brush.linearGradient/radialGradient`
render solid; the per-vertex sampler infrastructure is half-present.
- [ ] **Layer rotation** — rotationZ repositions but content doesn't rotate
(hit-testing too).
- [ ] **Real `saveLayer` alpha on SDL** — overlapping content composites at
paint level; needs an offscreen (the clip-target pool can serve it).
- [ ] **`clipPath` generic shapes** — bbox fallback clips square.
## 5. Permanent tooling
- [x] **Parity harness** — scripts/parity/: renders every :demo screen native +
JVM from the same commonMain composables, pixel-diffs, ranks by %differ,
emits per-screen diff heatmaps + side-by-side compares (pct in filename).
build/parity/ (gitignored). See scripts/parity/README.md.
- [ ] Promote the press/hover automation rig (window-handle-relative input +
screenshot) into `scripts/`.
## 6. Long-term
- [ ] **SDL_GPU render backend** — real stencil clipping (no masks at all),
pipelined batching, shader gradients. Weeks of work across three
platforms; items 2–4 stay useful beneath it.
Suggested order: profiler → lifecycle queue + leak closes → clip-target
right-sizing → gradients → dirty regions → the rest by demand.
+160
View File
@@ -0,0 +1,160 @@
# TODO.md
Everything the port currently leaves as a no-op, stub, hardcode, or partial
implementation, plus the gaps worth closing before a stable 1.12 release. This
is an audit of PROJECT code only (the `*.native.kt` / `*.sdl.kt` /
`com.compose.sdl.*` actuals); vendored upstream under `src/vendor/` is out of
scope.
Severity is a rough guide, not a mandate:
- **Blocker** for stable: a capability a typical desktop app expects that is
currently absent or broken.
- **Nice-to-have**: real gap, but the app works without it.
- **Cosmetic**: minor fidelity or edge-case behavior.
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
(see [RENDERER.md](RENDERER.md)). The **JVM Compose Desktop target is the
documented Windows fidelity/feature tier**, so SDL-leg visual gaps (section J)
are lower priority than the cross-cutting platform gaps (input, locale, a11y,
cursor) that affect every backend including Skia on macOS/Linux.
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
here.
---
## Release-blocking shortlist for stable 1.12
The cross-cutting items to weigh first (all detailed below):
1. **Locale is hardcoded to `en-US`** (section E). One fix unlocks the 40+
vendored Material 3 translations. Cheapest high-value win in this file.
2. **No IME / text composition** (section B). Only committed Latin text works;
CJK, dead keys, and accented input do not.
3. **No cursor changes** (section D). The OS cursor never becomes an I-beam over
text or a hand over clickables.
4. **No copy/paste context menu** (section C). Keyboard copy works; there is no
right-click or selection toolbar.
5. **Accessibility is entirely absent** (section A). Decide whether stable
requires any screen-reader support at all.
6. **Standard `Font(...)` / resource-font loading is a no-op** (section F). Only
pre-registered named fonts render. A blocker only if apps use the standard
font APIs.
---
## A. Accessibility (entirely absent)
No accessibility bridge exists. A real `SemanticsOwner` is built but never
surfaced to any OS accessibility API (no NSAccessibility / UIA / AT-SPI, no SDL
a11y). Screen readers get nothing.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:340` · `semanticsOwner` is instantiated but never read; no traversal into a platform a11y tree. A real a11y controller would walk the semantics tree and map roles/labels/actions to a native AT bridge. **Blocker for a11y.**
- `compose/ui/ui/src/nativeMain/.../platform/CompositionLocals.native.kt:31` · `LocalPlatformScreenReader` default throws (`error(...)`); never provided by any window. Screen-reader-active queries are unavailable. **Blocker for a11y.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
## B. Text input / IME
Committed text, arrow/backspace/enter editing, and mouse-drag selection work.
Composition (preedit) does not.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:407` · `textInputSession` awaits cancellation forever (`awaitCancellation()` at :414); no real `PlatformTextInputSession`. The modern `PlatformTextInputModifierNode` pipeline (BasicTextField2) has no IME start/stop, composition, or cursor-rect reporting. **Blocker.**
- `compose/ui/ui/src/nativeMain/.../SDL3EventMapper.kt:126` · maps `SDL_EVENT_TEXT_INPUT` (committed) only; `SDL_EVENT_TEXT_EDITING` (preedit/composition) is never mapped, so there is no underlined composing region for dead keys / CJK. **Blocker for CJK and dead-key input.**
- `compose/ui/ui/src/nativeMain/.../SDL3Backend.kt:111` · `SDL_StartTextInput` is called once at window init and never scoped to the focused field; `SDL_SetTextInputArea` is never called, so the IME candidate window renders at the wrong location. **Nice-to-have.**
- `compose/ui/ui/src/commonMain/.../text/input/NoOpPlatformTextInputService.kt:24,32` · legacy `PlatformTextInputService` `startInput`/`stopInput`/`updateState` + software-keyboard toggles are all NOP. Superseded by the modern path above. **Nice-to-have / cosmetic (desktop).**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:328` · `softwareKeyboardController.show/hide` NOP. **Cosmetic (desktop).**
## C. Text toolbar and context menus (copy / paste / select-all)
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. No selection or right-click copy/paste popup anywhere. Keyboard copy still works. **Blocker for text UX.**
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No copy/select-all menu for `SelectionContainer`. **Nice-to-have.**
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Nice-to-have.**
## D. Pointer / cursor icons
The OS cursor never changes. No SDL cursor API is called anywhere in the port.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:333` · `pointerIconService.getIcon()` always returns `Default`; `setIcon()` body is empty. This is the hook `Modifier.pointerHoverIcon` drives, so hover cursor changes are swallowed. Should store the requested icon and call `SDL_SetCursor`. **Blocker for UX.**
- `compose/ui/ui/src/nativeMain/.../input/pointer/PointerIcon.native.kt:18` · `pointerIconDefault|Crosshair|Text|Hand` are inert marker objects carrying only a name; nothing maps them to `SDL_SYSTEM_CURSOR_*`. Needs `SDL_CreateSystemCursor` + `SDL_SetCursor` (and cursor caching in the `:window` loop). **Blocker (paired with above).**
## E. Locale and internationalization
System-locale plumbing already exists in exactly one place:
`components/resources/.../ResourceEnvironment.native.kt:38` reads
`SDL_GetPreferredLocales()` for resource-qualifier resolution. Every other i18n
consumer ignores it and hardcodes English. Wiring `Locale.current` to that same
call is the flagship cheap win.
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt:57` · `DefaultLocale` is a fixed `Locale("en-US")`, so `Locale.current` can never change. This one value keys Material 3 string translation selection, stranding the 40+ vendored `material3/internal/l10n/*.kt` tables (they are present and reachable by tag, but the key is always en-US). Fix by querying `SDL_GetPreferredLocales()` (already bound in the `sdl3` cinterop). **Blocker; fixing it transitively localizes all of Material 3.**
- `compose/ui/ui/src/nativeMain/.../text/intl/PlatformLocale.native.kt:18` · `createPlatformLocaleDelegate().current` hardcodes `LocaleList(listOf(Locale("en-US")))`; should reflect the full ordered preferred-locale list SDL returns (the resources module currently reads only `[0]`). **Blocker.**
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`, so it needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. **Blocker (DatePicker/TimePicker).**
- `compose/material3/.../internal/PlatformDateFormat.native.kt` · multiple hardcodes:
- `:27` `weekdayNames` hardcoded English full/short names (DatePicker shows English day initials regardless of locale). **Blocker.**
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker.**
- `:25` `firstDayOfWeek` hardcoded `1` (Sunday); locale-dependent. **Nice-to-have.**
- `:44` `parse()` only accepts ISO `yyyy-MM-dd`, ignoring pattern/locale. **Nice-to-have.**
- `:62` `getDateInputFormat()` hardcoded `yyyy-MM-dd` / `'-'`. **Nice-to-have.**
- `:65` `is24HourFormat()` hardcoded `true`. **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../text/platform/NativeStringDelegate.native.kt:17` · `toUpperCase`/`toLowerCase` use locale-independent stdlib casing; the `locale` param is ignored (wrong for Turkish dotted/dotless i, etc.). **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt:17` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SdlParagraph.native.kt:333` `getBidiRunDirection` always returns `Ltr`; owners/DrawScopes default `LayoutDirection.Ltr`). No RTL shaping. **Nice-to-have.**
- No `NumberFormat` / currency / decimal-grouping actual exists; numbers render via `toString()` with `.` decimal and no grouping regardless of locale. **Cosmetic.**
**Beyond a real `getLocale`:** SDL reports language + country only (no script subtag, no calendar/clock/number preferences), and Kotlin/Native bundles no ICU. Localized month/weekday name tables, date/number/currency patterns, first-day-of-week, locale-aware casing, and RTL all still require a bundled CLDR subset or a third-party K/N i18n library. The SDL wiring is the cheap 80%; these are the expensive tail.
## F. Font resolution
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP (return `Unit`); font bytes for `ResourceFont` / `Font(bytes)` / `LoadedFontFamily` are never loaded. Only pre-registered `NamedFont` names reach the renderer. **Blocker if the app uses standard `Font(...)` / resource-font APIs; otherwise nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)`; any code reading the resolved typeface gets `Unit` (the renderer bypasses this via name lookup, so usually harmless). **Nice-to-have.**
- `compose/ui/ui/src/commonMain/.../text/NamedFont.kt:62` · `FontFamily.projectFontName` resolves only a `FontListFontFamily` whose first font is a `NamedFont`; returns `null` for `Serif`/`SansSerif`/`Monospace`/`Cursive`/`Default`, `LoadedFontFamily`, and resource-backed families, so all of those collapse to the single default font (no serif/mono/cursive distinction). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:69` · deprecated `createFontFamilyResolver(fontResourceLoader)` ignores the supplied loader. **Cosmetic.**
## G. Text rendering details
- `compose/ui/ui/src/nativeMain/.../text/SdlParagraph.native.kt:376` · `drawNativeText` accepts and ignores text `Shadow`, stroke `DrawStyle`, and non-`SrcOver` blend, so those never render on text. **Cosmetic.**
- `compose/foundation/.../text/StringHelpers.native.kt:31` and `compose/ui/ui/src/nativeMain/.../text/CharHelpers.native.kt:14` · `findPrecedingBreak`/`findFollowingBreak` (and `offsetByCodePoints`) do a codepoint/surrogate walk only, with no ICU grapheme clusters, so caret movement and backspace split combining marks and emoji ZWJ sequences. **Nice-to-have.**
## H. Drag and drop
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:38` · `requestDragAndDropTransfer` is a NOP and `isRequestDragAndDropTransferRequired` returns `false`; `Modifier.dragAndDropSource` compiles but no OS-level drag leaves the window. Needs per-OS native work (NSDraggingSession / DoDragDrop / XDND); SDL3 has no portable start-drag. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
## I. Window and platform info
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:354` · `windowInfo.containerSize`/`containerDpSize` return `IntSize.Zero`/`DpSize.Zero`; `LocalWindowInfo.containerSize` readers (popup sizing, window-size-aware layout) get 0. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:353` · `windowInfo.isWindowFocused` hardcoded `true`; not fed by SDL focus events (which the window already tracks). Focus-reactive UI will not react. **Nice-to-have.**
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:279` · `hapticFeedBack.performHapticFeedback` NOP (no desktop pointer haptics API). **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
## J. SDL renderer graphics (Windows / `-Prenderer=sdl3` only)
These affect only the SDL leg. On macOS/Linux the Skia leg implements them
correctly, and JVM is the documented Windows fidelity tier, so these are lower
priority under G1. Listed because SDL is the shipped Windows renderer.
- `compose/ui/ui/src/sdlRendererMain/.../graphics/PathEffect.sdl.kt:15` · `dash`/`corner`/`chain`/`stamped` path effects are all NOP; dashed/dotted strokes render solid. **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/RenderEffect.sdl.kt:16` · `RenderEffect.isSupported()` is `false`; `Modifier.blur()` and `graphicsLayer{renderEffect=...}` do nothing. `compose/ui/ui/src/sdlRendererMain/.../graphics/shadow/Blur.native.kt:33` · `Paint.setBlurFilter` NOP. **Nice-to-have (Windows visual parity).**
- Gradients render real ramps, but `TileMode` is dropped: `t` is clamped, so `Repeated`/`Mirror`/`Decal` all behave as `Clamp` (`Sdl3DrawScope.kt:1070` samplers; `CanvasPaintActuals.native.kt:66`). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/CanvasPaintActuals.native.kt:106` · `ActualImageShader`/`ActualCompositeShader` are stubs; image/composite `ShaderBrush` degrades to solid fill (white/black). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/ColorFilter.sdl.kt:13` · tint/color-matrix/lighting color filters are empty stubs; only `BlendModeColorFilter` tint on image blits is honored (`Sdl3Canvas.kt:1251`). ColorMatrix (grayscale/saturation) and all filters on shapes are inert. **Nice-to-have.**
- BlendMode: only `SrcOver` (plus a `Clear` special case) composites; `Multiply`/`Screen`/`Plus`/etc. are ignored (`Sdl3DrawScope.kt:206`, `Sdl3Canvas.kt:926`). Note `BlendMode.isSupported()` misleadingly returns `true`. **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:436` · `saveLayer` has no offscreen buffer; layer alpha is multiplied into each primitive, so overlapping content double-composites (wrong group opacity) and the layer paint's colorFilter/blendMode/renderEffect are dropped. **Nice-to-have (blocker for correct group-alpha over overlapping content).**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:509` · `clipPath` degrades an arbitrary path to its bounding box; rotated/sheared `clipRect` collapses to an AABB. Non-rect clips leak. (Rounded/difference clips are real, with feathered AA.) **Nice-to-have.**
- Stroke joins (`StrokeJoin`) are never applied, so polyline corners notch; `StrokeCap.Square` falls back to butt (`Sdl3DrawScope.kt:380,342`). **Nice-to-have / cosmetic.**
- `PathFillType` (NonZero vs EvenOdd) is mostly ignored beyond a 2-contour border case; interior holes and self-intersections are not cut out, and concave fills self-overlap (`Sdl3DrawScope.kt:366,717`). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:1276` · `drawPoints`/`drawRawPoints`/`drawVertices` are NOP; point-mode drawing and custom vertex meshes render nothing. **Nice-to-have.**
- Images under a rotated layer are not rotated (`SDL_RenderTexture` is axis-aligned, `Sdl3Canvas.kt:1264`); stroked non-square ovals stroke as a circular ring (`Sdl3DrawScope.kt:527`); `SdlImageBitmap.readPixels` is a no-op (`Sdl3Offscreen.kt:117`). **Cosmetic / nice-to-have.**
- Drop shadow is approximated (9-slice / stacked rings, not a gaussian blur; ambient vs spot largely collapsed) at `Sdl3Canvas.kt:785`; AA is a ~1px geometry fringe rather than analytic coverage. **Cosmetic.**
## K. GraphicsLayer / image
- `compose/ui/ui/src/sdlRendererMain/.../graphics/CanvasPaintActuals.native.kt:151` · `createImageBitmap(bytes)` throws `UnsupportedOperationException`. (`GraphicsLayer.toImageBitmap` and `snapshotBgra` do work on both renderers.) **Nice-to-have.**
+202
View File
@@ -0,0 +1,202 @@
# Tooling
Helper scripts and workflows used to build, vendor, and verify Compose Desktop
Native. Each has a focused job; the deeper ones link to their own README.
For architecture, source-set layout, and vendoring rules, see
[CLAUDE.md](CLAUDE.md).
## Native libraries
SDL3, SDL3_ttf, SDL3_image, and FreeType are built from source as static
libraries and linked straight into the executable. One script does it on every
OS. Output lands in the gitignored `libs/`; versions are pinned in
`scripts/build-sdl/build-sdl.properties`.
```bash
python3 scripts/build-sdl/build-all.py # freetype, sdl3, sdl3-image, sdl3-ttf
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild one step
```
Needs `git`, `cmake`, and Python 3 on every host (`ninja` is fetched when
absent). Run it once per machine, or after bumping the pinned versions.
## Vendoring upstream Compose
Most `androidx.compose.*` code is copied byte for byte from
`JetBrains/compose-multiplatform-core` into each module's gitignored
`src/vendor/`. A `compose-fork.txt` manifest per module lists which upstream
files it pulls; the pinned ref lives in `scripts/compose-fork/compose.properties`.
```bash
scripts/compose-fork/sync.sh # re-sync every module
scripts/compose-fork/sync.sh compose/ui/ui/compose-fork.txt # one module
```
Reach for this after a fresh checkout or a ref bump. Details:
[scripts/compose-fork/README.md](scripts/compose-fork/README.md).
Guardrails that keep the vendor tree honest:
```bash
python3 scripts/compose-fork/check-vendor-clean.py # src/vendor matches the pinned refs
python3 scripts/compose-fork/check-vendor-drift.py # hand-edited "manual vendors" match their pin
```
`check-vendor-clean` fails if a hand-edit ever leaks into `src/vendor/`.
`check-vendor-drift` reads the `// VENDOR-BASE:` header on every edited copy and,
using the local upstream clone, reports whether the base actually changed since
the pin (needs reconciling) or is merely stale (safe to re-stamp). Run both on
every ref bump.
### Coverage against upstream
How much of upstream's public API the port covers is measured, not guessed.
```bash
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
python3 scripts/compose-coverage.py --missing ui-text # list uncovered declarations
```
## Verification
### One-command gate
`verify-mac.sh` is the runbook to run after any renderer or layout change. On
macOS or Linux it exercises BOTH renderer legs (Skia by default, then
`-Prenderer=sdl3`) and exits non-zero on any failure:
```bash
scripts/verify-mac.sh
```
It runs, per leg: the vendor drift and clean checks, a build of `:demo` and
`:apidemo`, the interaction probes, the parity sweep, a memory soak, and a
frame-time spot check. The Windows target is verified separately (see below).
### Parity: native vs JVM
`:demo` renders the same shared screens on two stacks: this port, and stock JVM
Compose Desktop. `parity.py` screenshots every screen on both and pixel-diffs
them, so a screen that diverges is a porting bug. It gates against per-screen
golden baselines.
```bash
python3 scripts/parity/parity.py # all screens
python3 scripts/parity/parity.py Buttons Shapes # a subset
python3 scripts/parity/parity.py --no-build # reuse the last renders
```
How to read the diff and the percentage: [scripts/parity/README.md](scripts/parity/README.md).
### Interaction probe
`probe.py` launches a native window, sends window-relative input (click, hover,
hold), and captures the client area. It packages the rigs used to reproduce
interaction bugs. Windows-only. Details:
[scripts/probe/README.md](scripts/probe/README.md).
### Frame profiler
Set `CDN_PROFILE=1` (or `CDN_PROFILE=<path>`) and run any app. Every couple of
seconds it writes per-phase main-loop timings (events, app pump, render, with
draw sub-phases and per-frame draw counters) to a file. `CDN_FORCERENDER=1`
renders every frame so steady-state timings read on an otherwise idle screen.
```bash
CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo.kexe
```
Note: `present` is vsync-blocking, so profile on the target refresh rate before
drawing conclusions about a frame-rate gap.
### Demo probes
`:demo` ships headless regression probes driven by CLI flags, used by the
verify runbook and in isolation:
```bash
demo.kexe --nav3test # Navigation 3 + ViewModel + lifecycle
demo.kexe --backtest # predictive-back / BackHandler
demo.kexe --clicktest # pointer -> upstream clickable
demo.kexe --scrolltest # wheel -> scrollable
demo.kexe --multiwintest # multi-window lifecycle
demo.kexe --soaktest # memory soak (CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES)
```
### Windows smoke
The Mac runbook cannot cover the Windows target. Before shipping, on a Windows
host: build the mingwX64 executables, run `scripts/probe/`, and run a publish
dry run (only Windows compiles the full common-metadata variant table).
```bat
gradlew.bat :demo:runDebugExecutableMingwX64
gradlew.bat :apidemo:runDebugExecutableMingwX64
```
## Consuming the port
To build a third-party app against the published klibs, apply the bridge Gradle
plugin and declare official Compose Multiplatform coordinates; the plugin swaps
in the port's klibs on native desktop targets. Setup:
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
## Versioning and releasing
### Version map
Versions live in a few places, some of which move in lockstep. There is no
single file to edit; know which axis you are changing.
| Version | Where | Notes |
|---------|-------|-------|
| Project release version | The git tag `vX.Y.Z`. `PUBLISH_VERSION` (from the tag) feeds `vPublishVersion` in `build.gradle.kts`, which strips the leading `v`. Group is `com.bitsycore.compose.sdl`. | Set by the tag, not edited by hand. A non-publish build is `0.0.0-SNAPSHOT`. |
| Vendored Compose (native side) | `COMPOSE_CORE_REF` and `COMPOSE_REF` in `scripts/compose-fork/compose.properties`, plus `compose` in `gradle/libs.versions.toml`. | Pin to a durable tag (not a `+dev` commit upstream may GC). Re-sync after changing. |
| JVM parity forcing | `vComposeJvmVersion` in `demo`, `apidemo`, and `material-symbols` `build.gradle.kts`. | Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published. |
| Skiko | `skiko` in `gradle/libs.versions.toml`. | Must expose the `org.jetbrains.skiko.node` `RenderNode` / `GraphicsContext` API the vendored compose-core uses. Verify with a throwaway `skikoRendererMain` compile if unsure. |
| SDL3 / SDL3_ttf / SDL3_image / FreeType | `scripts/build-sdl/build-sdl.properties`. | Rebuild `libs/` with `build-all.py` after any change. |
| Bridge substituted version | Defaults to the bridge plugin's own published version; consumers override with the `composeDesktopNative.version` Gradle property. | The plugin publishes with the release tag, so a consumer on the matching plugin version resolves the right klibs automatically. |
### Bump the upstream Compose ref
Run this on each upstream bump; it is the flow that keeps the sync tax low.
1. Edit `COMPOSE_CORE_REF` / `COMPOSE_REF` in `compose.properties` (and `compose`
in `libs.versions.toml` if the coordinate version moved).
2. `scripts/compose-fork/sync.sh`
3. `python3 scripts/compose-fork/check-vendor-drift.py`. For any manual vendor
whose upstream base actually changed, reconcile by hand; otherwise re-stamp
its `// VENDOR-BASE:` header to the new ref.
4. Build both legs and fix any fallout.
5. `scripts/verify-mac.sh` (both legs green, including the soak and parity gates).
6. If a matching Compose version is now published to Maven, bump
`vComposeJvmVersion` in demo/apidemo/material-symbols to close the skew.
7. Run WIN-SMOKE on a Windows host.
8. Re-seed parity baselines only if metrics legitimately moved
(`parity.py --update-baselines`), and record why.
### Cut a release
1. Complete the ref-bump flow above and confirm it is green. For a STABLE
release, `vComposeJvmVersion` should match the vendored ref (no skew).
2. On a Windows host, run `gradlew :window:compileCommonMainKotlinMetadata`.
Only Windows compiles the full common-metadata variant table, and the root
KotlinMultiplatform publications live there; a macOS-only publish leaves the
roots without mingwX64 variants (this bit v0.1.15).
3. `git tag vX.Y.Z && git push origin vX.Y.Z`. This triggers `.github/workflows/publish.yml`.
What the publish job does, so you can read a failure:
- Each host publishes only its own target's publications to GitHub Packages
under `com.bitsycore.compose.sdl:*`. Windows additionally publishes the root
KotlinMultiplatform metadata, the `jvm` publication, and the bridge plugin
(only Windows declares every target).
- The `MODULES` list in the workflow must stay in sync with the modules in
`settings.gradle.kts`.
- A partial publish (for example a dropped connection mid-upload) deletes this
host's half-published versions for the tag and retries, so a rerun is safe.
- The demo and apidemo release binaries are zipped and attached to the GitHub
Release for the tag.
4. Verify a consumer build against the new version resolves through the bridge.
+3 -3
View File
@@ -20,8 +20,8 @@ val vHostSupportsMingw: Boolean by rootProject.extra
// COMPOSE_CORE_REF (see scripts/compose-fork/compose.properties); mirrors
// :demo's forcing (Gradle orders "+dev" BELOW the plain version, so the
// plugin's beta01 would win conflict resolution without it).
val vComposeJvmVersion = "1.12.0-beta01+dev4324"
val vComposeM3JvmVersion = "1.12.0-alpha03+dev4324"
val vComposeJvmVersion = libs.versions.compose.get()
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
val vComposeJvmForced = mapOf(
"org.jetbrains.compose.runtime" to vComposeJvmVersion,
"org.jetbrains.compose.ui" to vComposeJvmVersion,
@@ -106,7 +106,7 @@ kotlin {
// invisible to the granular-metadata visibility check. The
// runtime is the official klib everywhere, never substituted
// (jvm configs force the +dev build above).
implementation("org.jetbrains.compose.runtime:runtime:1.11.1")
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-text:$vComposeJvmVersion")
@@ -16,6 +16,8 @@ package org.jetbrains.compose.resources.vector.xmldom
CDATA skipping, and the five predefined entities plus numeric character
references. No DTD expansion, no processing-instruction handling beyond
skipping — malformed input throws MalformedXMLException like upstream. */
// VENDOR-BASE(COMPOSE_REF): components/resources/library/src/nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt @ v1.12.0-beta03+dev4483
// (fresh REIMPL, not a copy-edit — the base ref marks the upstream API it tracks)
internal fun parse(xml: String): Element {
val vParser = XmlDomParser(xml)
@@ -18,6 +18,7 @@
// MANUAL VENDOR of upstream compose/foundation/foundation/src/commonMain/kotlin/
// androidx/compose/foundation/text/selection/SelectionLayout.kt.
// VENDOR-BASE: compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionLayout.kt @ v1.12.0-beta03+dev4483
//
// Change vs upstream: MultiSelectionLayout.crossStatus and slotToIndex clamp their
// infoList index to the valid range. Upstream assumes that if the drag position never
@@ -32,11 +32,13 @@ import com.compose.sdl.window.createPopupHostState
import kotlinx.coroutines.*
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.cinterop.toKString
import kotlinx.cinterop.reinterpret
import sdl3.SDL_Delay
import sdl3.SDL_GetPerformanceCounter
import sdl3.SDL_GetPerformanceFrequency
import sdl3.SDL_GetTicks
import sdl3.SDL_GetTicksNS
import sdl3.SDL_Quit
import sdl3.SDL_SetWindowTitle
import sdl3.SDL_WaitEventTimeout
@@ -143,16 +145,18 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
appComposition.setContent { appScope.content() }
// Compose the initial Window() declarations before entering the loop.
appClock.sendFrame()
appClock.sendFrame(frameClockNanos())
yield()
// ============
// Main loop
var vGcLastTicks = SDL_GetTicks()
var vRenderedSinceGc = false
val vProfiler = if (platform.posix.getenv("CDN_PROFILE") != null) FrameProfiler() else null
while (!runtime.exitRequested) {
vProfiler?.mark()
FrameProfiler.mark()
// One virtual 16.6ms tick per loop iteration (no-op unless useVirtualFrameTime) —
// all windows' clocks share the same timestamp this iteration.
advanceVirtualFrame()
Snapshot.sendApplyNotifications()
// ============
@@ -180,12 +184,12 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
}
mainDispatcher.drainPending()
vProfiler?.phase(0)
FrameProfiler.phase("events")
// ============
// App composition pump — Window()s may appear / disappear here.
Snapshot.sendApplyNotifications()
appClock.sendFrame()
appClock.sendFrame(frameClockNanos())
yield()
runtime.reapDestroyed()
@@ -200,7 +204,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// Exit when the last window is gone (after at least one existed).
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
if (runtime.exitRequested) break
vProfiler?.phase(1)
FrameProfiler.phase("app")
// ============
// Per-window pump + render.
@@ -209,21 +213,21 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
val vAppPending = appRecomposer.hasPendingWork
for (vW in runtime.windows.toList()) {
vW.installGlobals()
vW.host.sendAnimationFrame(SDL_GetTicks().toLong() * 1_000_000L)
vW.host.sendAnimationFrame(animationClockNanos())
mainDispatcher.drainPending()
Snapshot.sendApplyNotifications()
vW.frameClock.sendFrame()
vW.frameClock.sendFrame(frameClockNanos())
yield()
vProfiler?.phase(2)
FrameProfiler.phase("pump")
if (vW.shouldRender()) {
vW.renderFrame()
vAnyRendered = true
if (!vW.backend.vsyncEnabled) vAllVsync = false
}
vProfiler?.phase(3)
FrameProfiler.phase("render")
}
runtime.reapDestroyed()
vProfiler?.frameDone(vAnyRendered)
FrameProfiler.frameDone(vAnyRendered)
// ============
// Drain deferred native-resource disposals on the MAIN thread —
@@ -280,41 +284,68 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
SDL_Quit()
}
/* CDN_PROFILE=1 — per-phase main-loop timings, printed every ~2s of rendered
frames. Phases: events (poll+dispatch), app (app-composition pump), pump
(per-window clocks/dispatch), render (layout+draw+present). Measure first,
optimize second — see ROADMAP.md. */
/* CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
A named-phase SINGLETON so both the main loop (events / app / pump / render)
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
the elapsed since the last mark/phase to that name. Measure first, optimize
second — see ROADMAP.md. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal class FrameProfiler {
internal object FrameProfiler {
// null until first checked; then true/false for the run's lifetime.
private var fEnabled: Boolean? = null
// Output file — resolved once from CDN_PROFILE. Writing to a file (not
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
// the cwd; CDN_PROFILE=<path> → that path.
private var fPath: String = "cdn_profile.log"
val enabled: Boolean
get() = fEnabled ?: run {
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
(vEnv != null).also { fEnabled = it }
}
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
private val fSum = DoubleArray(4)
private val fMax = DoubleArray(4)
// Insertion-ordered so the printed line follows the call order.
private val fSum = LinkedHashMap<String, Double>()
private val fMax = LinkedHashMap<String, Double>()
private var fFrames = 0
private var fLastPrintMs = SDL_GetTicks()
private var fMark = 0uL
fun mark() { fMark = SDL_GetPerformanceCounter() }
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
fun phase(inIndex: Int) {
fun phase(inName: String) {
if (!enabled) return
val vNow = SDL_GetPerformanceCounter()
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
fSum[inIndex] += vMs
if (vMs > fMax[inIndex]) fMax[inIndex] = vMs
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
fMark = vNow
}
fun frameDone(inRendered: Boolean) {
if (!enabled) return
if (inRendered) fFrames++
val vNowMs = SDL_GetTicks()
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
val vNames = listOf("events", "app", "pump", "render")
val vParts = vNames.mapIndexed { vI, vName ->
val vAvg = fSum[vI] / fFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${(fMax[vI] * 100).toInt() / 100.0}ms"
val vParts = fSum.keys.map { vName ->
val vAvg = (fSum[vName] ?: 0.0) / fFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
}
val vTotal = fSum.sum() / fFrames
println("[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " total=${(vTotal * 100).toInt() / 100.0}ms")
for (vI in fSum.indices) { fSum[vI] = 0.0; fMax[vI] = 0.0 }
// Per-frame draw-work averages (see DrawStats): what's inside `draw`.
val vStats = com.compose.sdl.graphics.DrawStats
val vDraw = "geo=${vStats.geometrySubmits / fFrames} verts=${vStats.vertices / fFrames} " +
"masks=${vStats.maskRealizations / fFrames} text=${vStats.textDraws / fFrames} img=${vStats.imageBlits / fFrames}"
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " | per-frame " + vDraw + "\n"
val vFile = platform.posix.fopen(fPath, "a")
if (vFile != null) {
platform.posix.fputs(vLine, vFile)
platform.posix.fclose(vFile)
}
vStats.reset()
fSum.clear(); fMax.clear()
fFrames = 0
fLastPrintMs = vNowMs
}
@@ -323,6 +354,59 @@ internal class FrameProfiler {
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
their native memory (see the main loop's native-memory nudge). */
private val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
// ==================
// MARK: Probe / screenshot support (render-to-quiescence)
// ==================
/* Set BEFORE nativeComposeApp/nativeComposeWindow: every window's composition then runs
under an InfiniteAnimationPolicy that CANCELS infinite animations, so
rememberInfiniteTransition & co. freeze at their initial value — the same mechanism
upstream's test rules use. Screenshot/parity runs enable it so looping screens can
reach quiescence and capture deterministically. */
var disableInfiniteAnimations: Boolean = false
/* The cancelling policy: a coroutine that ends in CancellationException counts as
cancelled (not failed), so only the animation coroutine stops — nothing propagates. */
private object CancelInfiniteAnimationsPolicy : androidx.compose.ui.platform.InfiniteAnimationPolicy {
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
throw CancellationException("infinite animations are disabled (disableInfiniteAnimations)")
}
/* Set BEFORE nativeComposeApp/nativeComposeWindow: the composition + animation frame
clocks advance a VIRTUAL 16.6ms per main-loop iteration instead of reading SDL's
real-time ticks — the native mirror of the JVM parity leg's render(nanos) stepping.
Animations then progress by exact per-frame deltas, so anything time-raced (e.g. a
bring-into-view scroll interrupted mid-flight) resolves identically on every run and
screenshots become deterministic. Input timestamps and FPS stay on real time. */
var useVirtualFrameTime: Boolean = false
private var virtualFrameNanos = 0L
private fun advanceVirtualFrame() {
if (useVirtualFrameTime) virtualFrameNanos += 16_666_667L
}
// Timestamp for the composition frame clocks (recomposer + withFrameNanos animations).
private fun frameClockNanos(): Long =
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicksNS().toLong()
// Timestamp for the owner's node-animation clock — real path keeps the pre-existing
// ms-resolution SDL_GetTicks base so non-screenshot behaviour is bit-for-bit unchanged.
private fun animationClockNanos(): Long =
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicks().toLong() * 1_000_000L
// The window currently inside renderFrame — the loop is single-threaded, so a plain
// var is enough for onFrame probes to address "the window I'm being called for".
private var renderingWindow: WindowInstance? = null
/* From inside an onFrame callback: true while the just-rendered window still has pending
work (recomposition, layout/draw invalidation, or an animation awaiting the next frame).
Screenshot probes capture once this stays false for a few consecutive frames instead of
at a fixed frame count. Outside onFrame it answers false. */
fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: false
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
@@ -461,8 +545,9 @@ internal class WindowInstance(
private var hasMousePos = false
private var frameIndex = 0
private var fpsFrames = 0
private var fpsLastMs = SDL_GetTicks()
private var fpsEma = 0.0
private var fpsLastFrameMs = SDL_GetTicks()
private var fpsLastTitleMs = 0uL
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
// Escape completes a back navigation on THIS window's dispatcher.
@@ -516,6 +601,10 @@ internal class WindowInstance(
host = ComposeRootHost(inDensity = backend.pixelDensity)
host.attach()
// A layer whose content changed (OwnedLayer.invalidate) schedules a frame
// even when nothing else (recompose / relayout) is pending — retained layers
// need this so a draw-only state change still repaints.
host.setInvalidationCallback { needsFrame = true }
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
@@ -524,7 +613,11 @@ internal class WindowInstance(
// setContent below).
installGlobals()
val vRecomposer = Recomposer(inScope.coroutineContext + frameClock)
// Effect context for this window's composition — the screenshot flag injects the
// infinite-animation-cancelling policy (see disableInfiniteAnimations above).
var vEffectContext = inScope.coroutineContext + frameClock
if (disableInfiniteAnimations) vEffectContext += CancelInfiniteAnimationsPolicy
val vRecomposer = Recomposer(vEffectContext)
recomposer = vRecomposer
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
val vComposition = Composition(host.applier, vRecomposer)
@@ -723,7 +816,18 @@ internal class WindowInstance(
// Frame pump
fun shouldRender(): Boolean =
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
/* True while this window still has pending work: a state/layout/draw invalidation
(needsFrame), recomposer work, or a composition/node animation awaiting the next
frame. Sampled by windowHasInvalidations() from onFrame probes — the quiescence
signal for render-to-settle screenshot capture. */
fun hasInvalidations(): Boolean =
needsFrame || recomposer?.hasPendingWork == true ||
frameClock.hasAwaiters || host.hasAnimationAwaiters()
// TEMP measurement: CDN_FORCERENDER=1 forces every frame to render so
// sustained steady-state timings can be measured on otherwise-idle screens.
fun renderFrame() {
val vRender = renderBackend ?: return
@@ -734,6 +838,7 @@ internal class WindowInstance(
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
host.measureAndLayout()
FrameProfiler.phase(" layout")
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
if (hasMousePos) {
@@ -741,30 +846,48 @@ internal class WindowInstance(
}
vRender.beginFrame(1f)
vRender.drawRoot(host)
if (onFrame != null && !onFrame.invoke(vRender, frameIndex)) {
vRender.drawRoot { canvas -> host.drawRoot(canvas) }
if (onFrame != null) {
renderingWindow = this
val vContinue = onFrame.invoke(vRender, frameIndex)
renderingWindow = null
// Probe consumers end the app when their scenario completes.
facade.close()
if (!vContinue) facade.close()
}
FrameProfiler.phase(" draw")
vRender.endFrame()
FrameProfiler.phase(" present")
frameIndex++
// FPS — refreshed ~once a second, per window.
fpsFrames++
// FPS — instantaneous inter-frame rate, EMA-smoothed, title refreshed
// ~4x/sec. This shows within ~2 rendered frames of ANY activity rather
// than waiting for a full second of unbroken rendering to accumulate
// (the old fixed-window counter never got its first update: bursty
// interaction kept idling before 1s elapsed, and the idle-skip reset the
// window each time — so FPS only appeared during a long enough page
// transition). A dt outside 1..100ms is the first frame or a resume from
// idle (the gap isn't a real frame interval), so it's not sampled — the
// title just holds the last active rate while idle.
val vNowMs = SDL_GetTicks()
val vElapsed = (vNowMs - fpsLastMs).toInt()
if (vElapsed >= 1000) {
val vFps = fpsFrames * 1000 / vElapsed
facade.updateFps(vFps)
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
fpsFrames = 0
fpsLastMs = vNowMs
val vDt = (vNowMs - fpsLastFrameMs).toInt()
fpsLastFrameMs = vNowMs
if (vDt in 1..100) {
val vInst = 1000.0 / vDt
fpsEma = if (fpsEma <= 0.0) vInst else fpsEma * 0.9 + vInst * 0.1
if ((vNowMs - fpsLastTitleMs).toInt() >= 250) {
val vFps = (fpsEma + 0.5).toInt()
facade.updateFps(vFps)
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
fpsLastTitleMs = vNowMs
}
}
}
// Idle-skip calls this: drop the frame timer so the resume-from-idle frame's
// dt (the whole idle gap) isn't sampled as a real interval. The EMA is left
// intact so the title keeps showing the last active rate while idle.
fun resetFpsWindow() {
fpsFrames = 0
fpsLastMs = SDL_GetTicks()
fpsLastFrameMs = SDL_GetTicks()
}
fun destroy() {
@@ -26,7 +26,12 @@ internal class SDL3FrameClock : MonotonicFrameClock {
override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R =
broadcast.withFrameNanos(onFrame)
fun sendFrame() {
broadcast.sendFrame(SDL_GetTicksNS().toLong())
fun sendFrame(inNanos: Long = SDL_GetTicksNS().toLong()) {
broadcast.sendFrame(inNanos)
}
// Coroutines (recomposer / composition animations) currently suspended in withFrameNanos
// awaiting the next sendFrame — the "an animation is still running" half of the window's
// quiescence signal (see WindowInstance.hasInvalidations).
val hasAwaiters: Boolean get() = broadcast.hasAwaiters
}
+1 -1
View File
@@ -13,7 +13,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
api("org.jetbrains.compose.runtime:runtime:1.11.1")
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
api("androidx.navigationevent:navigationevent-compose:1.1.2")
api(project(":ui-util"))
implementation(libs.kotlinx.coroutines.core)
+1 -1
View File
@@ -13,7 +13,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
api("org.jetbrains.compose.runtime:runtime:1.11.1")
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
api(project(":ui-util"))
}
kotlin.srcDir("src/vendor/common/kotlin")
@@ -20,7 +20,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
api("org.jetbrains.compose.runtime:runtime:1.11.1")
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
}
kotlin.srcDir("src/vendor/common/kotlin")
}
+1 -1
View File
@@ -13,7 +13,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
api("org.jetbrains.compose.runtime:runtime:1.11.1")
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
api(project(":ui-geometry"))
api(project(":ui-util"))
}
+1 -1
View File
@@ -15,7 +15,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
api("org.jetbrains.compose.runtime:runtime:1.11.1")
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
api("androidx.collection:collection:1.5.0")
}
kotlin.srcDir("src/vendor/common/kotlin")
+3 -3
View File
@@ -156,9 +156,9 @@ kotlin {
api(project(":ui-geometry"))
api(project(":ui-unit"))
api(project(":ui-backhandler"))
api("org.jetbrains.compose.runtime:runtime:1.11.1")
api("org.jetbrains.compose.runtime:runtime-saveable:1.11.1")
api("androidx.compose.runtime:runtime-retain:1.11.1")
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
api("org.jetbrains.compose.runtime:runtime-saveable:${libs.versions.composeRuntime.get()}")
api("androidx.compose.runtime:runtime-retain:${libs.versions.composeRuntime.get()}")
api("androidx.navigationevent:navigationevent-compose:1.1.2")
api("androidx.savedstate:savedstate:1.5.0")
api("androidx.savedstate:savedstate-compose:1.5.0")
+11 -8
View File
@@ -514,14 +514,14 @@ skikoMain/kotlin/ -> src/vendor/skikoRenderer/kotlin/
# | skikoMain/kotlin/androidx/compose/ui/graphics/SkiaVertexMode.skiko.kt -> src/vendor/skikoRenderer/kotlin/androidx/compose/ui/graphics/SkiaVertexMode.skiko.kt
# | skikoMain/kotlin/androidx/compose/ui/graphics/layer/SkiaGraphicsLayer.skiko.kt -> src/vendor/skikoRenderer/kotlin/androidx/compose/ui/graphics/layer/SkiaGraphicsLayer.skiko.kt
# | skikoMain/kotlin/androidx/compose/ui/graphics/shadow/Blur.skiko.kt -> src/vendor/skikoRenderer/kotlin/androidx/compose/ui/graphics/shadow/Blur.skiko.kt
!skikoMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.skiko.kt
# B6.1 (2026-07-16): un-refused — skiko leg adopts upstream graphics actuals.
# SkiaBackedCanvas is then MANUAL-VENDORED (moved to src/skikoRendererMain, re-refused
# below) to also implement the port Native* draw contracts. See RENDERER_TASKS.md B6.1.
# B6.2: un-refused — skiko leg adopts upstream GraphicsLayer/GraphicsContext (real
# skiko RenderNode) now the canvas is SkiaBackedCanvas. See RENDERER_TASKS.md B6.2.
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaBackedCanvas.skiko.kt
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaBackedPaint.skiko.kt
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaGraphicsContext.skiko.kt
# B6.1: MANUAL-VENDORED (flattened toBitmap/putBytesInto expect-actuals — see the file).
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaImageAsset.skiko.kt
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaShader.skiko.kt
!skikoMain/kotlin/androidx/compose/ui/graphics/layer/SkiaGraphicsLayer.skiko.kt
!skikoMain/kotlin/androidx/compose/ui/graphics/shadow/Blur.skiko.kt
SET_FOLDER=compose/ui/ui-text/src
@@ -757,11 +757,13 @@ skikoMain/kotlin/androidx/compose/ui/text/style/TextMotion.skiko.kt -> src/vendo
# | androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt -> src/vendor/android/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt
# | androidMain/kotlin/androidx/compose/ui/window/AndroidPopup.android.kt -> src/vendor/android/kotlin/androidx/compose/ui/window/AndroidPopup.android.kt
# | androidMain/kotlin/androidx/compose/ui/window/SecureFlagPolicy.android.kt -> src/vendor/android/kotlin/androidx/compose/ui/window/SecureFlagPolicy.android.kt
# desktopMain (173)
# desktopMain (175)
# | desktopMain/kotlin/androidx/compose/ui/Actuals.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/Actuals.desktop.kt
# | desktopMain/kotlin/androidx/compose/ui/ComposeDesktopEntryPoint.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/ComposeDesktopEntryPoint.kt
# | desktopMain/kotlin/androidx/compose/ui/ComposeFeatureFlags.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/ComposeFeatureFlags.desktop.kt
# | desktopMain/kotlin/androidx/compose/ui/ConfigureSwingGlobalsForCompose.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/ConfigureSwingGlobalsForCompose.desktop.kt
# | desktopMain/kotlin/androidx/compose/ui/DesktopComposeUiFlags.skiko.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/DesktopComposeUiFlags.skiko.kt
# | desktopMain/kotlin/androidx/compose/ui/ProvideSystemTheme.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/ProvideSystemTheme.desktop.kt
# | desktopMain/kotlin/androidx/compose/ui/awt/AwtDimension.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/awt/AwtDimension.desktop.kt
# | desktopMain/kotlin/androidx/compose/ui/awt/AwtEventFilter.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/awt/AwtEventFilter.desktop.kt
# | desktopMain/kotlin/androidx/compose/ui/awt/AwtEvents.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/awt/AwtEvents.desktop.kt
@@ -1092,7 +1094,7 @@ skikoMain/kotlin/androidx/compose/ui/text/style/TextMotion.skiko.kt -> src/vendo
# | wasmJsMain/kotlin/androidx/compose/ui/Actuals.wasm.kt -> src/vendor/wasmJs/kotlin/androidx/compose/ui/Actuals.wasm.kt
# | wasmJsMain/kotlin/androidx/compose/ui/internal/System.wasm.kt -> src/vendor/wasmJs/kotlin/androidx/compose/ui/internal/System.wasm.kt
# | wasmJsMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.wasm.kt -> src/vendor/wasmJs/kotlin/androidx/compose/ui/platform/PlatformClipboard.wasm.kt
# webMain (39)
# webMain (40)
# | webMain/kotlin/androidx/compose/ui/dom/Events.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/dom/Events.web.kt
# | webMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.web.kt
# | webMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropTransferData.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/draganddrop/DragAndDropTransferData.web.kt
@@ -1129,6 +1131,7 @@ skikoMain/kotlin/androidx/compose/ui/text/style/TextMotion.skiko.kt -> src/vendo
# | webMain/kotlin/androidx/compose/ui/window/ComposeViewportConfiguration.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/ComposeViewportConfiguration.web.kt
# | webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt
# | webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt
# | webMain/kotlin/androidx/compose/ui/window/RootScrollObserver.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/RootScrollObserver.kt
# | webMain/kotlin/androidx/compose/ui/window/SystemThemeObserver.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/SystemThemeObserver.web.kt
# | webMain/kotlin/androidx/compose/ui/window/WebCompositionLocals.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/WebCompositionLocals.kt
# | webMain/kotlin/androidx/compose/ui/window/WebTextInputSession.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/WebTextInputSession.kt
@@ -10,6 +10,7 @@
* Line-for-line kept in sync; the entry in compose/ui/compose-fork.txt is
* commented out so this file survives `scripts/compose-fork/sync.sh`.
*/
// VENDOR-BASE: compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Synchronization.kt @ v1.12.0-beta03+dev4483
package androidx.compose.ui.platform
@@ -6,6 +6,7 @@
* compose/ui/compose-fork.txt is commented out so this file survives
* `scripts/compose-fork/sync.sh`.
*/
// VENDOR-BASE: compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/Synchronization.kt @ v1.12.0-beta03+dev4483
package androidx.compose.ui.text.platform
@@ -1,97 +0,0 @@
package com.compose.sdl.element
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.node.ModifierNodeElement
// ==================
// MARK: Project modifier elements
// ==================
// Small set of project-only modifier elements — each pairs a
// `ModifierNodeElement<XxxNode>` with a `XxxNode : Modifier.Node`. Upstream
// LayoutNode reads them through the chain (ModifierNodeElement IS-A
// Modifier.Element). Everything gesture-related has been removed — pointer
// input, text input, right/middle click, drag: all go through the standard
// `Modifier.pointerInput` + `detectTapGestures` / `detectDragGestures` /
// `awaitPointerEventScope` APIs upstream now supplies. What's left is the
// two elements the renderer still owns directly:
// * ClipModifier — GraphicsLayer.kt lowers `clip = true` to it.
// * GraphicsLayerModifier — the transform / alpha / cache pipeline.
class ClipModifier(val shape: Shape) : ModifierNodeElement<ClipNode>() {
override fun create() = ClipNode(shape)
override fun update(node: ClipNode) { node.shape = shape }
override fun hashCode(): Int = shape.hashCode()
override fun equals(other: Any?): Boolean = other is ClipModifier && other.shape == shape
}
class ClipNode(var shape: Shape) : Modifier.Node()
// ==================
// MARK: GraphicsLayerModifier
// ==================
/**
* A "graphics layer" element: alpha + 2D transform (scale / rotation /
* translation), with an optional cacheKey that opts the subtree into
* render-to-texture caching across frames. See `Modifier.graphicsLayer`
* (in `androidx.compose.ui.graphics`) for the caching semantics.
*
* The renderer reads this element directly via the `LayoutNode.graphicsLayer`
* `foldIn` over the chain; the paired [GraphicsLayerNode] lifecycle stays
* dormant until the renderer rewrite drives it.
*/
class GraphicsLayerModifier(
val alpha: Float = 1f,
val scaleX: Float = 1f,
val scaleY: Float = 1f,
val rotationZ: Float = 0f,
val translationX: Float = 0f,
val translationY: Float = 0f,
val transformOrigin: TransformOrigin = TransformOrigin.Center,
val cacheKey: Any? = null,
) : ModifierNodeElement<GraphicsLayerNode>() {
val needsLayer: Boolean
get() = alpha < 1f || cacheKey != null
val needsTransform: Boolean
get() = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
translationX != 0f || translationY != 0f
val isIdentity: Boolean
get() = !needsLayer && !needsTransform
override fun create() =
GraphicsLayerNode(alpha, scaleX, scaleY, rotationZ, translationX, translationY, transformOrigin, cacheKey)
override fun update(node: GraphicsLayerNode) {
node.alpha = alpha; node.scaleX = scaleX; node.scaleY = scaleY
node.rotationZ = rotationZ; node.translationX = translationX; node.translationY = translationY
node.transformOrigin = transformOrigin; node.cacheKey = cacheKey
}
override fun hashCode(): Int {
var v = alpha.hashCode()
v = 31 * v + scaleX.hashCode(); v = 31 * v + scaleY.hashCode()
v = 31 * v + rotationZ.hashCode()
v = 31 * v + translationX.hashCode(); v = 31 * v + translationY.hashCode()
v = 31 * v + transformOrigin.hashCode(); v = 31 * v + (cacheKey?.hashCode() ?: 0)
return v
}
override fun equals(other: Any?): Boolean =
other is GraphicsLayerModifier &&
other.alpha == alpha && other.scaleX == scaleX && other.scaleY == scaleY &&
other.rotationZ == rotationZ &&
other.translationX == translationX && other.translationY == translationY &&
other.transformOrigin == transformOrigin && other.cacheKey == cacheKey
}
class GraphicsLayerNode(
var alpha: Float,
var scaleX: Float,
var scaleY: Float,
var rotationZ: Float,
var translationX: Float,
var translationY: Float,
var transformOrigin: TransformOrigin,
var cacheKey: Any?,
) : Modifier.Node()
@@ -1,17 +0,0 @@
package com.compose.sdl.graphics
import androidx.compose.ui.graphics.layer.GraphicsLayer
// ==================
// MARK: GraphicsLayer factory (project bridge)
// ==================
/* The vendored `expect class GraphicsLayer` exposes neither a constructor nor
the isReleased setter to common code — upstream owners create layers from
platform source sets. ComposeOwner lives in commonMain, so these two hops
let its GraphicsContext create and release the project record/replay
actual (GraphicsLayer.native.kt). */
internal expect fun createProjectGraphicsLayer(): GraphicsLayer
internal expect fun releaseProjectGraphicsLayer(inLayer: GraphicsLayer)
@@ -0,0 +1,17 @@
package com.compose.sdl.graphics
// ==================
// MARK: NativeFinishableCanvas
// ==================
/*
A renderer canvas that must be flushed once drawing into it is done — SDL batches
geometry and only submits on flush; an offscreen SDL canvas must flush before its
backing texture is read. Implemented by Sdl3Canvas (real flush) and SkiaCanvas
(no-op — Skia save/restore is balanced per call). Lets renderer-agnostic code
(e.g. GraphicsLayer.toImageBitmap) commit an offscreen render without knowing the
concrete canvas type.
*/
interface NativeFinishableCanvas {
fun finish()
}
@@ -44,6 +44,14 @@ interface NativeTextCanvas {
// (underline / line-through). Per-run overrides ride in via inSpans.
inBaseItalic: Boolean = false,
inTextDecoration: TextDecoration? = null,
// The paragraph's per-line band height in px (TextStyle.lineHeight when the
// style specifies one — SdlParagraph passes it so glyph rows stack exactly
// where layout put the line boxes). <= 0 → derive from font metrics (legacy
// behaviour; icon callers and TextDrawNode don't pass it).
inLineHeightPx: Float = 0f,
// Compat-trim (raw styles): the FIRST line keeps the tight font cell. False
// for M3's LineHeightStyle(Trim.None) — every line is the full band.
inTrimFirstLine: Boolean = true,
)
}
@@ -1,161 +0,0 @@
package androidx.compose.ui.graphics.layer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
// ==================
// MARK: GraphicsLayer — native no-op actual
// ==================
//
// Upstream's skiko actual (SkiaGraphicsLayer.skiko.kt, 513L) uses Skia's
// Picture/Canvas API for record + replay. We don't have skiko on the
// SDL3 path and the Skia path doesn't route through GraphicsLayer yet
// — the renderer's per-node draw does its own composition. So this
// actual is a plain field bag with no rendering behavior: setters store
// their values, getters return them, record/draw/toImageBitmap are
// no-ops. Nothing in our pipeline constructs a GraphicsLayer yet.
@Suppress("PropertyName", "unused")
actual class GraphicsLayer {
actual var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
actual var topLeft: IntOffset = IntOffset.Zero
actual var size: IntSize = IntSize.Zero
actual var pivotOffset: Offset = Offset.Unspecified
actual var alpha: Float = 1f
actual var scaleX: Float = 1f
actual var scaleY: Float = 1f
actual var translationX: Float = 0f
actual var translationY: Float = 0f
actual var shadowElevation: Float = 0f
actual var ambientShadowColor: Color = Color.Black
actual var spotShadowColor: Color = Color.Black
actual var blendMode: BlendMode = BlendMode.SrcOver
actual var colorFilter: ColorFilter? = null
private var outlineField: Outline = Outline.Rectangle(androidx.compose.ui.geometry.Rect.Zero)
actual val outline: Outline get() = outlineField
actual fun setOutsets(left: Int, top: Int, right: Int, bottom: Int) { /* no-op */ }
actual fun setPathOutline(path: Path) { outlineField = Outline.Generic(path) }
actual fun setRoundRectOutline(topLeft: Offset, size: Size, cornerRadius: Float) {
outlineField = Outline.Rounded(
androidx.compose.ui.geometry.RoundRect(
left = topLeft.x, top = topLeft.y,
right = topLeft.x + size.width, bottom = topLeft.y + size.height,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius),
)
)
}
actual fun setRectOutline(topLeft: Offset, size: Size) {
val effTopLeft = if (topLeft == Offset.Unspecified) Offset.Zero else topLeft
val effSize = if (size == Size.Unspecified) this.size.let { Size(it.width.toFloat(), it.height.toFloat()) } else size
outlineField = Outline.Rectangle(
androidx.compose.ui.geometry.Rect(
effTopLeft.x, effTopLeft.y,
effTopLeft.x + effSize.width, effTopLeft.y + effSize.height,
)
)
}
actual var rotationX: Float = 0f
actual var rotationY: Float = 0f
actual var rotationZ: Float = 0f
actual var cameraDistance: Float = DefaultCameraDistance
actual var clip: Boolean = false
actual var renderEffect: RenderEffect? = null
actual var isReleased: Boolean = false
internal set
// ============
// Record / draw — lambda replay
//
// Upstream's skiko actual records into a Skia Picture. This renderer is
// immediate-mode, so record() stores the draw lambda and draw() REPLAYS it
// with the layer's transform / alpha / clip applied. Same visual result as
// a display list as long as callers re-record on invalidation (they do —
// that's the GraphicsLayer contract). This is what makes GraphicsContext
// consumers render: SharedTransitionLayout's overlay, rememberGraphicsLayer.
private var recordedBlock: (DrawScope.() -> Unit)? = null
private var recordedDensity: Density = Density(1f)
private var recordedLayoutDirection: LayoutDirection = LayoutDirection.Ltr
private val drawScope = androidx.compose.ui.graphics.drawscope.CanvasDrawScope()
actual fun record(
density: Density,
layoutDirection: LayoutDirection,
size: IntSize,
block: DrawScope.() -> Unit,
) {
recordedDensity = density
recordedLayoutDirection = layoutDirection
this.size = size
recordedBlock = block
}
actual suspend fun toImageBitmap(): ImageBitmap =
throw NotImplementedError("GraphicsLayer.toImageBitmap not implemented on desktop")
internal actual fun draw(canvas: Canvas, parentLayer: GraphicsLayer?) {
if (isReleased) return
val vBlock = recordedBlock ?: return
canvas.save()
canvas.translate(topLeft.x.toFloat(), topLeft.y.toFloat())
// Transform about the pivot (defaults to the layer centre).
if (scaleX != 1f || scaleY != 1f || rotationZ != 0f || translationX != 0f || translationY != 0f) {
val vPivot =
if (pivotOffset == Offset.Unspecified) Offset(size.width / 2f, size.height / 2f)
else pivotOffset
canvas.translate(translationX + vPivot.x, translationY + vPivot.y)
if (scaleX != 1f || scaleY != 1f) canvas.scale(scaleX, scaleY)
if (rotationZ != 0f) canvas.rotate(rotationZ)
canvas.translate(-vPivot.x, -vPivot.y)
}
if (clip) {
// Rect/rounded outlines clip to their bounds (SDL clips are rects;
// rounded-corner exactness would need the offscreen mask path);
// generic outlines go through clipPath (bbox fallback on SDL).
when (val vOutline = outline) {
is Outline.Rectangle -> canvas.clipRect(vOutline.rect)
is Outline.Rounded -> canvas.clipRect(
androidx.compose.ui.geometry.Rect(
vOutline.roundRect.left, vOutline.roundRect.top,
vOutline.roundRect.right, vOutline.roundRect.bottom,
),
)
is Outline.Generic -> canvas.clipPath(vOutline.path)
}
}
val vNeedsAlphaLayer = alpha < 1f
if (vNeedsAlphaLayer) {
canvas.saveLayer(
androidx.compose.ui.geometry.Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()),
androidx.compose.ui.graphics.Paint().apply { this.alpha = this@GraphicsLayer.alpha },
)
}
drawScope.draw(
recordedDensity,
recordedLayoutDirection,
canvas,
Size(size.width.toFloat(), size.height.toFloat()),
vBlock,
)
if (vNeedsAlphaLayer) canvas.restore()
canvas.restore()
}
}
@@ -0,0 +1,437 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// MANUALLY VENDORED (NON-IDEMPOTENT) from compose-multiplatform-core:
// compose/ui/ui/src/skikoMain/.../node/GraphicsLayerOwnerLayer.skiko.kt
// VENDOR-BASE: compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/GraphicsLayerOwnerLayer.skiko.kt @ v1.12.0-beta03+dev4483
// Change vs upstream: dropped the trailing `SkiaGraphicsContext.setLightingInfo`
// extension + LIGHT_* constants (skiko-only shadow lighting; our shadows go via
// NativeShadowCanvas). The class body is unchanged — it is the real OwnedLayer
// (dirty-gated record/replay over a per-renderer GraphicsLayer), replacing the
// immediate-mode ProjectOwnedLayer. `!`-refused in compose-fork.txt; reconcile
// upstream changes by hand (run scripts/compose-fork/check-vendor-drift.py at each
// COMPOSE_CORE_REF bump). See RENDERER.md Appendix A.
package androidx.compose.ui.node
import androidx.compose.ui.FrameRateCategory
import androidx.compose.ui.geometry.MutableRect
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.Fields
import androidx.compose.ui.graphics.GraphicsContext
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.ReusableGraphicsLayerScope
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.isIdentity
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.layer.setOutline
import com.compose.sdl.graphics.prepareLayerTransformationMatrix
import androidx.compose.ui.internal.checkPreconditionNotNull
import androidx.compose.ui.internal.requirePrecondition
import androidx.compose.ui.platform.invertTo
import androidx.compose.ui.platform.isInOutline
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
/**
* A class representing a layer that owns and manages a `GraphicsLayer` for composable rendering.
*
* This layer is responsible for managing graphical properties, transformations, and rendering
* tasks associated with a `GraphicsLayer`. It provides mechanisms for updating layer properties,
* triggering invalidations, resizing, and mapping offsets or bounds using transformation matrices.
*
* @constructor
* @param graphicsLayer The initial `GraphicsLayer` for this layer.
* @param context The graphics context responsible for managing this layer, or `null` if externally
* managed. When context is not null, it means the object is created internally, and we need to
* release it during disposal.
* @param layerManager The manager responsible for handling the ownership and lifecycle of this layer.
* @param drawBlock The lambda function invoked to perform custom drawing on the canvas.
* @param invalidateParentLayer Callback to invalidate the parent layer when necessary.
*/
internal class GraphicsLayerOwnerLayer(
graphicsLayer: GraphicsLayer,
private val context: GraphicsContext?,
private val layerManager: OwnedLayerManager,
drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
invalidateParentLayer: () -> Unit,
) : OwnedLayer {
internal var graphicsLayer: GraphicsLayer = graphicsLayer
private set
private var drawBlock: ((canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit)? = drawBlock
private var invalidateParentLayer: (() -> Unit)? = invalidateParentLayer
private var size: IntSize = IntSize(Int.MAX_VALUE, Int.MAX_VALUE)
private var density = Density(1f)
private var layoutDirection = LayoutDirection.Ltr
private val scope = CanvasDrawScope()
private var mutatedFields: Int = 0
private var transformOrigin: TransformOrigin = TransformOrigin.Center
private var outline: Outline? = null
private val matrixCache = Matrix()
private var inverseMatrixCache: Matrix? = null
private var isDestroyed = false
private var isDirty = true
set(value) {
if (value != field) {
field = value
layerManager.notifyLayerIsDirty(this, value)
}
}
private var isMatrixDirty = false
private var isInverseMatrixDirty = false
private var isIdentity = true
override var frameRate: Float = 0f
override var isFrameRateFromParent = false
override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
val maybeChangedFields = scope.mutatedFields or mutatedFields
this.layoutDirection = scope.layoutDirection
this.density = scope.graphicsDensity
if (maybeChangedFields and Fields.Outsets != 0) {
with(density) {
graphicsLayer.setOutsets(
left = scope.outsets.left.roundToPx(),
top = scope.outsets.top.roundToPx(),
right = scope.outsets.right.roundToPx(),
bottom = scope.outsets.bottom.roundToPx(),
)
invalidate()
}
}
if (maybeChangedFields and Fields.TransformOrigin != 0) {
this.transformOrigin = scope.transformOrigin
}
if (maybeChangedFields and Fields.ScaleX != 0) {
graphicsLayer.scaleX = scope.scaleX
}
if (maybeChangedFields and Fields.ScaleY != 0) {
graphicsLayer.scaleY = scope.scaleY
}
if (maybeChangedFields and Fields.Alpha != 0) {
graphicsLayer.alpha = scope.alpha
}
if (maybeChangedFields and Fields.TranslationX != 0) {
graphicsLayer.translationX = scope.translationX
}
if (maybeChangedFields and Fields.TranslationY != 0) {
graphicsLayer.translationY = scope.translationY
}
if (maybeChangedFields and Fields.ShadowElevation != 0) {
graphicsLayer.shadowElevation = scope.shadowElevation
}
if (maybeChangedFields and Fields.AmbientShadowColor != 0) {
graphicsLayer.ambientShadowColor = scope.ambientShadowColor
}
if (maybeChangedFields and Fields.SpotShadowColor != 0) {
graphicsLayer.spotShadowColor = scope.spotShadowColor
}
if (maybeChangedFields and Fields.RotationZ != 0) {
graphicsLayer.rotationZ = scope.rotationZ
}
if (maybeChangedFields and Fields.RotationX != 0) {
graphicsLayer.rotationX = scope.rotationX
}
if (maybeChangedFields and Fields.RotationY != 0) {
graphicsLayer.rotationY = scope.rotationY
}
if (maybeChangedFields and Fields.CameraDistance != 0) {
graphicsLayer.cameraDistance = scope.cameraDistance
}
if (maybeChangedFields and Fields.TransformOrigin != 0) {
if (transformOrigin == TransformOrigin.Center) {
graphicsLayer.pivotOffset = Offset.Unspecified
} else {
graphicsLayer.pivotOffset =
Offset(
transformOrigin.pivotFractionX * size.width,
transformOrigin.pivotFractionY * size.height
)
}
}
if (maybeChangedFields and Fields.Clip != 0) {
graphicsLayer.clip = scope.clip
}
if (maybeChangedFields and Fields.RenderEffect != 0) {
graphicsLayer.renderEffect = scope.renderEffect
}
if (maybeChangedFields and Fields.ColorFilter != 0) {
graphicsLayer.colorFilter = scope.colorFilter
}
if (maybeChangedFields and Fields.BlendMode != 0) {
graphicsLayer.blendMode = scope.blendMode
}
if (maybeChangedFields and Fields.CompositingStrategy != 0) {
graphicsLayer.compositingStrategy =
when (scope.compositingStrategy) {
CompositingStrategy.Auto -> androidx.compose.ui.graphics.layer.CompositingStrategy.Auto
CompositingStrategy.Offscreen -> androidx.compose.ui.graphics.layer.CompositingStrategy.Offscreen
CompositingStrategy.ModulateAlpha -> androidx.compose.ui.graphics.layer.CompositingStrategy.ModulateAlpha
else -> throw IllegalStateException("Not supported composition strategy")
}
}
if (maybeChangedFields and Fields.MatrixAffectingFields != 0) {
isMatrixDirty = true
isInverseMatrixDirty = true
}
var outlineChanged = false
if (outline != scope.outline) {
outlineChanged = true
outline = scope.outline
updateOutline()
}
mutatedFields = scope.mutatedFields
if (maybeChangedFields != 0 || outlineChanged) {
triggerRepaint()
layerManager.voteFrameRate(frameRate)
}
}
/**
* Triggers redrawing of Compose content during the next frame.
*/
private fun triggerRepaint() {
layerManager.invalidate()
}
private fun updateOutline() {
val outline = outline ?: return
graphicsLayer.setOutline(outline)
}
override fun isInLayer(position: Offset): Boolean {
val x = position.x
val y = position.y
if (graphicsLayer.clip) {
return isInOutline(graphicsLayer.outline, x, y)
}
return true
}
override fun move(position: IntOffset) {
layerManager.voteFrameRate(FrameRateCategory.High.value)
graphicsLayer.topLeft = position
triggerRepaint()
}
override fun resize(size: IntSize) {
if (size != this.size) {
layerManager.voteFrameRate(FrameRateCategory.High.value)
this.size = size
invalidate()
}
}
override fun drawLayer(canvas: Canvas, parentLayer: GraphicsLayer?) {
updateDisplayList()
scope.drawContext.also {
it.canvas = canvas
it.graphicsLayer = parentLayer
}
scope.drawLayer(graphicsLayer)
}
override fun updateDisplayList() {
layerManager.voteFrameRate(frameRate)
if (isDirty) {
if (transformOrigin != TransformOrigin.Center && graphicsLayer.size != size) {
graphicsLayer.pivotOffset =
Offset(
transformOrigin.pivotFractionX * size.width,
transformOrigin.pivotFractionY * size.height
)
}
graphicsLayer.record(density, layoutDirection, size, recordLambda)
isDirty = false
}
}
private val recordLambda: DrawScope.() -> Unit = {
drawIntoCanvas { canvas ->
this@GraphicsLayerOwnerLayer.drawBlock?.let { it(canvas, drawContext.graphicsLayer) }
}
}
/**
* Marks content as dirty and triggers redrawing.
*/
override fun invalidate() {
if (isDestroyed) return
isDirty = true
layerManager.invalidate()
}
override fun destroy() {
frameRate = 0f
isFrameRateFromParent = false
drawBlock = null
invalidateParentLayer = null
isDestroyed = true
isDirty = false
if (context != null) {
context.releaseGraphicsLayer(graphicsLayer)
// Recycle only in case of non-null context (meaning only for not external layers).
layerManager.recycle(this)
}
}
override fun mapOffset(point: Offset, inverse: Boolean): Offset {
val matrix =
if (inverse) {
getInverseMatrix() ?: return Offset.Infinite
} else {
getMatrix()
}
return if (isIdentity) {
point
} else {
matrix.map(point)
}
}
override fun mapBounds(rect: MutableRect, inverse: Boolean) {
val matrix = if (inverse) getInverseMatrix() else getMatrix()
if (!isIdentity) {
if (matrix == null) {
rect.set(0f, 0f, 0f, 0f)
} else {
matrix.map(rect)
}
}
}
override fun reuseLayer(
drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
invalidateParentLayer: () -> Unit
) {
val context =
checkPreconditionNotNull(context) {
"currently reuse is only supported when we manage the layer lifecycle"
}
requirePrecondition(graphicsLayer.isReleased) {
"layer should have been released before reuse"
}
// recreate a layer
graphicsLayer = context.createGraphicsLayer()
isDestroyed = false
// apply new params
this.drawBlock = drawBlock
this.invalidateParentLayer = invalidateParentLayer
// reset mutable variables to their initial values
isMatrixDirty = false
isInverseMatrixDirty = false
isIdentity = true
matrixCache.reset()
inverseMatrixCache?.reset()
transformOrigin = TransformOrigin.Center
size = IntSize(Int.MAX_VALUE, Int.MAX_VALUE)
outline = null
mutatedFields = 0
}
override fun transform(matrix: Matrix) {
matrix.timesAssign(getMatrix())
}
override val underlyingMatrix: Matrix
get() = getMatrix()
override fun inverseTransform(matrix: Matrix) {
val inverse = getInverseMatrix()
if (inverse != null) {
matrix.timesAssign(inverse)
}
}
private fun getMatrix(): Matrix {
updateMatrix()
return matrixCache
}
private fun getInverseMatrix(): Matrix? {
val matrix = getMatrix()
if (isIdentity) {
return matrix
}
val inverseMatrix = inverseMatrixCache ?: Matrix().also { inverseMatrixCache = it }
if (!isInverseMatrixDirty) {
return if (inverseMatrix[0, 0].isNaN()) {
null
} else {
inverseMatrix
}
}
isInverseMatrixDirty = false
return if (matrix.invertTo(inverseMatrix)) {
inverseMatrix
} else {
inverseMatrix[0, 0] = Float.NaN
null
}
}
private fun updateMatrix() {
if (!isMatrixDirty) return
with(graphicsLayer) {
val pivotX: Float
val pivotY: Float
if (pivotOffset.isUnspecified) {
pivotX = this@GraphicsLayerOwnerLayer.size.width / 2f
pivotY = this@GraphicsLayerOwnerLayer.size.height / 2f
} else {
pivotX = pivotOffset.x
pivotY = pivotOffset.y
}
prepareLayerTransformationMatrix(
matrix = matrixCache,
pivotX = pivotX,
pivotY = pivotY,
translationX = translationX,
translationY = translationY,
rotationX = rotationX,
rotationY = rotationY,
rotationZ = rotationZ,
scaleX = scaleX,
scaleY = scaleY,
cameraDistance = cameraDistance
)
}
isMatrixDirty = false
isIdentity = matrixCache.isIdentity()
}
}
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.isUnspecified
import com.compose.sdl.text.NativeTextCanvas
import com.compose.sdl.text.currentTextMeasurer
@@ -73,7 +74,32 @@ internal class SdlParagraph(
private val wrapped = currentTextMeasurer.wrap(text, fontPx, maxWidthPx, family, variations)
private val allLines: List<String> = wrapped.lines
private val lineStarts: IntArray = wrapped.lineStarts
private val lh: Float = currentTextMeasurer.lineHeight(fontPx, family, variations).coerceAtLeast(1f)
// TextStyle.lineHeight in px — upstream Skia paragraphs honour it (every M3
// Typography style sets one, e.g. bodyMedium 14sp/20sp), so ignoring it made every
// text block's height differ from upstream by ~1px per WRAPPED line, an error that
// ACCUMULATED down the page (the dominant term in every parity diff). sp scales by
// density like fontPx; em multiplies the font size.
private val styleLineHeightPx: Float? = when {
style.lineHeight.isUnspecified -> null
style.lineHeight.type == TextUnitType.Sp -> style.lineHeight.value * density
style.lineHeight.type == TextUnitType.Em -> style.lineHeight.value * fontPx
else -> null
// Upstream ignores a lineHeight NOT STRICTLY GREATER than the font size (skia
// only applies height multipliers > 1em) — probe: 48sp/24lh -> the 65px cell,
// and 24sp/24lh (h = 1.0 exactly) -> the 33px cell, while 24sp/25lh -> 25.
}?.takeIf { it > fontPx }
// How the band applies depends on TextStyle.lineHeightStyle (probe-verified vs JVM):
// - unspecified (raw BasicText): COMPAT TRIM — the first line keeps the tight font
// cell, lineHeight is the advance between baselines. n lines = cell + (n-1)*lh.
// - Trim.None (every M3 Typography style: Center/None/Fixed): UNIFORM bands — every
// line is exactly lineHeight, single-line included (even below the cell).
private val uniformBands: Boolean =
style.lineHeightStyle?.trim == androidx.compose.ui.text.style.LineHeightStyle.Trim.None
private val fontCellH: Float =
currentTextMeasurer.lineHeight(fontPx, family, variations).coerceAtLeast(1f)
private val lh: Float = styleLineHeightPx ?: fontCellH
private val firstLineH: Float = if (uniformBands && styleLineHeightPx != null) lh else fontCellH
// True when a span changes glyph METRICS (fontSize / fontWeight) — gates
// the styled measurement paths so plain/colour-only text keeps the cheap
@@ -103,10 +129,14 @@ internal class SdlParagraph(
// Per-line box heights: base line height unless a size span makes a line's
// tallest run cell bigger. lineTops[i] = cumulative top; last entry = height.
private val lineHeights: FloatArray by lazy {
if (!metricSpans) FloatArray(lineCount) { lh }
if (!metricSpans) FloatArray(lineCount) { if (it == 0) firstLineH else lh }
else FloatArray(lineCount) {
styledLineCellHeight(allLines[it], lineStarts[it], spanStyles, fontPx, density, currentTextMeasurer, family, variations)
.coerceAtLeast(1f)
// A size span can push a line's cell above the style's line height, but
// never below it (first line keeps its tight cell under compat trim).
max(
styledLineCellHeight(allLines[it], lineStarts[it], spanStyles, fontPx, density, currentTextMeasurer, family, variations),
if (it == 0 && !uniformBands) 0f else (styleLineHeightPx ?: 0f),
).coerceAtLeast(1f)
}
}
private val lineTops: FloatArray by lazy {
@@ -118,7 +148,9 @@ internal class SdlParagraph(
override val width: Float by lazy {
if (maxWidthPx == Int.MAX_VALUE) (lineWidths.maxOrNull() ?: 0f) else widthConstraint
}
override val height: Float by lazy { if (!metricSpans) lineCount * lh else lineTops[lineCount] }
override val height: Float by lazy {
if (!metricSpans) firstLineH + (lineCount - 1) * lh else lineTops[lineCount]
}
// Widest single HARD-BREAK line — NOT the concatenated all-on-one-line width.
// Compose's Text(softWrap = false) reads this to decide the paragraph width
// (LayoutUtils.finalMaxWidth returns Constraints.Infinity when softWrap=false,
@@ -193,8 +225,9 @@ internal class SdlParagraph(
}
override fun getLineForVerticalPosition(vertical: Float): Int {
if (!metricSpans) return (vertical / lh).toInt().coerceIn(0, lineCount - 1)
// Cumulative tops are monotonic — walk to the line containing `vertical`.
// (No uniform-divide fast path anymore: the first line's box is the font
// cell while later lines use the style lineHeight.)
for (i in 0 until lineCount) if (vertical < lineTops[i + 1]) return i
return lineCount - 1
}
@@ -368,6 +401,8 @@ internal class SdlParagraph(
inFontVariations = variations,
inBaseItalic = style.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic,
inTextDecoration = inDecoration ?: style.textDecoration,
inLineHeightPx = lh,
inTrimFirstLine = !uniformBands || styleLineHeightPx == null,
)
}
@@ -31,11 +31,13 @@ interface RenderBackend {
so it maps 1:1 onto the pixel back buffer. */
fun beginFrame(inDpr: Float)
/* Draw the upstream LayoutNode tree owned by [inHost] through the vendored
coordinator / DrawModifierNode pipeline (LayoutNode.draw → CanvasDrawScope
→ the platform Canvas backend). Default no-op — the Skia backend will
override once SkiaCanvas lands (see NODE_ENGINE_PORT.md Phase 9). */
fun drawRoot(inHost: com.compose.sdl.node.ComposeRootHost) {}
/* Build this frame's platform Canvas and hand it to [inDraw], which walks the
composition (host.drawRoot(canvas)) through the vendored coordinator /
DrawModifierNode pipeline. Taking a (Canvas)->Unit instead of the host keeps
RenderBackend + its implementations independent of the node/host layer — the
decoupling that lets the renderers live in :ui-graphics (no ui-graphics→ui
cycle). Default no-op. */
fun drawRoot(inDraw: (canvas: androidx.compose.ui.graphics.Canvas) -> Unit) {}
/* Flush + present whatever was just drawn. */
fun endFrame()
@@ -0,0 +1,34 @@
package com.compose.sdl.graphics
// ==================
// MARK: DrawStats — per-frame renderer work counters (diagnostic)
// ==================
/**
* Cheap per-frame counters the SDL draw path bumps and the frame profiler
* reads, to answer "what inside `draw` costs the time?" — geometry submissions
* (SDL_RenderGeometry calls), total vertices tessellated, rounded-clip mask
* realizations (offscreen render-target passes — the expensive ones), text
* blits and image blits. Reset each frame by the profiler after reading.
*
* Single-threaded (all draw is on the main thread), so plain vars are fine.
* Zero overhead when the profiler isn't reading — just integer increments.
*/
object DrawStats {
var geometrySubmits: Int = 0
var vertices: Int = 0
var maskRealizations: Int = 0
var textDraws: Int = 0
var imageBlits: Int = 0
fun reset() {
geometrySubmits = 0
vertices = 0
maskRealizations = 0
textDraws = 0
imageBlits = 0
}
fun summary(): String =
"geo=$geometrySubmits verts=$vertices masks=$maskRealizations text=$textDraws img=$imageBlits"
}
@@ -0,0 +1,14 @@
package com.compose.sdl.graphics
import androidx.compose.ui.graphics.GraphicsContext
// ==================
// MARK: GraphicsContext factory seam (B2 / P1.3)
// ==================
/* ComposeOwner.graphicsContext is created behind a per-renderer factory seam so each
leg supplies its own GraphicsContext: exactly one actual is attached per target
(the createRenderBackend trick). SDL → the project ProjectGraphicsContext (record/
replay via the SDL NativeRenderNode); Skia → upstream's SkiaGraphicsContext (owns a
skiko RenderNodeContext). See RENDERER.md §4 (B2). */
internal expect fun createGraphicsContext(): GraphicsContext
@@ -1,13 +0,0 @@
package com.compose.sdl.graphics
import androidx.compose.ui.graphics.layer.GraphicsLayer
// ==================
// MARK: GraphicsLayer factory — native actuals
// ==================
internal actual fun createProjectGraphicsLayer(): GraphicsLayer = GraphicsLayer()
internal actual fun releaseProjectGraphicsLayer(inLayer: GraphicsLayer) {
inLayer.isReleased = true
}
@@ -0,0 +1,67 @@
package com.compose.sdl.graphics
import androidx.compose.ui.graphics.Matrix
import kotlin.math.abs
// Layer transform matrix builder for GraphicsLayerOwnerLayer (hit-testing /
// coordinate mapping). Body vendored from compose-multiplatform-core
// Matrices.skiko.kt `prepareTransformationMatrix` — pure Compose `Matrix` math,
// no Skia. It lives HERE in nativeMain (shared by both renderer legs) under a
// distinct name so it is visible to GraphicsLayerOwnerLayer (also nativeMain);
// the upstream `prepareTransformationMatrix` stays skikoRenderer-only in the
// vendored Matrices.skiko.kt and would not be visible up here. Distinct name
// avoids clashing with that copy on the Skia leg. (RENDERER.md P1.5 will
// reverse this rename once B2 vendors Matrices.skiko onto the Skia leg.)
// VENDOR-BASE: compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/graphics/Matrices.skiko.kt @ v1.12.0-beta03+dev4483
// Builds the layer transform (translation / rotation / scale / camera about a
// pivot) into [matrix]. Matches the skiko copy so hit-testing agrees across legs.
internal fun prepareLayerTransformationMatrix(
matrix: Matrix,
pivotX: Float,
pivotY: Float,
translationX: Float,
translationY: Float,
rotationX: Float,
rotationY: Float,
rotationZ: Float,
scaleX: Float,
scaleY: Float,
cameraDistance: Float,
) {
matrix.reset()
matrix.translate(x = -pivotX, y = -pivotY)
matrix *= Matrix().apply {
rotateZ(rotationZ)
rotateY(rotationY)
rotateX(rotationX)
scale(scaleX, scaleY)
}
// Perspective transform should be applied only in case of rotations to avoid
// multiply application in hierarchies.
// See Android's frameworks/base/libs/hwui/RenderProperties.cpp for reference
if (!rotationX.isZero() || !rotationY.isZero()) {
matrix *= Matrix().apply {
// The camera location is passed in inches, set in pt
val depth = cameraDistance * 72f
this[2, 3] = -1f / depth
}
}
matrix *= Matrix().apply {
translate(x = pivotX + translationX, y = pivotY + translationY)
}
// Third column and row are irrelevant for 2D space.
// Zeroing required to get correct inverse transformation matrix.
matrix[2, 0] = 0f
matrix[2, 1] = 0f
matrix[2, 3] = 0f
matrix[0, 2] = 0f
matrix[1, 2] = 0f
matrix[3, 2] = 0f
}
private const val NON_ZERO_EPSILON = 0.001f
@Suppress("NOTHING_TO_INLINE")
private inline fun Float.isZero(): Boolean = abs(this) <= NON_ZERO_EPSILON
@@ -65,12 +65,36 @@ class ComposeRootHost(inDensity: Float = 1f) {
fOwner.animationFrameClock.sendFrame(inNanos)
}
// True while a node-level animation (scroll fling, node Animatable) awaits the next
// animation frame — the node half of the window's quiescence signal (render-to-quiescence
// screenshot capture asks the window whether ANY work is still pending).
fun hasAnimationAwaiters(): Boolean = fOwner.animationFrameClock.hasAwaiters
fun measureAndLayout() {
// Flush deferred end-of-apply work (focus invalidation etc.) before measuring, so
// requestFocus() from composition OR from a pointer event this frame takes effect —
// e.g. focus-on-click, which schedules a focus invalidation during event dispatch.
fOwner.onEndApplyChanges()
fOwner.measureAndLayout()
// Drop snapshot observations whose scope is no longer valid (detached nodes,
// destroyed layers, disposed draw scopes). Upstream RootNodeOwner does this after
// the measure pass; without it, every disposed subtree's measure/layout/DRAW
// observation scopes linger in the OwnerSnapshotObserver forever — each pins its
// observed object graph (a leak the P2.2 soak caught on ripple/indication draws).
fOwner.snapshotObserver.clearInvalidObservations()
}
// Draw the composed tree into [canvas] — re-records dirty layers, then walks
// the root so clean layers replay their cached content. Backends call this
// instead of rootNode.draw so retained-layer bookkeeping runs each frame.
fun drawRoot(canvas: androidx.compose.ui.graphics.Canvas) {
fOwner.renderRoot(canvas)
}
// The window wires this to set needsFrame — a layer whose content changed
// (OwnedLayer.invalidate) schedules a frame even with nothing else pending.
fun setInvalidationCallback(callback: () -> Unit) {
fOwner.onInvalidate = callback
}
// The owner's FocusOwner IS a FocusManager — the window provides it as LocalFocusManager.
@@ -138,29 +162,8 @@ class ComposeRootHost(inDensity: Float = 1f) {
fun onDropComplete() = fOwner.dragAndDropManager.dropComplete()
}
// ==================
// MARK: pointer-event bridge (nativeMain builds the internal PointerInputEvent)
// ==================
/* The internal `expect PointerInputEvent` exposes no commonMain constructor, so the
build+dispatch is a nativeMain actual. Constructs a single-pointer event and feeds it
to the owner's PointerInputEventProcessor. inType: 0=Move 1=Press 2=Release. */
internal expect fun feedPointerToProcessor(
inOwner: com.compose.sdl.node.impl.ComposeOwner,
inType: Int,
inButton: Int,
inUptime: Long,
inX: Float,
inY: Float,
)
/* Builds a Scroll-type PointerInputEvent (scrollDelta) and drives the processor, so the vendored
Modifier.scrollable's MouseWheelScrollingLogic handles the wheel. nativeMain-only (ctor is native). */
internal expect fun feedScrollToProcessor(
inOwner: com.compose.sdl.node.impl.ComposeOwner,
inX: Float,
inY: Float,
inDeltaX: Float,
inDeltaY: Float,
inUptime: Long,
)
// NOTE: feedPointerToProcessor / feedScrollToProcessor are plain nativeMain
// functions in PointerEventBridge.native.kt. This facade moved from commonMain to
// nativeMain (it now constructs the native retained-layer OwnedLayer via
// ComposeOwner), so the former expect/actual split collapsed to a single native
// definition — see RENDERER.md §5.
@@ -25,7 +25,7 @@ private var fTertiaryDown = false
/* Builds the internal PointerInputEvent (its constructor is native-only) from a single mouse
pointer and drives the owner's PointerInputEventProcessor. inType: 0=Move 1=Press 2=Release;
inButton: 0=primary 1=secondary 2=tertiary. */
internal actual fun feedPointerToProcessor(
internal fun feedPointerToProcessor(
inOwner: ComposeOwner,
inType: Int,
inButton: Int,
@@ -82,7 +82,7 @@ internal actual fun feedPointerToProcessor(
vendored Modifier.scrollable (MouseWheelScrollingLogic) consumes it. SDL wheel delta maps
directly to scrollDelta (wheel-down = SDL deltaY<0 → content scrolls down); magnitude is scaled
in Sdl3ScrollConfig (dp per notch). */
internal actual fun feedScrollToProcessor(
internal fun feedScrollToProcessor(
inOwner: ComposeOwner,
inX: Float,
inY: Float,
@@ -9,6 +9,8 @@ import androidx.compose.ui.node.LayoutNodeDrawScope
import androidx.compose.ui.node.MeasureAndLayoutDelegate
import androidx.compose.ui.node.Owner
import androidx.compose.ui.node.OwnedLayer
import androidx.compose.ui.node.OwnedLayerManager
import androidx.compose.ui.node.GraphicsLayerOwnerLayer
import androidx.compose.ui.node.OwnerScope
import androidx.compose.ui.node.OwnerSnapshotObserver
import androidx.compose.ui.node.RootForTest
@@ -79,7 +81,7 @@ internal class ComposeOwner(
override val root: LayoutNode,
override val density: Density = Density(1f, 1f),
override val layoutDirection: LayoutDirection = LayoutDirection.Ltr,
) : Owner {
) : Owner, OwnedLayerManager {
// The vendored measure/layout state machine, rooted at [root].
private val fDelegate = MeasureAndLayoutDelegate(root)
@@ -151,6 +153,11 @@ internal class ComposeOwner(
override fun onDetach(node: LayoutNode) {
fDelegate.onNodeDetached(node)
// Drop the detached node's read-observation scopes, exactly as upstream
// RootNodeOwner.onDetach does. Without this, every disposed node's measure/
// layout/draw observation scopes accumulate in the snapshot observer forever
// — the baseline composition-machinery leak the P2.2 soak caught.
snapshotObserver.clear(node)
}
override fun registerOnLayoutCompletedListener(listener: Owner.OnLayoutCompletedListener) {
@@ -176,7 +183,86 @@ internal class ComposeOwner(
drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
invalidateParentLayer: () -> Unit,
explicitLayer: GraphicsLayer?,
): OwnedLayer = ProjectOwnedLayer(drawBlock)
): OwnedLayer = GraphicsLayerOwnerLayer(
graphicsLayer = explicitLayer ?: graphicsContext.createGraphicsLayer(),
context = if (explicitLayer != null) null else graphicsContext,
layerManager = this,
drawBlock = drawBlock,
invalidateParentLayer = invalidateParentLayer,
)
// ============
// OwnedLayerManager — retained-layer bookkeeping (mirrors upstream
// OwnedLayerManagerImpl). dirtyLayers are re-recorded once per frame in
// [renderRoot] before the tree is drawn; clean layers replay their cached
// display list. invalidate() asks the window to schedule a frame.
private val dirtyLayers = mutableListOf<OwnedLayer>()
private var postponedDirtyLayers: MutableList<OwnedLayer>? = null
private var isDrawingContent = false
// Set by the window (via ComposeRootHost) to flag needsFrame; a layer whose
// content changed (OwnedLayer.invalidate) schedules a frame even when nothing
// else (recompose / relayout) is pending.
internal var onInvalidate: (() -> Unit)? = null
override fun invalidate() {
onInvalidate?.invoke()
}
override fun notifyLayerIsDirty(layer: OwnedLayer, isDirty: Boolean) {
// Mirrors upstream OwnedLayerManagerImpl exactly. The CRITICAL detail: when a
// layer clears its dirty flag DURING drawing (updateDisplayList sets isDirty
// = false in the renderRoot loop), we must NOT remove it from dirtyLayers here
// — that would shrink the list mid-iteration (IndexOutOfBounds). renderRoot's
// dirtyLayers.clear() after the loop handles it instead.
if (!isDirty) {
if (!isDrawingContent) {
dirtyLayers.remove(layer)
postponedDirtyLayers?.remove(layer)
}
} else if (!isDrawingContent) {
dirtyLayers += layer
} else {
val postponed = postponedDirtyLayers
?: mutableListOf<OwnedLayer>().also { postponedDirtyLayers = it }
postponed += layer
}
}
override fun recycle(layer: OwnedLayer): Boolean {
dirtyLayers.remove(layer)
postponedDirtyLayers?.remove(layer)
return false
}
// Both Owner and OwnedLayerManager declare voteFrameRate with a default, so an
// explicit override is required. We render at a fixed vsync cadence — no voting.
override fun voteFrameRate(frameRate: Float) {}
// Draw the tree: re-record dirty layers' display lists, then walk the root so
// clean layers replay. Called once per frame by ComposeRootHost.drawRoot.
internal fun renderRoot(canvas: Canvas) {
isDrawingContent = true
// Re-record dirty layers BEFORE drawing (unlike Android, our draw forms the
// actual render-command sequence, so display lists must be current first).
// notifyLayerIsDirty(false) is a no-op while drawing, so the list can't shrink
// here; clear() below drops them all at once.
if (dirtyLayers.isNotEmpty()) {
for (i in 0 until dirtyLayers.size) {
dirtyLayers[i].updateDisplayList()
}
}
dirtyLayers.clear()
root.draw(canvas, null)
// Layers that invalidated themselves during draw were parked in postponed;
// roll them into next frame.
postponedDirtyLayers?.let {
dirtyLayers.addAll(it)
it.clear()
}
isDrawingContent = false
}
// ============
// No-op subsystems (focus / semantics / input / clipboard / text / …).
@@ -216,17 +302,12 @@ internal class ComposeOwner(
containsControls: Boolean,
): Long = originalTimeoutMillis
}
// GraphicsLayer here is the project record/replay actual (see
// GraphicsLayer.native.kt) — SharedTransitionLayout overlays and
// rememberGraphicsLayer() create layers through this context. The
// expect class hides its constructor/release from commonMain, hence
// the project factory hops.
override val graphicsContext: GraphicsContext = object : GraphicsContext {
override fun createGraphicsLayer(): GraphicsLayer =
com.compose.sdl.graphics.createProjectGraphicsLayer()
override fun releaseGraphicsLayer(layer: GraphicsLayer) =
com.compose.sdl.graphics.releaseProjectGraphicsLayer(layer)
}
// Created behind a per-renderer factory seam (B2 / P1.3): SDL uses the project
// record/replay GraphicsContext; the Skia leg swaps in upstream's SkiaGraphicsContext
// at P1.6. SharedTransitionLayout overlays and rememberGraphicsLayer() create layers
// through this context. See com/compose/sdl/graphics/GraphicsContextFactory.kt.
override val graphicsContext: GraphicsContext =
com.compose.sdl.graphics.createGraphicsContext()
override val textToolbar: TextToolbar = object : TextToolbar {
override val status: androidx.compose.ui.platform.TextToolbarStatus
get() = androidx.compose.ui.platform.TextToolbarStatus.Hidden
@@ -0,0 +1,321 @@
package androidx.compose.ui.graphics.layer
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.toSize
import com.compose.sdl.graphics.NativeRenderNode
// ==================
// MARK: GraphicsLayer — façade over NativeRenderNode
// ==================
//
// DERIVED (copy + edit) from compose-multiplatform-core
// ui-graphics/src/skikoMain/.../graphics/layer/SkiaGraphicsLayer.skiko.kt.
// VENDOR-BASE: compose/ui/ui-graphics/src/skikoMain/kotlin/androidx/compose/ui/graphics/layer/SkiaGraphicsLayer.skiko.kt @ v1.12.0-beta03+dev4483
// The ONLY structural change vs upstream: the backing display-list node is our
// renderer-agnostic `NativeRenderNode` instead of `org.jetbrains.skiko.node.RenderNode`,
// so both renderers share this façade (Skia's node wraps the real skiko RenderNode;
// SDL's node is SdlRenderNode). Every visual property is mirrored onto the node and
// applied at replay, exactly like upstream. Trimmed vs upstream: outsets (blur
// expansion) and ChildLayerDependenciesTracker (prompt child release — our children
// release via NativeReleaseQueue/GC instead). See RENDERER.md §4 (B2).
@Suppress("PropertyName")
actual class GraphicsLayer internal constructor(
private val renderNode: NativeRenderNode,
) {
private var outlineDirty = true
private var roundRectOutlineTopLeft: Offset = Offset.Zero
private var roundRectOutlineSize: Size = Size.Unspecified
private var roundRectCornerRadius: Float = 0f
private var internalOutline: Outline? = null
private var outlinePath: Path? = null
actual var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
set(value) {
if (field != value) {
field = value
renderNode.compositingStrategy = value
}
}
actual var topLeft: IntOffset = IntOffset.Zero
set(value) {
if (field != value) {
field = value
renderNode.topLeft = value
}
}
actual var size: IntSize = IntSize.Zero
private set(value) {
if (field != value) {
field = value
renderNode.size = value
if (roundRectOutlineSize.isUnspecified) {
outlineDirty = true
configureOutlineAndClip()
}
}
}
actual var pivotOffset: Offset = Offset.Unspecified
set(value) {
if (field != value) {
field = value
renderNode.pivot = value
}
}
actual var alpha: Float = 1f
set(value) {
if (field != value) {
field = value
renderNode.alpha = value
}
}
actual var scaleX: Float = 1f
set(value) {
if (field != value) { field = value; renderNode.scaleX = value }
}
actual var scaleY: Float = 1f
set(value) {
if (field != value) { field = value; renderNode.scaleY = value }
}
actual var translationX: Float = 0f
set(value) {
if (field != value) { field = value; renderNode.translationX = value }
}
actual var translationY: Float = 0f
set(value) {
if (field != value) { field = value; renderNode.translationY = value }
}
actual var shadowElevation: Float = 0f
set(value) {
if (field != value) {
field = value
renderNode.shadowElevation = value
outlineDirty = true
configureOutlineAndClip()
}
}
actual var ambientShadowColor: Color = Color.Black
set(value) {
if (field != value) { field = value; renderNode.ambientShadowColor = value }
}
actual var spotShadowColor: Color = Color.Black
set(value) {
if (field != value) { field = value; renderNode.spotShadowColor = value }
}
actual var blendMode: BlendMode = BlendMode.SrcOver
set(value) {
if (field != value) { field = value; renderNode.blendMode = value }
}
actual var colorFilter: ColorFilter? = null
set(value) {
if (field != value) { field = value; renderNode.colorFilter = value }
}
actual val outline: Outline
get() {
val tmpOutline = internalOutline
val tmpPath = outlinePath
return if (tmpOutline != null) {
tmpOutline
} else if (tmpPath != null) {
Outline.Generic(tmpPath).also { internalOutline = it }
} else {
resolveOutlinePosition { outlineTopLeft, outlineSize ->
val left = outlineTopLeft.x
val top = outlineTopLeft.y
val right = left + outlineSize.width
val bottom = top + outlineSize.height
val cornerRadius = this.roundRectCornerRadius
if (cornerRadius > 0f) {
Outline.Rounded(RoundRect(left, top, right, bottom, CornerRadius(cornerRadius)))
} else {
Outline.Rectangle(Rect(left, top, right, bottom))
}
}.also { internalOutline = it }
}
}
private fun resetOutlineParams() {
internalOutline = null
outlinePath = null
roundRectOutlineSize = Size.Unspecified
roundRectOutlineTopLeft = Offset.Zero
roundRectCornerRadius = 0f
outlineDirty = true
}
actual fun setPathOutline(path: Path) {
resetOutlineParams()
this.outlinePath = path
configureOutlineAndClip()
}
actual fun setRoundRectOutline(topLeft: Offset, size: Size, cornerRadius: Float) {
if (this.roundRectOutlineTopLeft != topLeft ||
this.roundRectOutlineSize != size ||
this.roundRectCornerRadius != cornerRadius ||
this.outlinePath != null
) {
resetOutlineParams()
this.roundRectOutlineTopLeft = topLeft
this.roundRectOutlineSize = size
this.roundRectCornerRadius = cornerRadius
configureOutlineAndClip()
}
}
actual fun setRectOutline(topLeft: Offset, size: Size) {
setRoundRectOutline(topLeft, size, 0f)
}
actual var rotationX: Float = 0f
set(value) {
if (field != value) { field = value; renderNode.rotationX = value }
}
actual var rotationY: Float = 0f
set(value) {
if (field != value) { field = value; renderNode.rotationY = value }
}
actual var rotationZ: Float = 0f
set(value) {
if (field != value) { field = value; renderNode.rotationZ = value }
}
actual var cameraDistance: Float = DefaultCameraDistance
set(value) {
if (field != value) { field = value; renderNode.cameraDistance = value }
}
actual var clip: Boolean = false
set(value) {
if (field != value) {
field = value
renderNode.clip = value
outlineDirty = true
configureOutlineAndClip()
}
}
actual var renderEffect: RenderEffect? = null
set(value) {
if (field != value) { field = value; renderNode.renderEffect = value }
}
actual var isReleased: Boolean = false
internal set
actual fun setOutsets(left: Int, top: Int, right: Int, bottom: Int) {
// Outsets (blur/shadow expansion) not modelled on this port — see header.
}
actual fun record(
density: Density,
layoutDirection: LayoutDirection,
size: IntSize,
block: DrawScope.() -> Unit,
) {
this.size = size
renderNode.record(density, layoutDirection, size, block)
}
internal actual fun draw(canvas: Canvas, parentLayer: GraphicsLayer?) {
if (isReleased) return
configureOutlineAndClip()
renderNode.drawInto(canvas)
}
@OptIn(androidx.compose.ui.InternalComposeUiApi::class)
private fun configureOutlineAndClip() {
if (!outlineDirty) return
val outlineIsNeeded = clip || shadowElevation > 0f
if (!outlineIsNeeded) {
renderNode.clip = false
renderNode.setClipPath(null)
} else {
renderNode.clip = clip
when (val tmpOutline = outline) {
is Outline.Rectangle -> renderNode.setClipRect(
tmpOutline.rect.left, tmpOutline.rect.top,
tmpOutline.rect.right, tmpOutline.rect.bottom,
)
is Outline.Rounded -> renderNode.setClipRRect(
tmpOutline.roundRect.left, tmpOutline.roundRect.top,
tmpOutline.roundRect.right, tmpOutline.roundRect.bottom,
floatArrayOf(
tmpOutline.roundRect.topLeftCornerRadius.x,
tmpOutline.roundRect.topLeftCornerRadius.y,
tmpOutline.roundRect.topRightCornerRadius.x,
tmpOutline.roundRect.topRightCornerRadius.y,
tmpOutline.roundRect.bottomRightCornerRadius.x,
tmpOutline.roundRect.bottomRightCornerRadius.y,
tmpOutline.roundRect.bottomLeftCornerRadius.x,
tmpOutline.roundRect.bottomLeftCornerRadius.y,
),
)
is Outline.Generic -> renderNode.setClipPath(tmpOutline.path)
}
}
outlineDirty = false
}
private inline fun <T> resolveOutlinePosition(block: (Offset, Size) -> T): T {
val layerSize = this.size.toSize()
val rRectTopLeft = roundRectOutlineTopLeft
val rRectSize = roundRectOutlineSize
val outlineSize = if (rRectSize.isUnspecified) layerSize else rRectSize
return block(rRectTopLeft, outlineSize)
}
internal fun release() {
if (!isReleased) {
isReleased = true
renderNode.close()
}
}
// Mirrors upstream SkiaGraphicsLayer.toImageBitmap: render the layer into a fresh
// bitmap of its own size. The offscreen canvas is flushed via NativeFinishableCanvas
// (SDL batches geometry; the backing render-target texture must be committed before
// it's read) — Skia's finish() is a no-op.
actual suspend fun toImageBitmap(): ImageBitmap {
val bitmap = ImageBitmap(size.width, size.height)
val canvas = Canvas(bitmap)
draw(canvas, null)
(canvas as? com.compose.sdl.graphics.NativeFinishableCanvas)?.finish()
return bitmap
}
}
@@ -0,0 +1,201 @@
package com.compose.sdl.graphics
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.DefaultCameraDistance
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
// ==================
// MARK: DeferredRenderNode — replay-the-block NativeRenderNode
// ==================
/*
The renderer-agnostic [NativeRenderNode] both renderers use today. It does NOT
cache rasterised output: [record] stores the draw block; [drawInto] REPLAYS it
against the target canvas under the node's transform / clip / shadow / alpha. This
is behaviour-identical to the pre-refactor paths it replaces — the old
GraphicsLayer.native lambda-replay AND ProjectOwnedLayer's transform/shadow/clip —
now unified in one place behind the RenderNode seam.
It is the correct-but-unoptimised node. The perf win (stop re-tessellating on
replay) comes from swapping the per-renderer createNativeRenderNode actual to a
caching node — skiko RenderNode (a real display list) on Skia, an offscreen /
cached-geometry display list on SDL — WITHOUT touching GraphicsLayer. See
RENDERER.md §4 (Phase 2 = that swap).
Transform is applied via [prepareLayerTransformationMatrix] + canvas.concat, the
SAME matrix GraphicsLayerOwnerLayer uses for hit-testing (mapOffset), so draw and
hit-test provably agree. Shadow + rounded/shape clip go through the project canvas
interfaces (NativeShadowCanvas / NativeShapeClipCanvas), exactly as the old
ProjectOwnedLayer did.
*/
internal class DeferredRenderNode : NativeRenderNode {
override var topLeft: IntOffset = IntOffset.Zero
override var size: IntSize = IntSize.Zero
override var pivot: Offset = Offset.Unspecified
override var alpha: Float = 1f
override var scaleX: Float = 1f
override var scaleY: Float = 1f
override var translationX: Float = 0f
override var translationY: Float = 0f
override var rotationX: Float = 0f
override var rotationY: Float = 0f
override var rotationZ: Float = 0f
override var cameraDistance: Float = DefaultCameraDistance
override var shadowElevation: Float = 0f
override var ambientShadowColor: Color = Color.Black
override var spotShadowColor: Color = Color.Black
override var blendMode: BlendMode = BlendMode.SrcOver
override var colorFilter: ColorFilter? = null
override var renderEffect: RenderEffect? = null
override var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
override var clip: Boolean = false
// Outline pushed by GraphicsLayer.configureOutlineAndClip whenever clip OR a
// shadow is present — so it drives BOTH the content clip (when clip=true) and
// the drop-shadow silhouette (when shadowElevation>0, even with clip=false).
private var clipOutline: Outline? = null
override fun setClipRect(left: Float, top: Float, right: Float, bottom: Float) {
clipOutline = Outline.Rectangle(Rect(left, top, right, bottom))
}
override fun setClipRRect(left: Float, top: Float, right: Float, bottom: Float, radii: FloatArray) {
clipOutline = Outline.Rounded(
RoundRect(
left = left, top = top, right = right, bottom = bottom,
topLeftCornerRadius = CornerRadius(radii[0], radii[1]),
topRightCornerRadius = CornerRadius(radii[2], radii[3]),
bottomRightCornerRadius = CornerRadius(radii[4], radii[5]),
bottomLeftCornerRadius = CornerRadius(radii[6], radii[7]),
),
)
}
override fun setClipPath(path: Path?) {
clipOutline = if (path == null) null else Outline.Generic(path)
}
// ============
// Record — store the block (deferred; replayed in drawInto).
private var recordedBlock: (DrawScope.() -> Unit)? = null
private var recordedDensity: Density = Density(1f)
private var recordedLayoutDirection: LayoutDirection = LayoutDirection.Ltr
private val drawScope = CanvasDrawScope()
override fun record(
density: Density,
layoutDirection: LayoutDirection,
size: IntSize,
block: DrawScope.() -> Unit,
) {
recordedDensity = density
recordedLayoutDirection = layoutDirection
this.size = size
recordedBlock = block
}
override fun drawInto(canvas: Canvas) {
val block = recordedBlock ?: return
val w = size.width.toFloat()
val h = size.height.toFloat()
canvas.save()
canvas.translate(topLeft.x.toFloat(), topLeft.y.toFloat())
// Transform about the pivot (default centre) — via the SAME matrix used for
// hit-testing, so draw and hit-test agree.
val hasTransform = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
rotationX != 0f || rotationY != 0f || translationX != 0f || translationY != 0f
if (hasTransform && w > 0f && h > 0f) {
val pivotX = if (pivot.isUnspecified) w / 2f else pivot.x
val pivotY = if (pivot.isUnspecified) h / 2f else pivot.y
val matrix = Matrix()
prepareLayerTransformationMatrix(
matrix = matrix,
pivotX = pivotX, pivotY = pivotY,
translationX = translationX, translationY = translationY,
rotationX = rotationX, rotationY = rotationY, rotationZ = rotationZ,
scaleX = scaleX, scaleY = scaleY, cameraDistance = cameraDistance,
)
canvas.concat(matrix)
}
// Drop shadow — layer-local, BEFORE the clip (shadow lives outside bounds).
if (shadowElevation > 0f && w > 0f && h > 0f) {
val outline = clipOutline
if (outline != null) {
(canvas as? NativeShadowCanvas)
?.drawDropShadow(outline, shadowElevation, ambientShadowColor, spotShadowColor)
}
}
// Content clip.
if (clip && w > 0f && h > 0f) applyClip(canvas)
// Offscreen for alpha (behaviour-preserving: the old paths saveLayer'd on
// alpha<1). ModulateAlpha suppresses the offscreen (alpha folds per-op) — but
// our DrawScope has no per-op multiplier yet, so it still composites here.
val needsAlphaLayer = alpha < 1f && w > 0f && h > 0f
if (needsAlphaLayer) {
canvas.saveLayer(
Rect(0f, 0f, w, h),
Paint().apply { alpha = this@DeferredRenderNode.alpha },
)
}
drawScope.draw(recordedDensity, recordedLayoutDirection, canvas, Size(w, h), block)
if (needsAlphaLayer) canvas.restore()
canvas.restore()
}
private fun applyClip(canvas: Canvas) {
when (val outline = clipOutline) {
is Outline.Rectangle -> canvas.clipRect(
outline.rect.left, outline.rect.top, outline.rect.right, outline.rect.bottom,
)
is Outline.Rounded -> {
// Skia clips rounded natively via clipPath; the SDL canvas has no
// path-clip primitive, so it implements NativeShapeClipCanvas and clips
// the rounded outline via an offscreen mask (preferred over clipPath,
// which falls back to the bounding rect there and left rounded state
// layers looking square).
val shapeClip = canvas as? NativeShapeClipCanvas
if (shapeClip != null) {
shapeClip.clipRoundRect(outline.roundRect)
} else {
val path = Path().apply { addRoundRect(outline.roundRect) }
canvas.clipPath(path)
}
}
is Outline.Generic -> canvas.clipPath(outline.path)
null -> {}
}
}
override fun close() {
recordedBlock = null
}
}
@@ -0,0 +1,11 @@
package com.compose.sdl.graphics
import androidx.compose.ui.graphics.GraphicsContext
// ==================
// MARK: createGraphicsContext — SDL3 renderer actual
// ==================
/* The SDL leg uses the project record/replay GraphicsContext (backed by the SDL
NativeRenderNode via createProjectGraphicsLayer). This is the permanent SDL home. */
internal actual fun createGraphicsContext(): GraphicsContext = ProjectGraphicsContext()
@@ -0,0 +1,14 @@
package com.compose.sdl.graphics
import androidx.compose.ui.graphics.layer.GraphicsLayer
// ==================
// MARK: GraphicsLayer factory — native actuals
// ==================
internal fun createProjectGraphicsLayer(): GraphicsLayer =
GraphicsLayer(createNativeRenderNode(NativeRenderNodeContext()))
internal fun releaseProjectGraphicsLayer(inLayer: GraphicsLayer) {
inLayer.release()
}
@@ -0,0 +1,105 @@
package com.compose.sdl.graphics
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
// ==================
// MARK: NativeRenderNode — the renderer-agnostic retained display-list node
// ==================
/**
* The one genuinely renderer-specific piece of the retained-layer engine. This is
* the [GraphicsLayer] façade's backing node — the analogue of skiko's
* `org.jetbrains.skiko.node.RenderNode`. [GraphicsLayer] (our copy-edit of upstream
* `SkiaGraphicsLayer.skiko.kt`) mirrors every visual property onto this node, calls
* [record] to capture the layer's drawing once, and [drawInto] to replay it every
* frame with the transform / clip / shadow / compositing applied — WITHOUT
* re-recording.
*
* Property contract mirrors skiko `RenderNode` (translation/scale/rotation/pivot/
* alpha/shadow/clip/…), so the Skia actual wraps skiko `RenderNode` almost verbatim
* and the SDL actual (`SdlRenderNode`) implements the same contract.
*
* [record]/[drawInto] are higher-level than skiko's `beginRecording()`/`drawInto()`
* so BOTH a display-list impl (Skia: record into a skiko RenderNode; SDL later: a
* cached-geometry / offscreen display list) AND the current deferred-replay impl
* (SDL now: store the block, replay it under the transform) satisfy them.
*/
internal interface NativeRenderNode {
// ============
// Geometry — where the recorded content sits and its transform origin.
var topLeft: IntOffset
var size: IntSize
var pivot: Offset
// ============
// Transform — applied at replay, no re-record (matches skiko RenderNode).
var alpha: Float
var scaleX: Float
var scaleY: Float
var translationX: Float
var translationY: Float
var rotationX: Float
var rotationY: Float
var rotationZ: Float
var cameraDistance: Float
// ============
// Elevation shadow.
var shadowElevation: Float
var ambientShadowColor: Color
var spotShadowColor: Color
// ============
// Compositing — decides an offscreen (requiresLayer) and how content composites.
var blendMode: BlendMode
var colorFilter: ColorFilter?
var renderEffect: RenderEffect?
var compositingStrategy: CompositingStrategy
// ============
// Clip — outline pushed onto the node; rect / rounded-rect / generic path.
var clip: Boolean
fun setClipRect(left: Float, top: Float, right: Float, bottom: Float)
fun setClipRRect(left: Float, top: Float, right: Float, bottom: Float, radii: FloatArray)
fun setClipPath(path: Path?)
// ============
// Record / replay.
/** Capture the layer content once; re-called only when the layer is dirty. */
fun record(density: Density, layoutDirection: LayoutDirection, size: IntSize, block: DrawScope.() -> Unit)
/** Replay the recorded content onto [canvas], applying this node's transform. */
fun drawInto(canvas: Canvas)
/** Release native resources (textures / picture / display list). */
fun close()
}
/**
* Per-window context shared by the [NativeRenderNode]s of one composition — the
* analogue of skiko's `RenderNodeContext`. Minimal today; the SDL caching impl will
* grow it to carry the renderer handle it needs to allocate offscreen targets.
*/
internal class NativeRenderNodeContext(
val measureDrawBounds: Boolean = false,
)
@@ -0,0 +1,37 @@
package com.compose.sdl.graphics
import com.compose.sdl.renderer.sdl.SdlDisplayListRenderNode
import com.compose.sdl.renderer.sdl.SdlRenderNode
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
// ==================
// MARK: createNativeRenderNode — SDL3 renderer actual
// ==================
/* CDN_LAYERCACHE selects the retained node. The DEFAULT is now the cached-geometry
node (Phase 4): it records each leaf's tessellated geometry + plain text runs ONCE
and replays them under the layer transform — crisp/bit-exact at any transform, no
render-target state, deterministic. A full 57-screen geo-vs-block-replay sweep is
clean (55 at 0.000%; GraphicsLayer/ModShortcuts <0.13% cosmetic rotated-edge AA)
and the former Carousel/Pickers diffs are gone, so it graduates from opt-in to
default. See RENDERER.md §4b/§13.
Escape hatches (env override):
off / defer / 0 → DeferredRenderNode (replay the block every frame; no caching —
the previous default, kept as a fallback)
1 / texture → SdlRenderNode (offscreen-texture cache — legacy; fast for
static leaves but timing-nondeterministic
on complex screens, so not the default)
* → SdlDisplayListRenderNode (geo — the default) */
@OptIn(ExperimentalForeignApi::class)
private val layerCacheMode: String by lazy {
platform.posix.getenv("CDN_LAYERCACHE")?.toKString() ?: ""
}
internal fun createNativeRenderNode(context: NativeRenderNodeContext): NativeRenderNode =
when (layerCacheMode) {
"off", "defer", "0" -> DeferredRenderNode()
"1", "texture" -> SdlRenderNode()
else -> SdlDisplayListRenderNode()
}
@@ -0,0 +1,18 @@
package com.compose.sdl.graphics
import androidx.compose.ui.graphics.GraphicsContext
import androidx.compose.ui.graphics.layer.GraphicsLayer
// ==================
// MARK: ProjectGraphicsContext — SDL leg (record/replay GraphicsLayer)
// ==================
/* The SDL leg's GraphicsContext: creates/releases the project record/replay
GraphicsLayer (backed by the SDL NativeRenderNode). GraphicsLayer's `expect class`
hides its constructor/release from common code, so layer creation/release hop
through the [createProjectGraphicsLayer] / [releaseProjectGraphicsLayer] factories
(co-located here). `shadowContext` keeps the GraphicsContext interface default. */
internal class ProjectGraphicsContext : GraphicsContext {
override fun createGraphicsLayer(): GraphicsLayer = createProjectGraphicsLayer()
override fun releaseGraphicsLayer(layer: GraphicsLayer) = releaseProjectGraphicsLayer(layer)
}
@@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import com.compose.sdl.graphics.a8
import com.compose.sdl.graphics.b8
import com.compose.sdl.graphics.g8
import com.compose.sdl.graphics.r8
@@ -71,17 +72,87 @@ internal class Sdl3Canvas(
// Alpha8 mask) so the tint applied on blit colours it correctly.
private val fOffscreenTexture: COpaquePointer? = null,
private val fForceWhite: Boolean = false,
// Phase 4 capture mode: when set, this canvas RECORDS shape geometry into the list
// (via the scope's recordingSink) and touches NO GPU. Ops not yet capturable
// (text / image / clip / saveLayer / shadow) mark the list `unsupported` so the
// SdlDisplayListRenderNode falls back to a crisp block-replay for that leaf — so
// no un-captured op ever leaks to the screen. See SdlDisplayList.
private val fCaptureList: SdlDisplayList? = null,
) : Canvas,
com.compose.sdl.text.NativeTextCanvas,
com.compose.sdl.graphics.NativePainterCanvas,
com.compose.sdl.graphics.NativeShapeClipCanvas,
com.compose.sdl.graphics.NativeShadowCanvas {
com.compose.sdl.graphics.NativeShadowCanvas,
com.compose.sdl.graphics.NativeFinishableCanvas {
// The tessellating scope. It emits geometry in the canvas's user space; the
// canvas hands it the current affine each draw (setMatrix) so scale / rotate /
// translate reach the vertices.
private val fScope = Sdl3DrawScope(fRenderer, 0f, 0f, fSize)
init {
// Capture mode routes the scope's geometry to the display list instead of the GPU.
if (fCaptureList != null) fScope.recordingSink = fCaptureList
}
// Capture-mode gate for ops we can't record yet: flag the list unsupported (the
// node will replay the block instead) and skip the GPU work. Returns true if the
// caller should return early. No-op (false) outside capture mode.
private fun captureUnsupported(): Boolean {
val list = fCaptureList ?: return false
list.unsupported = true
return true
}
// Replay a captured display list into this (real) canvas under its CURRENT
// transform, in order (z-order preserved). Geometry batches re-emit their
// layer-local vertices through the affine; text runs flush pending geometry then
// re-look-up + blit at the transformed origin (glyph size stays logical). Used by
// SdlDisplayListRenderNode.drawInto.
internal fun replayDisplayList(list: SdlDisplayList) {
fScope.flush()
fScope.setMatrix(fMa, fMb, fMc, fMd, fMe, fMf)
for (i in list.commands.indices) {
when (val cmd = list.commands[i]) {
is GeometryBatch -> fScope.replayBatch(cmd.vertexData, cmd.vertexCount)
is TextRun -> {
fScope.flush() // submit geometry before this run (z-order)
// Transform the run ORIGIN by the affine; keep glyph size logical
// (matches the immediate path: layer scale reaches position, not size).
fTextRenderer?.blitRun(
inFontFamily = cmd.fontFamily,
inText = cmd.text,
inFontSize = cmd.fontSizePx,
inFontVariations = cmd.variations,
inStyle = cmd.style,
inDeviceX = mapX(cmd.x, cmd.y),
inDeviceY = mapY(cmd.x, cmd.y),
inLogW = cmd.w,
inLogH = cmd.h,
inColor = androidx.compose.ui.graphics.Color(cmd.colorArgb),
)
}
is IconRun -> {
fScope.flush() // submit geometry before this glyph (z-order)
// Map the box origin through the affine; box size stays logical (the
// glyph centres in it — layer scale reaches position, not glyph size).
fTextRenderer?.blitIconRun(
inFontFamily = cmd.fontFamily,
inText = cmd.text,
inFontSize = cmd.fontSizePx,
inFontVariations = cmd.variations,
inDeviceBoxX = mapX(cmd.boxX, cmd.boxY),
inDeviceBoxY = mapY(cmd.boxX, cmd.boxY),
inBoxW = cmd.boxW,
inBoxH = cmd.boxH,
inColor = androidx.compose.ui.graphics.Color(cmd.colorArgb),
)
}
}
}
fScope.flush()
}
// Current user→screen affine (a,b,c,d,e,f): screen = (a*x+c*y+e, b*x+d*y+f).
// Accumulates Canvas.translate/scale/rotate/concat; identity + a translate is
// the common layout-positioning case.
@@ -259,6 +330,7 @@ internal class Sdl3Canvas(
fClip = vRegion
applyClip()
clearRegion(vRegion)
com.compose.sdl.graphics.DrawStats.maskRealizations++
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound))
// Attribute the realized mask to the save-frame that pushed the
// clip: inner frames entered since must NOT composite it on their
@@ -278,7 +350,7 @@ internal class Sdl3Canvas(
// Flushes any pending batched geometry to SDL, then frees the scope's
// native buffer + clears the SDL clip. Call once per frame after the draw.
fun finish() {
override fun finish() {
fPendingClips.clear()
fScope.release()
// Safety: an unbalanced save/restore must never leave the renderer pointed
@@ -342,6 +414,14 @@ internal class Sdl3Canvas(
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
fSavedOffscreenTarget = SDL_GetRenderTarget(vRenderer)
SDL_SetRenderTarget(vRenderer, vTex.reinterpret())
// Clear to transparent. Render-target textures aren't auto-cleared, and a
// retained-layer texture (SdlRenderNode) is REUSED across re-records — without
// this, a re-record ghosts the previous content, which showed up as multi-frame
// instability on churny screens (Pickers). Fresh textures happen to be zeroed
// so static leaves were fine, but reuse needs an explicit clear. RenderClear
// fills with the draw colour ignoring blend, so (0,0,0,0) = transparent.
SDL_SetRenderDrawColor(vRenderer, 0u, 0u, 0u, 0u)
SDL_RenderClear(vRenderer)
fClip = null
SDL_SetRenderClipRect(vRenderer, null)
}
@@ -354,6 +434,7 @@ internal class Sdl3Canvas(
}
override fun saveLayer(bounds: Rect, paint: Paint) {
if (captureUnsupported()) return
// No real offscreen buffer — SDL3 lacks a portable render-target-in-a-batched-scope
// primitive here. Instead, multiplicatively propagate the layer's alpha through
// every enclosed primitive: each draw() reads `fAlpha` and multiplies against
@@ -385,6 +466,7 @@ internal class Sdl3Canvas(
}
override fun clipRect(left: Float, top: Float, right: Float, bottom: Float, clipOp: ClipOp) {
if (captureUnsupported()) return
if (clipOp == ClipOp.Difference) {
clipRectDifference(left, top, right, bottom)
return
@@ -425,6 +507,7 @@ internal class Sdl3Canvas(
}
override fun clipPath(path: ComposePath, clipOp: ClipOp) {
if (captureUnsupported()) return
// SDL has only rectangular render clipping; approximate a path clip by
// its bounding box (TODO: stencil/mask for non-rect clips).
val vB = pathBounds(path) ?: return
@@ -465,6 +548,7 @@ internal class Sdl3Canvas(
// arbitrary paths keep the clipPath bbox fallback.
override fun clipRoundRect(inRoundRect: RoundRect) {
if (captureUnsupported()) return
// The mask is cut in DEVICE space (the bbox below goes through the
// affine), but the corner radii arrive in LOCAL user space — scale
// them by the affine too, or a graphicsLayer-scaled circle clip cuts
@@ -704,6 +788,7 @@ internal class Sdl3Canvas(
inAmbientColor: androidx.compose.ui.graphics.Color,
inSpotColor: androidx.compose.ui.graphics.Color,
) {
if (captureUnsupported()) return
realizePendingClips()
if (inElevationPx <= 0f) return
@@ -957,12 +1042,32 @@ internal class Sdl3Canvas(
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?,
inBaseItalic: Boolean,
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
inLineHeightPx: Float,
inTrimFirstLine: Boolean,
) {
// Capture mode: icon-font glyphs (Material Symbols via FreeType) record as an
// IconRun — flush pending geometry first for z-order, then capture the box in
// LAYER-LOCAL device coords (replay maps it through the layer affine + re-draws
// via FreeTypeIcons' cache). Plain/decorated AND spanned text ARE captured too
// (runSink set below; each styled run becomes a TextRun) — only a run with a
// SpanStyle.background still bails inside Sdl3TextRenderer.drawText.
val vCaptureList = fCaptureList
if (vCaptureList != null && inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
fScope.flush()
val vIconColor = if (fAlpha >= 1f) inColor else inColor.copy(alpha = inColor.alpha * fAlpha)
val vArgb = (vIconColor.a8 shl 24) or (vIconColor.r8 shl 16) or (vIconColor.g8 shl 8) or vIconColor.b8
vCaptureList.captureIconRun(
inFontFamily, inText, inFontSizePx, inFontVariations,
mapX(inX, inY), mapY(inX, inY), inBoxWidth, inBoxHeight, vArgb,
)
return
}
// Small centred labels (a popped bubble's "pop") usually sit fully
// inside a rounded clip — gate on containment like other draws, with
// a 2px margin for glyph overhang / AA bleed, instead of paying a
// mask pass per text run.
admitDraw(inX - 2f, inY - 2f, inX + inBoxWidth + 2f, inY + inBoxHeight + 2f)
com.compose.sdl.graphics.DrawStats.textDraws++
fScope.flush()
// Paragraph-level decoration bits forwarded to every wrapped line.
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
@@ -978,26 +1083,25 @@ internal class Sdl3Canvas(
// node's real box. The per-line path below centres within a lineHeight band
// (1.2 em for Material Symbols), taller than the size-clamped icon node,
// which pushed every icon ~0.1 em below centre.
// Device-space scale for GLYPHS: the pen position already maps through
// the affine, but font size and centering boxes must scale with it too
// or text inside a graphicsLayer(scale) renders at full size, off
// centre (JVM/Skia scales glyphs with the canvas). Wrap stays in LOCAL
// units below so line breaks match what layout measured.
val vTextScaleX = sqrt(fMa * fMa + fMb * fMb)
val vTextScaleY = sqrt(fMc * fMc + fMd * fMd)
val vDeviceFontPx =
if (vTextScaleY == 1f) inFontSizePx
else (inFontSizePx * vTextScaleY).toInt().coerceAtLeast(1)
// Text is rasterised at its LOGICAL font size (stable glyph-texture cache
// + stable hinting) and blit 1:1 at the affine-mapped pen position; the
// layer's scale reaches position but NOT glyph size. Re-rasterising at a
// per-frame device size (an earlier attempt) made text under an animated
// layer scale — every Material popup/dropdown enter transition
// (Menu.kt scaleX/scaleY) — step through integer sizes with visible
// size/weight jumps, since SDL3_ttf re-hints at each size. Smoothly
// scaling glyphs would need blit-scaling a logical-size rasterisation
// (JVM/Skia GPU-scales the layer) — a future text-renderer enhancement;
// until then logical-size + no glyph scale is the stable choice.
if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
vTr.drawText(
inText = inText,
inX = mapX(inX, inY).toInt(),
inY = mapY(inX, inY).toInt(),
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
inBoxHeight = (inBoxHeight * vTextScaleY).toInt(),
inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = inBoxHeight.toInt(),
inColor = vColor,
inFontSize = vDeviceFontPx,
inFontSize = inFontSizePx,
inAlign = inTextAlign,
inFontFamily = inFontFamily,
inFontVariations = inFontVariations,
@@ -1015,21 +1119,38 @@ internal class Sdl3Canvas(
// right edge instead of wrapping when the sidebar was resized narrower.
val vWrapWidth = if (inSoftWrap && inBoxWidth > 0f) inBoxWidth.toInt() else Int.MAX_VALUE
val vWrapped = vRenderer.wrap(inText, inFontSizePx, vWrapWidth, inFontFamily, inFontVariations)
val vBaseLineH = vRenderer.lineHeight(inFontSizePx, inFontFamily, inFontVariations)
// Line stacking mirrors SdlParagraph's per-mode model: compat trim (raw styles)
// keeps the FIRST line at the tight font cell; M3's Trim.None (inTrimFirstLine =
// false) makes every line the full band. Following lines always advance by the
// band (inLineHeightPx) when supplied, else the font cell.
val vFontCellH = vRenderer.lineHeight(inFontSizePx, inFontFamily, inFontVariations)
val vAdvance = if (inLineHeightPx > 0f) inLineHeightPx else vFontCellH
val vFirstLineH = if (inTrimFirstLine || inLineHeightPx <= 0f) vFontCellH else vAdvance
// Per-run fontSize spans make a line's box the TALLEST run cell on it —
// same styledLineCellHeight the paragraph measured with, so paint stacks
// lines exactly where layout put them.
val vMetricSpans = com.compose.sdl.text.spansAffectMetrics(inSpans)
// Capture mode: route each plain line's run to the display list (spanned lines
// bail inside drawText via markUnsupported). Reset after the loop. No-op when
// not capturing (vCaptureList == null).
fTextRenderer?.runSink = vCaptureList
var vLineY = inY
for ((vIdx, vLine) in vWrapped.lines.withIndex()) {
val vLineH =
if (vMetricSpans && inSpans != null) {
com.compose.sdl.text.styledLineCellHeight(
vLine, vWrapped.lineStarts.getOrElse(vIdx) { 0 }, inSpans,
inFontSizePx, vTr.dpr, vRenderer, inFontFamily, inFontVariations,
// Size spans can exceed the band, never shrink below it — the same
// max() the paragraph's lineHeights used, so paint tracks layout
// (first line keeps its tight cell under compat trim).
kotlin.math.max(
com.compose.sdl.text.styledLineCellHeight(
vLine, vWrapped.lineStarts.getOrElse(vIdx) { 0 }, inSpans,
inFontSizePx, vTr.dpr, vRenderer, inFontFamily, inFontVariations,
),
if ((vIdx == 0 && inTrimFirstLine) || inLineHeightPx <= 0f) 0f else inLineHeightPx,
)
} else vBaseLineH
} else if (vIdx == 0) vFirstLineH else vAdvance
// Cull lines that would fall entirely below the box (softWrap keeps the
// natural line count; the box just clips at draw time).
if (vLineY >= inY + inBoxHeight) break
@@ -1040,10 +1161,10 @@ internal class Sdl3Canvas(
// scale with the layer. Rotation still only repositions.
inX = mapX(inX, vLineY).toInt(),
inY = mapY(inX, vLineY).toInt(),
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
inBoxHeight = (vLineH * vTextScaleY).toInt(),
inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = vLineH.toInt(),
inColor = vColor,
inFontSize = vDeviceFontPx,
inFontSize = inFontSizePx,
inAlign = inTextAlign,
inFontFamily = inFontFamily,
inFontVariations = inFontVariations,
@@ -1055,6 +1176,7 @@ internal class Sdl3Canvas(
)
vLineY += vLineH
}
fTextRenderer?.runSink = null
}
// ============
@@ -1071,6 +1193,7 @@ internal class Sdl3Canvas(
inContentScale: androidx.compose.ui.layout.ContentScale,
inAlpha: Float,
) {
if (captureUnsupported()) return
admitDraw(inX, inY, inX + inWidth, inY + inHeight)
fScope.flush()
// Map the origin through the affine and scale the size by the matrix's axis
@@ -1089,6 +1212,7 @@ internal class Sdl3Canvas(
// Not-yet-wired ops (B5 image leaf / B6 layers). Accept-and-ignore.
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
if (captureUnsupported()) return
val vBmp = image as? SdlImageBitmap ?: return
admitDraw(topLeftOffset.x, topLeftOffset.y, topLeftOffset.x + vBmp.width, topLeftOffset.y + vBmp.height)
drawImageRect(
@@ -1113,8 +1237,10 @@ internal class Sdl3Canvas(
dstSize: androidx.compose.ui.unit.IntSize,
paint: Paint,
) {
if (captureUnsupported()) return
realizePendingClips()
val vTex = (image as? SdlImageBitmap)?.texture ?: return
com.compose.sdl.graphics.DrawStats.imageBlits++
// Commit pending frame geometry and re-assert this canvas's target + clip
// (an offscreen render just borrowed the render target).
fScope.flush()
@@ -112,6 +112,14 @@ internal class Sdl3DrawScope(
internal fun setMatrix(a: Float, b: Float, c: Float, d: Float, e: Float, f: Float) {
fMa = a; fMb = b; fMc = c; fMd = d; fMe = e; fMf = f
}
// Current device affine (a,b,c,d,e,f) — screen = (a*x+c*y+e, b*x+d*y+f). Phase 4
// replay reads this to re-transform captured layer-local vertices.
internal fun currentDeviceMatrix(): FloatArray = floatArrayOf(fMa, fMb, fMc, fMd, fMe, fMf)
// Phase 4: when set, flush() CAPTURES the tessellated batch (layer-local, if this
// scope's base CTM is identity) instead of submitting to the GPU. null = normal
// immediate submit. See SdlDisplayList.
internal var recordingSink: GeometrySink? = null
// ============
// Vertex batch — heap-allocated buffer of SDL_Vertex shared across
@@ -133,6 +141,13 @@ internal class Sdl3DrawScope(
call mid-scope when the buffer fills up (no cross-triangle state). */
fun flush() {
if (fBatchCount == 0) return
val vSink = recordingSink
if (vSink != null) {
// Phase 4 recording: capture the batch (layer-local) instead of submitting.
vSink.captureGeometry(fVertexData, fBatchCount)
fBatchCount = 0
return
}
fVertexData.usePinned { vPinned ->
platform.posix.memcpy(
fBatch,
@@ -141,6 +156,8 @@ internal class Sdl3DrawScope(
)
}
SDL_RenderGeometry(fRenderer.reinterpret(), null, fBatch, fBatchCount, null, 0)
com.compose.sdl.graphics.DrawStats.geometrySubmits++
com.compose.sdl.graphics.DrawStats.vertices += fBatchCount
fBatchCount = 0
}
@@ -243,7 +260,10 @@ internal class Sdl3DrawScope(
val vB = vT + size.height
when (style) {
Fill -> {
emitQuad(vL, vT, vR, vT, vR, vB, vL, vB, vSampler)
// Gradient fills need interior samples (see emitRectMesh); solid
// fills are a single flat quad.
if (brush is SolidColor) emitQuad(vL, vT, vR, vT, vR, vB, vL, vB, vSampler)
else emitRectMesh(vL, vT, vR, vB, vSampler)
// Axis-aligned rects are pixel-crisp and need no AA. When the affine
// rotates/shears them, the edges become diagonal — feather each edge
// outward (normals in local space; the matrix orients them on screen).
@@ -524,20 +544,30 @@ internal class Sdl3DrawScope(
val vY = fOriginY + topLeft.y
val vW = size.width
val vH = size.height
// Gradient fills sample per-vertex, so the interior must be meshed
// (corner-only sampling renders radial/sweep flat — see emitRectMesh);
// solid fills stay single quads.
val vGrad = brush !is SolidColor
if (vR <= 0f) {
// Trivial: just two triangles.
emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
if (vGrad) emitRectMesh(vX, vY, vX + vW, vY + vH, vSampler)
else emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
return
}
// Body in 3 strips: middle (full width × inner height), top edge,
// bottom edge — plus the 4 corner arcs.
if (style == Fill) {
// Middle strip
emitQuad(vX, vY + vR, vX + vW, vY + vR, vX + vW, vY + vH - vR, vX, vY + vH - vR, vSampler)
// Top edge (between left+right corners)
emitQuad(vX + vR, vY, vX + vW - vR, vY, vX + vW - vR, vY + vR, vX + vR, vY + vR, vSampler)
// Bottom edge
emitQuad(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH - vR, vX + vW - vR, vY + vH, vX + vR, vY + vH, vSampler)
if (vGrad) {
emitRectMesh(vX, vY + vR, vX + vW, vY + vH - vR, vSampler) // middle
emitRectMesh(vX + vR, vY, vX + vW - vR, vY + vR, vSampler) // top edge
emitRectMesh(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH, vSampler) // bottom edge
} else {
// Middle strip
emitQuad(vX, vY + vR, vX + vW, vY + vR, vX + vW, vY + vH - vR, vX, vY + vH - vR, vSampler)
// Top edge (between left+right corners)
emitQuad(vX + vR, vY, vX + vW - vR, vY, vX + vW - vR, vY + vR, vX + vR, vY + vR, vSampler)
// Bottom edge
emitQuad(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH - vR, vX + vW - vR, vY + vH, vX + vR, vY + vH, vSampler)
}
// 4 corner fills — segment count adapts to the corner radius.
val vSeg = arcSegments(90f, vR)
emitFilledArc(vX + vR, vY + vR, vR, vR, 180f, 90f, false, vSeg, vSampler)
@@ -854,6 +884,34 @@ internal class Sdl3DrawScope(
emitTri(ax, ay, cx, cy, dx, dy, inSampler)
}
// Axis-aligned rect subdivided into a grid, so a per-vertex sampler is
// evaluated across the INTERIOR, not just the 4 corners. Corner-only
// sampling renders radial/sweep gradients flat (a square's corners are all
// equidistant from its centre → identical t → solid fill) and clips
// non-uniform multi-stop linear ramps. SDL interpolates linearly within
// each cell, so ~16 cells/axis tracks the gradient curve closely. Solid
// fills never come here (they use emitQuad — one quad).
private fun emitRectMesh(
inL: Float, inT: Float, inR: Float, inB: Float, inSampler: Sampler,
) {
// ~one cell per 8 device px per axis (1..24), so thin edge strips stay
// cheap while large fills get enough interior samples for the gradient.
val vCols = (((inR - inL) / 8f).toInt()).coerceIn(1, 24)
val vRows = (((inB - inT) / 8f).toInt()).coerceIn(1, 24)
val vW = (inR - inL) / vCols
val vH = (inB - inT) / vRows
for (vRow in 0 until vRows) {
val vY0 = inT + vRow * vH
val vY1 = if (vRow == vRows - 1) inB else vY0 + vH
for (vCol in 0 until vCols) {
val vX0 = inL + vCol * vW
val vX1 = if (vCol == vCols - 1) inR else vX0 + vW
emitTri(vX0, vY0, vX1, vY0, vX1, vY1, inSampler)
emitTri(vX0, vY0, vX1, vY1, vX0, vY1, inSampler)
}
}
}
private fun emitTri(
ax: Float, ay: Float, bx: Float, by: Float, cx: Float, cy: Float,
inSampler: Sampler,
@@ -889,6 +947,28 @@ internal class Sdl3DrawScope(
emitFringeQuad(x0, y0, x1, y1, x1 + inNx, y1 + inNy, x0 + inNx, y0 + inNy, inSampler)
}
// Phase 4 replay: re-emit a captured (layer-local) batch through the CURRENT CTM.
// The batch was recorded with an identity base CTM, so applying fMa..fMf here
// places it at the layer's transform. Colours/UVs pass through unchanged. Runs
// in NORMAL (non-recording) mode → the next flush() submits via SDL_RenderGeometry.
internal fun replayBatch(vd: FloatArray, count: Int) {
for (i in 0 until count) {
if (fBatchCount >= kBatchCapacity && fBatchCount % 3 == 0) flush()
val s = i * kFloatsPerVertex
val x = vd[s]; val y = vd[s + 1]
val base = fBatchCount * kFloatsPerVertex
fVertexData[base + 0] = fMa * x + fMc * y + fMe
fVertexData[base + 1] = fMb * x + fMd * y + fMf
fVertexData[base + 2] = vd[s + 2]
fVertexData[base + 3] = vd[s + 3]
fVertexData[base + 4] = vd[s + 4]
fVertexData[base + 5] = vd[s + 5]
fVertexData[base + 6] = vd[s + 6]
fVertexData[base + 7] = vd[s + 7]
fBatchCount++
}
}
// Stage one vertex into fVertexData (auto-flush when full). Position goes
// through the current affine so scale/rotate reach the GPU; the colour is
// sampled at the pre-transform point so a gradient rides its shape.
@@ -930,7 +1010,7 @@ private const val kBatchCapacity: Int = 8190
// Floats per SDL_Vertex: position(x,y) + color(r,g,b,a) + tex_coord(x,y), tightly
// packed. Used to stage vertices in a Kotlin FloatArray and memcpy them across.
private const val kFloatsPerVertex: Int = 8
internal const val kFloatsPerVertex: Int = 8
// ==================
// MARK: Brush → per-vertex colour sampler
@@ -157,6 +157,12 @@ internal class Sdl3OffscreenRenderer(
fForceWhite = vBmp.config == ImageBitmapConfig.Alpha8,
)
}
// Phase 4: a capture canvas records shape geometry into [list] and touches NO GPU
// (text/image/clip mark the list unsupported). Used by SdlDisplayListRenderNode to
// record a leaf's layer-local geometry. No render target — no timing nondeterminism.
internal fun createCaptureCanvas(list: SdlDisplayList, size: Size): Sdl3Canvas =
Sdl3Canvas(fRenderer, size, fTextRenderer, fCaptureList = list)
}
// ==================
@@ -88,7 +88,7 @@ internal class Sdl3RenderBackend(private val backend: SDL3Backend) : RenderBacke
// Paint the upstream LayoutNode tree through the vendored pipeline.
// inHost.rootNode.draw → NodeCoordinator.draw → DrawModifierNode → CanvasDrawScope
// → Sdl3Canvas → SDL_RenderGeometry.
override fun drawRoot(inHost: com.compose.sdl.node.ComposeRootHost) {
override fun drawRoot(inDraw: (androidx.compose.ui.graphics.Canvas) -> Unit) {
val vRenderer = backend.renderer ?: return
val vClipTargets = fClipTargets ?: Sdl3ClipTargets(vRenderer).also { fClipTargets = it }
val vShadowCache = fShadowCache ?: Sdl3ShadowCache(vRenderer).also { fShadowCache = it }
@@ -108,7 +108,7 @@ internal class Sdl3RenderBackend(private val backend: SDL3Backend) : RenderBacke
// Expose the frame canvas so an offscreen render can flush it before borrowing
// the render target (z-order).
currentMainCanvas = vCanvas
inHost.rootNode.draw(vCanvas, null)
inDraw(vCanvas)
vCanvas.finish()
currentMainCanvas = null
}
@@ -53,6 +53,13 @@ import sdl3_ttf.TTF_StringToTag
import sdl3.SDL_IOFromConstMem
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.unit.Density
import freetype.FT_Done_Face
import freetype.FT_Done_FreeType
import freetype.FT_FaceVar
import freetype.FT_Init_FreeType
import freetype.FT_Library
import freetype.FT_LibraryVar
import freetype.FT_New_Memory_Face
// ==================
// MARK: Sdl3TextRenderer (mingwX64 fallback)
@@ -176,6 +183,11 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
// Cap-and-clear (not LRU): lookups stay a single hash op on the hot measure
// path, and re-measuring after a rare full clear is cheap.
private data class WidthKey(val family: String?, val text: String, val fontSize: Int, val variations: String, val style: Int)
// Phase 4: when set, drawText CAPTURES a plain run (text + params) into the display
// list instead of blitting — eviction-safe (replay re-looks-up the cache). Set only
// for plain (no-span) text by Sdl3Canvas capture mode; spanned/icon text bails.
internal var runSink: TextRunSink? = null
private val fWidthCache = mutableMapOf<WidthKey, Int>()
/* Returns false if TTF couldn't init. */
@@ -194,6 +206,9 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
for ((vMem, _) in fFontMem.values) nativeHeap.free(vMem)
fFontMem.clear()
fMissingFamilies.clear()
fCellRatios.clear()
fMetricsLib?.let { FT_Done_FreeType(it) }
fMetricsLib = null
fFreeTypeIcons.destroy()
TTF_Quit()
}
@@ -214,12 +229,66 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
override fun lineHeight(inFontSize: Int, inFontFamily: String?, inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?): Float {
val vFont = getFont(inFontFamily, inFontSize, inFontVariations) ?: return inFontSize * 1.3f
// Skia-aligned cell for TEXT fonts (P3.1): Skia scales the face's hhea
// metrics linearly and the paragraph rounds to nearest, while
// TTF_GetFontHeight reports the grid-fitted CEILED int — +1px at the M3
// body sizes (12, 14), accumulating ~1px per stacked label down every
// parity page. Icon families keep the TTF value: their glyph boxes were
// tuned against it and Skia never renders them.
if (inFontFamily == null || !IconFont.isIconFamily(inFontFamily)) {
cellRatios(inFontFamily)?.let { vRatios ->
val vPhysical = (inFontSize * fDpr).coerceAtLeast(1f)
return (kotlin.math.round(vRatios.cell * vPhysical) / fDpr).coerceAtLeast(1f)
}
}
// TTF_GetFontHeight returns physical pixels (we opened the
// font at fontSize * DPR). Convert back to logical.
return (TTF_GetFontHeight(vFont.reinterpret()).toFloat() / fDpr).coerceAtLeast(1f)
}
}
// ============
// Unrounded face metrics (P3.1) — read hhea ascender/descender/upem once per
// family straight from the font bytes via a metrics-only FreeType library
// (SDL3_ttf exposes only grid-fitted ints). Ratios are size-independent.
private var fMetricsLib: FT_Library? = null
private data class CellRatios(val cell: Float, val ascent: Float)
private val fCellRatios = mutableMapOf<String?, CellRatios?>()
private fun cellRatios(inFamily: String?): CellRatios? {
if (fCellRatios.containsKey(inFamily)) return fCellRatios[inFamily]
val vComputed = computeCellRatios(inFamily)
fCellRatios[inFamily] = vComputed
return vComputed
}
private fun computeCellRatios(inFamily: String?): CellRatios? {
// getFont() has already staged the family bytes into fFontMem (lineHeight
// calls it first); absent bytes = system-font fallback -> no ratios.
val vSlot = fFontMem[inFamily] ?: return null
if (fMetricsLib == null) {
memScoped {
val vLibPtr = alloc<FT_LibraryVar>()
if (FT_Init_FreeType(vLibPtr.ptr).toInt() == 0) fMetricsLib = vLibPtr.value
}
}
val vLib = fMetricsLib ?: return null
memScoped {
val vFacePtr = alloc<FT_FaceVar>()
val vErr = FT_New_Memory_Face(vLib, vSlot.first.reinterpret(), vSlot.second.convert(), 0, vFacePtr.ptr)
if (vErr.toInt() != 0 || vFacePtr.value == null) return null
val vFace = vFacePtr.value!!.pointed
val vUpem = vFace.units_per_EM.toFloat()
val vRatios =
if (vUpem > 0f) CellRatios(
cell = (vFace.ascender - vFace.descender).toFloat() / vUpem,
ascent = vFace.ascender.toFloat() / vUpem,
) else null
FT_Done_Face(vFacePtr.value)
return vRatios
}
}
/* Greedy soft-wrap that mirrors the Skia renderer's algorithm so the
cross-platform behaviour stays identical. Hard lines on '\n'; long
lines split at whitespace, ultra-long words split mid-word. */
@@ -422,6 +491,11 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
// blit each as its own texture at its prefix x. Tabs expand for both the
// prefix measure and the texture so offsets and glyphs agree.
if (inSpans != null) {
// Capture mode: record each styled run as its own TextRun command (like the
// plain path) — replay re-looks-up the per-run segment texture, eviction-safe.
// A run wanting a SpanStyle.background bails to block-replay (the background
// fill isn't a captured command yet); rare — highlight/selection spans.
val vCapture = runSink
// O(spans + line length) style runs (colour + weight + italic +
// background + decoration + size), instead of an O(chars × spans)
// per-character scan.
@@ -441,6 +515,16 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
(if (inRun.italic) kStyleItalic else 0) or
(if (inRun.underline) kStyleUnderline else 0) or
(if (inRun.lineThrough) kStyleStrikethrough else 0)
// Background fills aren't a captured command yet — defer the whole leaf if
// any run wants one (rare: highlight/selection spans). Also keeps the
// background SDL_RenderFillRect below from firing during a capture pass.
if (vCapture != null) {
for (vRun in vRuns) {
if (vRun.background != ComposeColor.Unspecified && vRun.background.alpha > 0f) {
vCapture.markUnsupported(); return
}
}
}
// Line width = sum of each run's width at ITS style (for alignment).
var vLineW = 0f
for (vRun in vRuns) vLineW += measureWidth(runSeg(vRun), runPx(vRun), inFontFamily, runVars(vRun), runStyle(vRun)).toFloat()
@@ -499,17 +583,27 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
}
val vCachedSeg = getOrCreateTexture(inFontFamily, vSeg, vPx, vVars, vStyle)
if (vCachedSeg != null) {
applyTint(vCachedSeg.tex, vRun.color)
val vLogW = vCachedSeg.w / fDpr
val vLogH = vCachedSeg.h / fDpr
val vPenY = vBaselineY - fontAscent(vPx, inFontFamily, vVars)
memScoped {
val vDst = alloc<SDL_FRect>()
vDst.x = snap(vRunX)
vDst.y = snap(vPenY)
vDst.w = vLogW
vDst.h = vLogH
SDL_RenderTexture(vRenderer.reinterpret(), vCachedSeg.tex.reinterpret(), null, vDst.ptr)
if (vCapture != null) {
// Record params (not the texture pointer) — eviction-safe replay.
val vArgb = (vRun.color.a8 shl 24) or (vRun.color.r8 shl 16) or
(vRun.color.g8 shl 8) or vRun.color.b8
vCapture.captureTextRun(
inFontFamily, vSeg, vPx, vVars, vStyle,
snap(vRunX), snap(vPenY), vLogW, vLogH, vArgb,
)
} else {
applyTint(vCachedSeg.tex, vRun.color)
memScoped {
val vDst = alloc<SDL_FRect>()
vDst.x = snap(vRunX)
vDst.y = snap(vPenY)
vDst.w = vLogW
vDst.h = vLogH
SDL_RenderTexture(vRenderer.reinterpret(), vCachedSeg.tex.reinterpret(), null, vDst.ptr)
}
}
}
vRunX += vAdvance
@@ -544,6 +638,18 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
// round(v * dpr) / dpr keeps the blit 1:1, matching Skia's crispness.
fun snap(inV: Float): Float = kotlin.math.round(inV * fDpr) / fDpr
val vSink = runSink
if (vSink != null) {
// Capture the plain run for eviction-safe retained replay — params, NOT the
// texture pointer (the LRU may evict it; replay re-looks-up). Snapped
// layer-local dst; replay maps the origin through the layer affine.
val vArgb = (inColor.a8 shl 24) or (inColor.r8 shl 16) or (inColor.g8 shl 8) or inColor.b8
vSink.captureTextRun(
inFontFamily, vText, inFontSize, inFontVariations, vBaseStyle,
snap(vPenX), snap(vPenY), vLogW, vLogH, vArgb,
)
return
}
memScoped {
val vDst = alloc<SDL_FRect>()
vDst.x = snap(vPenX)
@@ -554,6 +660,81 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
}
}
// Phase 4 replay: blit a captured plain run. Re-looks-up the run texture
// (eviction-safe — re-rasterises if the LRU dropped it), applies the tint, and
// blits at the given DEVICE origin (already mapped through the layer affine) at
// logical size (matching the immediate path: layer scale reaches position, not size).
internal fun blitRun(
inFontFamily: String?,
inText: String,
inFontSize: Int,
inFontVariations: List<FontVariation.Setting>?,
inStyle: Int,
inDeviceX: Float,
inDeviceY: Float,
inLogW: Float,
inLogH: Float,
inColor: ComposeColor,
) {
val vRenderer = backend.renderer ?: return
val vCached = getOrCreateTexture(inFontFamily, inText, inFontSize, inFontVariations, inStyle) ?: return
applyTint(vCached.tex, inColor)
memScoped {
val vDst = alloc<SDL_FRect>()
vDst.x = kotlin.math.round(inDeviceX * fDpr) / fDpr
vDst.y = kotlin.math.round(inDeviceY * fDpr) / fDpr
vDst.w = inLogW
vDst.h = inLogH
SDL_RenderTexture(vRenderer.reinterpret(), vCached.tex.reinterpret(), null, vDst.ptr)
}
}
/* Phase 4 replay: re-draw a captured icon glyph. Mirrors drawNativeText's icon
branch — FreeType glyph first (its own cache re-rasterises on eviction), then the
SDL_ttf fallback for a codepoint the FreeType path can't draw (rare: a glyph
absent from the subset font). Box origin is DEVICE (already mapped through the
layer affine); the glyph centres in the logical box. */
internal fun blitIconRun(
inFontFamily: String,
inText: String,
inFontSize: Int,
inFontVariations: List<FontVariation.Setting>?,
inDeviceBoxX: Float,
inDeviceBoxY: Float,
inBoxW: Float,
inBoxH: Float,
inColor: ComposeColor,
) {
val vRenderer = backend.renderer ?: return
val vDrew = fFreeTypeIcons.drawGlyph(
inSdlRenderer = vRenderer,
inFamily = inFontFamily,
inCodepoint = inText.codePointAtSafe(0),
inPixelSize = inFontSize,
inColor = inColor,
inVariations = inFontVariations ?: emptyList(),
inBoxX = inDeviceBoxX.toInt(),
inBoxY = inDeviceBoxY.toInt(),
inBoxW = inBoxW.toInt(),
inBoxH = inBoxH.toInt(),
inDpr = fDpr,
)
if (vDrew) return
// SDL_ttf fallback — render the codepoint as a normal glyph, centred in the box.
val vCached = getOrCreateTexture(inFontFamily, inText, inFontSize, inFontVariations, 0) ?: return
applyTint(vCached.tex, inColor)
val vLogW = vCached.w / fDpr
val vLogH = vCached.h / fDpr
memScoped {
val vDst = alloc<SDL_FRect>()
vDst.x = kotlin.math.round((inDeviceBoxX + (inBoxW - vLogW) / 2f) * fDpr) / fDpr
vDst.y = kotlin.math.round((inDeviceBoxY + (inBoxH - vLogH) / 2f) * fDpr) / fDpr
vDst.w = vLogW
vDst.h = vLogH
SDL_RenderTexture(vRenderer.reinterpret(), vCached.tex.reinterpret(), null, vDst.ptr)
}
}
// Coverage gamma: alpha' = 255*(alpha/255)^kTextGamma. kTextGamma < 1
// boosts partial coverage so antialiased stems read heavier and smoother
// for light text on a dark background — closer to Skia's gamma-corrected
@@ -0,0 +1,145 @@
package com.compose.sdl.renderer.sdl
import androidx.compose.ui.text.font.FontVariation
// ==================
// MARK: SdlDisplayList — captured draw commands for retained replay
// ==================
/*
Phase 4 (RENDERER.md §3/§13). A display list is a layer's drawing captured
ONCE in layer-local space and replayed under the layer transform — instead of
re-tessellating every frame (DeferredRenderNode) or round-tripping through an
offscreen texture (SdlRenderNode: fixed-resolution soft + timing-nondeterministic).
It is an ORDERED command stream (z-order preserved) mixing three command kinds, each
chosen to avoid the expensive recompute the way upstream skiko's Picture + Skia
caches do, adapted to SDL:
* GeometryBatch — tessellated untextured triangles (layer-local). SDL has no path/
tessellation cache, so we cache the vertices (skiko relies on Skia's GPU path
cache instead). Owned float copy ⇒ always safe.
* TextRun — a "draw this run" command by PARAMETERS, mirroring skiko recording a
glyph-run into a Picture. Replay re-looks-up the run texture via the text
renderer's own per-run LRU (our analog of Skia's glyph atlas); if the cache
evicted it, the lookup re-rasterises — so it NEVER holds a dangling texture
pointer. Covers PLAIN text AND spanned text (one TextRun per styled run); only a
run with a SpanStyle.background still defers (the background fill isn't captured).
* IconRun — a Material Symbols glyph by PARAMETERS; replay re-draws via FreeTypeIcons'
own glyph cache (eviction-safe), same lifetime story as TextRun.
`unsupported` trips on any op not yet capturable (image blits, saveLayer, shadow, a
rounded/generic LAYER clip, alpha/blend/colorFilter/renderEffect, span backgrounds)
so the node falls back to a crisp block-replay — nothing un-captured ever leaks to
the GPU.
*/
internal sealed class DisplayCommand
/* One captured untextured triangle batch in layer-local coords (8 floats/vertex:
pos.xy, color.rgba, tex.xy — Sdl3DrawScope.fVertexData packing). */
internal class GeometryBatch(
val vertexData: FloatArray,
val vertexCount: Int,
) : DisplayCommand()
/* One captured plain text run — replayed by re-looking-up its cached texture
(eviction-safe) and blitting at the transformed origin (glyph size stays logical,
matching the immediate path's "layer scale reaches position, not glyph size"). */
internal class TextRun(
val fontFamily: String?,
val text: String,
val fontSizePx: Int,
val variations: List<FontVariation.Setting>?,
val style: Int,
val x: Float,
val y: Float,
val w: Float,
val h: Float,
val colorArgb: Int,
) : DisplayCommand()
/* One captured icon-font glyph (Material Symbols via FreeType). The box origin is
layer-local (replay maps it through the layer affine); the box size stays logical
(the glyph centres in it, like the immediate icon path). Replay re-draws through
FreeTypeIcons' own glyph cache — eviction-safe (re-rasterises if dropped). */
internal class IconRun(
val fontFamily: String,
val text: String,
val fontSizePx: Int,
val variations: List<FontVariation.Setting>?,
val boxX: Float,
val boxY: Float,
val boxW: Float,
val boxH: Float,
val colorArgb: Int,
) : DisplayCommand()
/* Sink the drawscope + text renderer capture into during a recording. */
internal interface GeometrySink {
fun captureGeometry(vertexData: FloatArray, vertexCount: Int)
}
internal interface TextRunSink {
fun captureTextRun(
fontFamily: String?,
text: String,
fontSizePx: Int,
variations: List<FontVariation.Setting>?,
style: Int,
x: Float,
y: Float,
w: Float,
h: Float,
colorArgb: Int,
)
fun markUnsupported()
}
internal class SdlDisplayList : GeometrySink, TextRunSink {
val commands = ArrayList<DisplayCommand>()
var unsupported = false
override fun captureGeometry(vertexData: FloatArray, vertexCount: Int) {
if (vertexCount <= 0) return
commands.add(GeometryBatch(vertexData.copyOf(vertexCount * kFloatsPerVertex), vertexCount))
}
override fun captureTextRun(
fontFamily: String?,
text: String,
fontSizePx: Int,
variations: List<FontVariation.Setting>?,
style: Int,
x: Float,
y: Float,
w: Float,
h: Float,
colorArgb: Int,
) {
commands.add(TextRun(fontFamily, text, fontSizePx, variations, style, x, y, w, h, colorArgb))
}
override fun markUnsupported() {
unsupported = true
}
/* Icon glyphs are captured directly by the canvas (not via a sink) since the
canvas owns the icon-draw path. Box origin is layer-local; size logical. */
fun captureIconRun(
fontFamily: String,
text: String,
fontSizePx: Int,
variations: List<FontVariation.Setting>?,
boxX: Float,
boxY: Float,
boxW: Float,
boxH: Float,
colorArgb: Int,
) {
commands.add(IconRun(fontFamily, text, fontSizePx, variations, boxX, boxY, boxW, boxH, colorArgb))
}
fun clear() {
commands.clear()
unsupported = false
}
}
@@ -0,0 +1,249 @@
package com.compose.sdl.renderer.sdl
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.DefaultCameraDistance
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import com.compose.sdl.graphics.NativeRenderNode
import com.compose.sdl.graphics.NativeShadowCanvas
import com.compose.sdl.graphics.NativeShapeClipCanvas
import com.compose.sdl.graphics.offscreenRenderer
import com.compose.sdl.graphics.prepareLayerTransformationMatrix
// ==================
// MARK: SdlDisplayListRenderNode — cached-GEOMETRY retained node (Phase 4)
// ==================
/*
The robust retained node. record() captures a leaf's tessellated geometry ONCE (in
layer-local space, via a capture canvas that touches no GPU); drawInto() re-emits
those cached vertices through the layer transform — crisp under ANY transform
(vertices re-transformed, not a resampled texture), bit-exact (no texture round-trip),
and with NO per-frame render-target state (so none of the texture node's timing-
dependent nondeterminism). See RENDERER.md §3/§13.
Captures tessellated geometry, plain + spanned text runs, and Material Symbols icon
glyphs. A leaf whose block draws something not-yet-capturable (image blits, saveLayer,
a rounded/generic layer clip, alpha/blend/colorFilter/renderEffect, span backgrounds)
— or that hosts a child layer — trips the capture list's `unsupported` flag / defer
path and falls back to a crisp block-replay (== DeferredRenderNode): always correct,
just not cached. Image capture is the next step to widen the fast path further.
*/
internal class SdlDisplayListRenderNode : NativeRenderNode {
override var topLeft: IntOffset = IntOffset.Zero
override var size: IntSize = IntSize.Zero
override var pivot: Offset = Offset.Unspecified
override var alpha: Float = 1f
override var scaleX: Float = 1f
override var scaleY: Float = 1f
override var translationX: Float = 0f
override var translationY: Float = 0f
override var rotationX: Float = 0f
override var rotationY: Float = 0f
override var rotationZ: Float = 0f
override var cameraDistance: Float = DefaultCameraDistance
override var shadowElevation: Float = 0f
override var ambientShadowColor: Color = Color.Black
override var spotShadowColor: Color = Color.Black
override var blendMode: BlendMode = BlendMode.SrcOver
override var colorFilter: ColorFilter? = null
override var renderEffect: RenderEffect? = null
override var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
override var clip: Boolean = false
private var clipOutline: Outline? = null
override fun setClipRect(left: Float, top: Float, right: Float, bottom: Float) {
clipOutline = Outline.Rectangle(Rect(left, top, right, bottom))
}
override fun setClipRRect(left: Float, top: Float, right: Float, bottom: Float, radii: FloatArray) {
clipOutline = Outline.Rounded(
RoundRect(
left = left, top = top, right = right, bottom = bottom,
topLeftCornerRadius = CornerRadius(radii[0], radii[1]),
topRightCornerRadius = CornerRadius(radii[2], radii[3]),
bottomRightCornerRadius = CornerRadius(radii[4], radii[5]),
bottomLeftCornerRadius = CornerRadius(radii[6], radii[7]),
),
)
}
override fun setClipPath(path: Path?) {
clipOutline = if (path == null) null else Outline.Generic(path)
}
private var recordedBlock: (DrawScope.() -> Unit)? = null
private var recordedDensity: Density = Density(1f)
private var recordedLayoutDirection: LayoutDirection = LayoutDirection.Ltr
private val drawScope = CanvasDrawScope()
private var displayList: SdlDisplayList? = null
private var deferMode = false // sticky: block drew a not-yet-capturable op / has a child layer
private var sawChild = false // a child layer drew during this node's record
// Main-loop-thread stack of nodes currently recording. A child's drawInto (invoked
// while its parent's block runs) flags the parent as a non-leaf so the parent
// defers — nesting is by DEFER (children composite themselves on the parent's
// block-replay), avoiding by-value baking + GPU leaks into the capture pass.
private companion object {
val fRecordingStack = ArrayList<SdlDisplayListRenderNode>()
}
override fun record(
density: Density,
layoutDirection: LayoutDirection,
size: IntSize,
block: DrawScope.() -> Unit,
) {
recordedDensity = density
recordedLayoutDirection = layoutDirection
this.size = size
recordedBlock = block
displayList = null
val w = size.width
val h = size.height
val renderer = offscreenRenderer as? Sdl3OffscreenRenderer
if (deferMode || w <= 0 || h <= 0 || renderer == null) return
// Capture at identity base CTM ⇒ layer-local geometry. No GPU touched.
val list = SdlDisplayList()
val capture = renderer.createCaptureCanvas(list, Size(w.toFloat(), h.toFloat()))
sawChild = false
fRecordingStack.add(this)
try {
drawScope.draw(recordedDensity, recordedLayoutDirection, capture, Size(w.toFloat(), h.toFloat()), block)
} finally {
fRecordingStack.removeAt(fRecordingStack.size - 1)
capture.finish()
}
if (list.unsupported || sawChild) {
deferMode = true
} else {
displayList = list
}
}
override fun drawInto(canvas: Canvas) {
val block = recordedBlock ?: return
// Drawn during a PARENT's record → flag the parent as a non-leaf and draw
// NOTHING now. The parent will defer to a block-replay that draws us normally;
// this prevents leaking our GPU ops into the target-less capture pass and
// avoids by-value child baking.
if (fRecordingStack.isNotEmpty()) {
fRecordingStack[fRecordingStack.size - 1].sawChild = true
return
}
val w = size.width.toFloat()
val h = size.height.toFloat()
canvas.save()
canvas.translate(topLeft.x.toFloat(), topLeft.y.toFloat())
val hasTransform = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
rotationX != 0f || rotationY != 0f || translationX != 0f || translationY != 0f
if (hasTransform && w > 0f && h > 0f) {
val pivotX = if (pivot.isUnspecified) w / 2f else pivot.x
val pivotY = if (pivot.isUnspecified) h / 2f else pivot.y
val matrix = Matrix()
prepareLayerTransformationMatrix(
matrix = matrix,
pivotX = pivotX, pivotY = pivotY,
translationX = translationX, translationY = translationY,
rotationX = rotationX, rotationY = rotationY, rotationZ = rotationZ,
scaleX = scaleX, scaleY = scaleY, cameraDistance = cameraDistance,
)
canvas.concat(matrix)
}
// Drop shadow — parent-space, before content (drawn on the real canvas).
if (shadowElevation > 0f && w > 0f && h > 0f) {
val outline = clipOutline
if (outline != null) {
(canvas as? NativeShadowCanvas)
?.drawDropShadow(outline, shadowElevation, ambientShadowColor, spotShadowColor)
}
}
val list = displayList
// Layer-level alpha / blend / colorFilter / renderEffect aren't in the captured
// geometry (they're applied at replay); until geo replay folds them in, defer
// those to the crisp block-replay.
//
// A ROUNDED / PATH content clip also forces the block-replay: the fast path
// re-emits raw geometry via SDL_RenderGeometry, which the SDL canvas clips only
// to a RECT (SDL_SetRenderClipRect). Its rounded-corner mask is an offscreen
// realized lazily from drawRect/admitDraw — a path replayBatch bypasses — so a
// rounded layer clip would go UNCUT on the fast path. That was the Carousel
// diff: each item's morphing rounded mask was ignored, so the coloured card
// overflowed its silhouette (wrong shape + apparent horizontal shift). The
// block-replay re-runs the block through drawRect, which realizes the mask
// correctly (== DeferredRenderNode). A plain RECTANGLE clip is honoured by
// SDL_RenderGeometry directly, so it stays on the fast path.
val outline = clipOutline
val hasShapeClip = clip && (outline is Outline.Rounded || outline is Outline.Generic)
val needsCompositing = alpha < 1f || blendMode != BlendMode.SrcOver ||
colorFilter != null || renderEffect != null || hasShapeClip
if (list != null && !needsCompositing && canvas is Sdl3Canvas && w > 0f && h > 0f) {
// GEO fast path: re-emit captured commands (geometry + text) through the
// current transform. Crisp at any scale/rotation, bit-exact, no texture.
// A rectangular content clip narrows the SDL clip rect first (honoured by
// SDL_RenderGeometry), keeping cached geometry inside the layer bounds.
if (clip && outline is Outline.Rectangle) {
canvas.clipRect(outline.rect.left, outline.rect.top, outline.rect.right, outline.rect.bottom)
}
canvas.replayDisplayList(list)
} else {
// Block-replay fallback (== DeferredRenderNode): clip + alpha + re-run block.
if (clip && w > 0f && h > 0f) applyClip(canvas)
val needsAlphaLayer = alpha < 1f && w > 0f && h > 0f
if (needsAlphaLayer) {
canvas.saveLayer(Rect(0f, 0f, w, h), Paint().apply { alpha = this@SdlDisplayListRenderNode.alpha })
}
drawScope.draw(recordedDensity, recordedLayoutDirection, canvas, Size(w, h), block)
if (needsAlphaLayer) canvas.restore()
}
canvas.restore()
}
private fun applyClip(canvas: Canvas) {
when (val outline = clipOutline) {
is Outline.Rectangle -> canvas.clipRect(
outline.rect.left, outline.rect.top, outline.rect.right, outline.rect.bottom,
)
is Outline.Rounded -> {
val shapeClip = canvas as? NativeShapeClipCanvas
if (shapeClip != null) shapeClip.clipRoundRect(outline.roundRect)
else { val p = Path().apply { addRoundRect(outline.roundRect) }; canvas.clipPath(p) }
}
is Outline.Generic -> canvas.clipPath(outline.path)
null -> {}
}
}
override fun close() {
recordedBlock = null
displayList = null
}
}
@@ -0,0 +1,294 @@
package com.compose.sdl.renderer.sdl
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageBitmapConfig
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.DefaultCameraDistance
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import com.compose.sdl.graphics.DrawStats
import com.compose.sdl.graphics.NativeRenderNode
import com.compose.sdl.graphics.NativeShadowCanvas
import com.compose.sdl.graphics.NativeShapeClipCanvas
import com.compose.sdl.graphics.offscreenRenderer
import com.compose.sdl.graphics.prepareLayerTransformationMatrix
// ==================
// MARK: SdlRenderNode — RETAINED (texture-cached) NativeRenderNode
// ==================
/*
The SDL caching node — the perf payoff. Records a layer's drawing ONCE into an
offscreen SDL texture; replay is a cheap textured blit under the layer transform,
NOT a re-tessellation (compare DeferredRenderNode, which re-runs the block every
replay). Gated behind CDN_LAYERCACHE=1 while it's verified; DeferredRenderNode
stays the default.
CORRECT-BY-CONSTRUCTION nesting, no upstream divergence: SDL textures composite
children BY VALUE, unlike skiko's by-reference Picture, so a naive "cache every
layer" would show stale children. Instead we **cache leaves, defer parents**: a
node auto-detects on its first record whether its block drew any CHILD layer (via
the recording stack below). If it did → it's a parent → fall back to replay-the-
block (children composite their own live textures, so no staleness). If it didn't
→ it's a leaf → keep the texture and just blit it henceforth. Leaves (text runs,
icons, shapes — the bulk of static content like the sidebar) stop re-tessellating;
parents cheaply re-composite. See RENDERER.md §4b / §13.
*/
internal class SdlRenderNode : NativeRenderNode {
override var topLeft: IntOffset = IntOffset.Zero
override var size: IntSize = IntSize.Zero
override var pivot: Offset = Offset.Unspecified
override var alpha: Float = 1f
override var scaleX: Float = 1f
override var scaleY: Float = 1f
override var translationX: Float = 0f
override var translationY: Float = 0f
override var rotationX: Float = 0f
override var rotationY: Float = 0f
override var rotationZ: Float = 0f
override var cameraDistance: Float = DefaultCameraDistance
override var shadowElevation: Float = 0f
override var ambientShadowColor: Color = Color.Black
override var spotShadowColor: Color = Color.Black
override var blendMode: BlendMode = BlendMode.SrcOver
override var colorFilter: ColorFilter? = null
override var renderEffect: RenderEffect? = null
override var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
override var clip: Boolean = false
private var clipOutline: Outline? = null
override fun setClipRect(left: Float, top: Float, right: Float, bottom: Float) {
clipOutline = Outline.Rectangle(Rect(left, top, right, bottom))
}
override fun setClipRRect(left: Float, top: Float, right: Float, bottom: Float, radii: FloatArray) {
clipOutline = Outline.Rounded(
RoundRect(
left = left, top = top, right = right, bottom = bottom,
topLeftCornerRadius = CornerRadius(radii[0], radii[1]),
topRightCornerRadius = CornerRadius(radii[2], radii[3]),
bottomRightCornerRadius = CornerRadius(radii[4], radii[5]),
bottomLeftCornerRadius = CornerRadius(radii[6], radii[7]),
),
)
}
override fun setClipPath(path: Path?) {
clipOutline = if (path == null) null else Outline.Generic(path)
}
// ============
// Record
private var recordedBlock: (DrawScope.() -> Unit)? = null
private var recordedDensity: Density = Density(1f)
private var recordedLayoutDirection: LayoutDirection = LayoutDirection.Ltr
private val drawScope = CanvasDrawScope()
// Caching state (auto-detected — see class doc).
private var deferMode = false // true once a child layer was seen: replay the block
private var cached = false // true once a leaf texture is valid
private var bitmap: ImageBitmap? = null
private var bitmapW = 0
private var bitmapH = 0
private var sawChildDuringRecord = false
override fun record(
density: Density,
layoutDirection: LayoutDirection,
size: IntSize,
block: DrawScope.() -> Unit,
) {
recordedDensity = density
recordedLayoutDirection = layoutDirection
this.size = size
recordedBlock = block
cached = false
val w = size.width
val h = size.height
val renderer = offscreenRenderer
// Parents (deferMode) / zero-size / no offscreen support → just keep the block
// and replay it in drawInto (identical to DeferredRenderNode).
if (deferMode || w <= 0 || h <= 0 || renderer == null) return
// Attempt a leaf-cache: render the block into an offscreen texture, watching
// for any child layer being drawn (which flips us to deferMode).
val bmp = ensureBitmap(w, h) ?: return
val target = renderer.createCanvas(bmp) ?: return
sawChildDuringRecord = false
// Watch for image/vector/icon blits: content with partial alpha (a decoded
// image, a tinted icon) does NOT round-trip bit-exact through the 8-bit
// premultiplied offscreen (the heart painterResource rendered visibly wrong),
// so a leaf that draws any image bails to the crisp block-replay. Text/shapes/
// solid fills bump other DrawStats counters and stay cached (pixel-equal).
// (A true bit-exact cache of such content is Phase 4's cached-geometry list,
// which has no texture round-trip.) Costs little: icons are blits, not
// tessellation — the big tessellation cost is text, which still caches.
val imgBlitsBefore = DrawStats.imageBlits
fRecordingStack.add(this)
try {
// Content is recorded at layer-local origin; the transform is applied at
// replay (blit). Clip is baked into the texture so rounded corners are
// transparent in the cached pixels.
if (clip) applyClip(target)
drawScope.draw(recordedDensity, recordedLayoutDirection, target, Size(w.toFloat(), h.toFloat()), block)
} finally {
fRecordingStack.removeAt(fRecordingStack.size - 1)
(target as? com.compose.sdl.graphics.NativeFinishableCanvas)?.finish()
}
val drewImage = DrawStats.imageBlits > imgBlitsBefore
if (sawChildDuringRecord || drewImage) {
// Not a cacheable leaf (has a child layer, or drew an image) — abandon
// caching for this node's lifetime and replay the block instead.
deferMode = true
releaseBitmap()
} else {
cached = true
}
}
private fun ensureBitmap(w: Int, h: Int): ImageBitmap? {
val renderer = offscreenRenderer ?: return null
if (bitmap == null || bitmapW != w || bitmapH != h) {
releaseBitmap()
bitmap = renderer.createImageBitmap(w, h, ImageBitmapConfig.Argb8888, true, ColorSpaces.Srgb)
bitmapW = w
bitmapH = h
}
return bitmap
}
private fun releaseBitmap() {
(bitmap as? SdlImageBitmap)?.close()
bitmap = null
bitmapW = 0
bitmapH = 0
}
// ============
// Replay
override fun drawInto(canvas: Canvas) {
val block = recordedBlock ?: return
val w = size.width.toFloat()
val h = size.height.toFloat()
// If I am being drawn while a parent is recording, that parent has a child
// layer (me) → it must NOT leaf-cache. (Detected here rather than in the
// parent so it works for any nesting depth.)
if (fRecordingStack.isNotEmpty()) fRecordingStack[fRecordingStack.size - 1].sawChildDuringRecord = true
canvas.save()
canvas.translate(topLeft.x.toFloat(), topLeft.y.toFloat())
val hasTransform = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
rotationX != 0f || rotationY != 0f || translationX != 0f || translationY != 0f
if (hasTransform && w > 0f && h > 0f) {
val pivotX = if (pivot.isUnspecified) w / 2f else pivot.x
val pivotY = if (pivot.isUnspecified) h / 2f else pivot.y
val matrix = Matrix()
prepareLayerTransformationMatrix(
matrix = matrix,
pivotX = pivotX, pivotY = pivotY,
translationX = translationX, translationY = translationY,
rotationX = rotationX, rotationY = rotationY, rotationZ = rotationZ,
scaleX = scaleX, scaleY = scaleY, cameraDistance = cameraDistance,
)
canvas.concat(matrix)
}
// Drop shadow — parent-space, before content (never baked into the texture).
if (shadowElevation > 0f && w > 0f && h > 0f) {
val outline = clipOutline
if (outline != null) {
(canvas as? NativeShadowCanvas)
?.drawDropShadow(outline, shadowElevation, ambientShadowColor, spotShadowColor)
}
}
// Blit the cached texture ONLY for an opaque, untransformed, effect-free leaf
// — the exact subset that a plain blit reproduces pixel-equal (verified). We
// bail to a crisp block-replay when:
// * scale/rotation — a fixed-resolution texture would bilinear-resample (soft);
// * alpha<1 / blendMode / colorFilter / renderEffect — the blit can't
// reproduce the offscreen compositing (premultiplied-alpha blit differs).
// So caching is strictly non-regressing: the static-content case (the win)
// blits; everything else replays exactly as before. Phase 4's cached-geometry
// display list would widen this (crisp under transform, real compositing).
val hasScaleOrRotation = scaleX != 1f || scaleY != 1f ||
rotationZ != 0f || rotationX != 0f || rotationY != 0f
val needsCompositing = alpha < 1f || blendMode != BlendMode.SrcOver ||
colorFilter != null || renderEffect != null
val bmp = bitmap
if (cached && bmp != null && !hasScaleOrRotation && !needsCompositing && w > 0f && h > 0f) {
// Leaf fast path: blit the cached texture (clip already baked in).
canvas.drawImageRect(
image = bmp,
srcOffset = IntOffset.Zero,
srcSize = IntSize(bitmapW, bitmapH),
dstOffset = IntOffset.Zero,
dstSize = IntSize(bitmapW, bitmapH),
paint = Paint(),
)
} else {
// Parent / uncached fallback: replay the block (== DeferredRenderNode).
if (clip && w > 0f && h > 0f) applyClip(canvas)
val needsAlphaLayer = alpha < 1f && w > 0f && h > 0f
if (needsAlphaLayer) {
canvas.saveLayer(Rect(0f, 0f, w, h), Paint().apply { alpha = this@SdlRenderNode.alpha })
}
drawScope.draw(recordedDensity, recordedLayoutDirection, canvas, Size(w, h), block)
if (needsAlphaLayer) canvas.restore()
}
canvas.restore()
}
private fun applyClip(canvas: Canvas) {
when (val outline = clipOutline) {
is Outline.Rectangle -> canvas.clipRect(
outline.rect.left, outline.rect.top, outline.rect.right, outline.rect.bottom,
)
is Outline.Rounded -> {
val shapeClip = canvas as? NativeShapeClipCanvas
if (shapeClip != null) shapeClip.clipRoundRect(outline.roundRect)
else { val p = Path().apply { addRoundRect(outline.roundRect) }; canvas.clipPath(p) }
}
is Outline.Generic -> canvas.clipPath(outline.path)
null -> {}
}
}
override fun close() {
recordedBlock = null
releaseBitmap()
}
// The main-loop-thread stack of nodes currently recording. A child's drawInto
// (invoked while its parent's block runs) flags the parent as a non-leaf.
private companion object {
val fRecordingStack = ArrayList<SdlRenderNode>()
}
}
@@ -0,0 +1,604 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// DERIVED (copy + edit) from compose-multiplatform-core -- MANUAL VENDOR, NON-IDEMPOTENT.
// VENDOR-BASE: compose/ui/ui-graphics/src/skikoMain/kotlin/androidx/compose/ui/graphics/SkiaBackedCanvas.skiko.kt @ v1.12.0-beta03+dev4483
// ONLY change vs upstream: this class also implements the port draw contracts
// (NativeTextCanvas / NativePainterCanvas / NativeShadowCanvas / NativeFinishableCanvas) so
// the port text engine (SkiaTextRenderer, P3.1 metrics) + resource-image cache + elevation
// shadows keep working on the Skia leg, which now draws through THIS canvas instead of the
// retired project SkiaCanvas. Bridged methods forward to `skiaLeafDrawer` using
// `internalSkiaCanvas`. See RENDERER.md (B6.1, section 4).
package androidx.compose.ui.graphics
import androidx.compose.runtime.InternalComposeApi
import com.compose.sdl.renderer.skia.skiaLeafDrawer
import org.jetbrains.skia.FilterBlurMode
import org.jetbrains.skia.MaskFilter
import org.jetbrains.skia.RRect as SkRRect
import org.jetbrains.skia.Color as SkColorNs
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastForEach
import org.jetbrains.skia.Canvas as SkCanvas
import org.jetbrains.skia.ClipMode as SkClipMode
import org.jetbrains.skia.CubicResampler
import org.jetbrains.skia.FilterMipmap
import org.jetbrains.skia.FilterMode
import org.jetbrains.skia.Image
import org.jetbrains.skia.Matrix44
import org.jetbrains.skia.MipmapMode
import org.jetbrains.skia.Paint as SkPaint
import org.jetbrains.skia.SamplingMode
import org.jetbrains.skia.impl.use
@Deprecated(
message = "Use direct reference to org.jetbrains.skia.Canvas instead of typealias",
replaceWith = ReplaceWith("Canvas", "org.jetbrains.skia.Canvas"),
level = DeprecationLevel.ERROR,
)
actual typealias NativeCanvas = SkCanvas
internal actual fun ActualCanvas(image: ImageBitmap): Canvas {
val skiaBitmap = image.asSkiaBitmap()
require(!skiaBitmap.isImmutable) {
"Cannot draw on immutable ImageBitmap"
}
return SkiaBackedCanvas(SkCanvas(skiaBitmap))
}
/**
* Convert the [org.jetbrains.skia.Canvas] instance into a Compose-compatible Canvas
*/
fun SkCanvas.asComposeCanvas(): Canvas = SkiaBackedCanvas(this)
/**
* Provides access to the underlying [org.jetbrains.skia.Canvas] instance.
*
* It throws an exception if accessed on unsupported types.
*/
val Canvas.skiaCanvas: SkCanvas
get() {
requirePrecondition(this is SkiaBackedCanvas) {
"Extracting skia canvas reference is only supported from androidx.compose.ui.graphics.SkiaBackedCanvas instances but received ${this::class}"
}
return internalSkiaCanvas
}
@Deprecated(
message = "Naming alignment to avoid ambiguity: use [Canvas.skiaCanvas] extension instead",
replaceWith = ReplaceWith("skiaCanvas", "androidx.compose.ui.graphics.skiaCanvas"),
)
val Canvas.nativeCanvas: SkCanvas
get() = skiaCanvas
// This was added for internal usage from old render layers (another submodule),
// but wasn't properly marked as internal. Keep it as deprecated for some time to be safe.
@InternalComposeApi
@Deprecated(
level = DeprecationLevel.ERROR,
message = "This API is not supposed to be used outside of Compose UI package"
)
var Canvas.alphaMultiplier: Float
get() = (this as SkiaBackedCanvas).alphaMultiplier
set(value) { (this as SkiaBackedCanvas).alphaMultiplier = value }
internal class SkiaBackedCanvas(
internal val internalSkiaCanvas: SkCanvas,
) : Canvas,
com.compose.sdl.text.NativeTextCanvas,
com.compose.sdl.graphics.NativePainterCanvas,
com.compose.sdl.graphics.NativeShadowCanvas,
com.compose.sdl.graphics.NativeFinishableCanvas {
internal var alphaMultiplier: Float = 1.0f
private fun Paint.asSkiaPaintWithAppliedAlphaMultiplier(): SkPaint {
require(this is SkiaBackedPaint)
this.alphaMultiplier = this@SkiaBackedCanvas.alphaMultiplier
return internalSkiaPaint
}
override fun save() {
internalSkiaCanvas.save()
}
override fun restore() {
internalSkiaCanvas.restore()
}
override fun saveLayer(bounds: Rect, paint: Paint) {
internalSkiaCanvas.saveLayer(
bounds.left,
bounds.top,
bounds.right,
bounds.bottom,
paint.asSkiaPaintWithAppliedAlphaMultiplier()
)
}
override fun translate(dx: Float, dy: Float) {
internalSkiaCanvas.translate(dx, dy)
}
override fun scale(sx: Float, sy: Float) {
internalSkiaCanvas.scale(sx, sy)
}
override fun rotate(degrees: Float) {
internalSkiaCanvas.rotate(degrees)
}
override fun skew(sx: Float, sy: Float) {
internalSkiaCanvas.skew(sx, sy)
}
override fun concat(matrix: Matrix) {
if (!matrix.isIdentity()) {
internalSkiaCanvas.concat(matrix.toSkia())
}
}
override fun clipRect(left: Float, top: Float, right: Float, bottom: Float, clipOp: ClipOp) {
val antiAlias = true
internalSkiaCanvas.clipRect(
left = left,
top = top,
right = right,
bottom = bottom,
mode = clipOp.toSkia(),
antiAlias = antiAlias
)
}
@OptIn(InternalComposeUiApi::class)
override fun clipPath(path: Path, clipOp: ClipOp) {
val antiAlias = true
internalSkiaCanvas.clipPath(path.materializeSkiaPath(), clipOp.toSkia(), antiAlias)
}
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
internalSkiaCanvas.drawLine(
x0 = p1.x,
y0 = p1.y,
x1 = p2.x,
y1 = p2.y,
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
)
}
override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
internalSkiaCanvas.drawRect(
left = left,
top = top,
right = right,
bottom = bottom,
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
)
}
override fun drawRoundRect(
left: Float,
top: Float,
right: Float,
bottom: Float,
radiusX: Float,
radiusY: Float,
paint: Paint
) {
internalSkiaCanvas.drawRRect(
left = left,
top = top,
right = right,
bottom = bottom,
radii = floatArrayOf(radiusX, radiusY),
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
)
}
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
internalSkiaCanvas.drawOval(
left = left,
top = top,
right = right,
bottom = bottom,
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
)
}
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
internalSkiaCanvas.drawCircle(
x = center.x,
y = center.y,
radius = radius,
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
)
}
override fun drawArc(
left: Float,
top: Float,
right: Float,
bottom: Float,
startAngle: Float,
sweepAngle: Float,
useCenter: Boolean,
paint: Paint
) {
internalSkiaCanvas.drawArc(
left = left,
top = top,
right = right,
bottom = bottom,
startAngle = startAngle,
sweepAngle = sweepAngle,
includeCenter = useCenter,
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
)
}
@OptIn(InternalComposeUiApi::class)
override fun drawPath(path: Path, paint: Paint) {
internalSkiaCanvas.drawPath(
path = path.materializeSkiaPath(),
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
)
}
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
drawImageRect(
image = image,
srcLeft = 0f,
srcTop = 0f,
srcRight = image.width.toFloat(),
srcBottom = image.height.toFloat(),
dstLeft = topLeftOffset.x,
dstTop = topLeftOffset.y,
dstRight = topLeftOffset.x + image.width.toFloat(),
dstBottom = topLeftOffset.y + image.height.toFloat(),
paint = paint,
)
}
override fun drawImageRect(
image: ImageBitmap,
srcOffset: IntOffset,
srcSize: IntSize,
dstOffset: IntOffset,
dstSize: IntSize,
paint: Paint
) {
drawImageRect(
image = image,
srcLeft = srcOffset.x.toFloat(),
srcTop = srcOffset.y.toFloat(),
srcRight = srcOffset.x.toFloat() + srcSize.width.toFloat(),
srcBottom = srcOffset.y.toFloat() + srcSize.height.toFloat(),
dstLeft = dstOffset.x.toFloat(),
dstTop = dstOffset.y.toFloat(),
dstRight = dstOffset.x.toFloat() + dstSize.width.toFloat(),
dstBottom = dstOffset.y.toFloat() + dstSize.height.toFloat(),
paint = paint,
)
}
// TODO(demin): probably this method should be in the common Canvas
private fun drawImageRect(
image: ImageBitmap,
srcLeft: Float,
srcTop: Float,
srcRight: Float,
srcBottom: Float,
dstLeft: Float,
dstTop: Float,
dstRight: Float,
dstBottom: Float,
paint: Paint
) {
val bitmap = image.asSkiaBitmap()
Image.makeFromBitmap(bitmap).use { skiaImage ->
internalSkiaCanvas.drawImageRect(
image = skiaImage,
srcLeft = srcLeft,
srcTop = srcTop,
srcRight = srcRight,
srcBottom = srcBottom,
dstLeft = dstLeft,
dstTop = dstTop,
dstRight = dstRight,
dstBottom = dstBottom,
samplingMode = paint.filterQuality.toSkia(),
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
strict = true,
)
}
}
override fun drawPoints(pointMode: PointMode, points: List<Offset>, paint: Paint) {
when (pointMode) {
// Draw a line between each pair of points, each point has at most one line
// If the number of points is odd, then the last point is ignored.
PointMode.Lines -> drawLines(points, paint, 2)
// Connect each adjacent point with a line
PointMode.Polygon -> drawLines(points, paint, 1)
// Draw a point at each provided coordinate
PointMode.Points -> drawPoints(points, paint)
}
}
override fun enableZ() = Unit
override fun disableZ() = Unit
private fun drawPoints(points: List<Offset>, paint: Paint) {
val skiaPaint = paint.asSkiaPaintWithAppliedAlphaMultiplier()
points.fastForEach { point ->
internalSkiaCanvas.drawPoint(
x = point.x,
y = point.y,
paint = skiaPaint,
)
}
}
/**
* Draw lines connecting points based on the corresponding step.
*
* ex. 3 points with a step of 1 would draw 2 lines between the first and second points
* and another between the second and third
*
* ex. 4 points with a step of 2 would draw 2 lines between the first and second and another
* between the third and fourth. If there is an odd number of points, the last point is
* ignored
*
* @see drawRawLines
*/
private fun drawLines(points: List<Offset>, paint: Paint, stepBy: Int) {
if (points.size >= 2) {
val skiaPaint = paint.asSkiaPaintWithAppliedAlphaMultiplier()
var i = 0
while (i < points.size - 1) {
val p1 = points[i]
val p2 = points[i + 1]
internalSkiaCanvas.drawLine(p1.x, p1.y, p2.x, p2.y, skiaPaint)
i += stepBy
}
}
}
/**
* @throws IllegalArgumentException if a non even number of points is provided
*/
override fun drawRawPoints(pointMode: PointMode, points: FloatArray, paint: Paint) {
if (points.size % 2 != 0) {
throw IllegalArgumentException("points must have an even number of values")
}
when (pointMode) {
PointMode.Lines -> drawRawLines(points, paint, 2)
PointMode.Polygon -> drawRawLines(points, paint, 1)
PointMode.Points -> drawRawPoints(points, paint, 2)
}
}
private fun drawRawPoints(points: FloatArray, paint: Paint, stepBy: Int) {
if (points.size % 2 == 0) {
val skiaPaint = paint.asSkiaPaintWithAppliedAlphaMultiplier()
var i = 0
while (i < points.size - 1) {
val x = points[i]
val y = points[i + 1]
internalSkiaCanvas.drawPoint(x, y, skiaPaint)
i += stepBy
}
}
}
/**
* Draw lines connecting points based on the corresponding step. The points are interpreted
* as x, y coordinate pairs in alternating index positions
*
* ex. 3 points with a step of 1 would draw 2 lines between the first and second points
* and another between the second and third
*
* ex. 4 points with a step of 2 would draw 2 lines between the first and second and another
* between the third and fourth. If there is an odd number of points, the last point is
* ignored
*
* @see drawLines
*/
private fun drawRawLines(points: FloatArray, paint: Paint, stepBy: Int) {
// Float array is treated as alternative set of x and y coordinates
// x1, y1, x2, y2, x3, y3, ... etc.
if (points.size >= 4 && points.size % 2 == 0) {
val skiaPaint = paint.asSkiaPaintWithAppliedAlphaMultiplier()
var i = 0
while (i < points.size - 3) {
val x1 = points[i]
val y1 = points[i + 1]
val x2 = points[i + 2]
val y2 = points[i + 3]
internalSkiaCanvas.drawLine(x1, y1, x2, y2, skiaPaint)
i += stepBy * 2
}
}
}
override fun drawVertices(vertices: Vertices, blendMode: BlendMode, paint: Paint) {
internalSkiaCanvas.drawVertices(
vertexMode = vertices.vertexMode.toSkiaVertexMode(),
positions = vertices.positions,
colors = vertices.colors,
texCoords = vertices.textureCoordinates,
indices = vertices.indices,
blendMode = blendMode.toSkia(),
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
)
}
private fun ClipOp.toSkia() = when (this) {
ClipOp.Difference -> SkClipMode.DIFFERENCE
ClipOp.Intersect -> SkClipMode.INTERSECT
else -> SkClipMode.INTERSECT
}
private fun Matrix.toSkia() = Matrix44(
this[0, 0],
this[1, 0],
this[2, 0],
this[3, 0],
this[0, 1],
this[1, 1],
this[2, 1],
this[3, 1],
this[0, 2],
this[1, 2],
this[2, 2],
this[3, 2],
this[0, 3],
this[1, 3],
this[2, 3],
this[3, 3]
)
// These constants are chosen to correspond the old implementation of SkFilterQuality:
// https://github.com/google/skia/blob/1f193df9b393d50da39570dab77a0bb5d28ec8ef/src/image/SkImage.cpp#L809
// https://github.com/google/skia/blob/1f193df9b393d50da39570dab77a0bb5d28ec8ef/include/core/SkSamplingOptions.h#L86
private fun FilterQuality.toSkia(): SamplingMode = when (this) {
FilterQuality.Low -> FilterMipmap(FilterMode.LINEAR, MipmapMode.NONE)
FilterQuality.Medium -> FilterMipmap(FilterMode.LINEAR, MipmapMode.NEAREST)
FilterQuality.High -> CubicResampler(1 / 3.0f, 1 / 3.0f)
else -> FilterMipmap(FilterMode.NEAREST, MipmapMode.NONE)
}
// ============
// Port draw contracts (B6.1) -- forward to the port renderers via skiaLeafDrawer.
override fun finish() { /* Skia save/restore is balanced per call */ }
override fun drawNativeText(
inText: String,
inSpans: List<androidx.compose.ui.text.AnnotatedString.Range<androidx.compose.ui.text.SpanStyle>>?,
inX: Float,
inY: Float,
inBoxWidth: Float,
inBoxHeight: Float,
inColor: Color,
inFontSizePx: Int,
inTextAlign: androidx.compose.ui.text.style.TextAlign,
inSoftWrap: Boolean,
inFontFamily: String?,
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?,
inBaseItalic: Boolean,
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
inLineHeightPx: Float,
inTrimFirstLine: Boolean,
) {
val drawer = skiaLeafDrawer ?: return
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
val vLineThrough = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.LineThrough) == true
drawer.textRenderer.drawText(
inCanvas = internalSkiaCanvas,
inText = inText,
inX = inX,
inY = inY,
inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = inBoxHeight.toInt(),
inColor = inColor,
inFontSize = inFontSizePx,
inAlign = inTextAlign,
inSoftWrap = inSoftWrap,
inFontFamily = inFontFamily,
inFontVariations = inFontVariations,
inSpans = inSpans,
inBaseItalic = inBaseItalic,
inBaseUnderline = vUnderline,
inBaseLineThrough = vLineThrough,
inLineHeightPx = inLineHeightPx,
inTrimFirstLine = inTrimFirstLine,
)
}
override fun drawNativePainter(
inResourcePath: String,
inKind: com.compose.sdl.res.ResourceKind,
inX: Float,
inY: Float,
inWidth: Float,
inHeight: Float,
inContentScale: androidx.compose.ui.layout.ContentScale,
inAlpha: Float,
) {
val drawer = skiaLeafDrawer ?: return
drawer.imageCache.draw(
inCanvas = internalSkiaCanvas,
inPath = inResourcePath,
inKind = inKind,
inX = inX,
inY = inY,
inW = inWidth,
inH = inHeight,
inScale = inContentScale,
inAlpha = inAlpha,
)
}
@OptIn(InternalComposeUiApi::class)
override fun drawDropShadow(
inOutline: Outline,
inElevationPx: Float,
inAmbientColor: Color,
inSpotColor: Color,
) {
if (inElevationPx <= 0f) return
val vOffsetY = inElevationPx * 0.4f
val vSpot = inSpotColor.copy(alpha = 0.28f * inSpotColor.alpha)
val vPaint = SkPaint().apply {
color = SkColorNs.makeARGB(
(vSpot.alpha * 255f).toInt().coerceIn(0, 255),
(vSpot.red * 255f).toInt().coerceIn(0, 255),
(vSpot.green * 255f).toInt().coerceIn(0, 255),
(vSpot.blue * 255f).toInt().coerceIn(0, 255),
)
maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, sigma = inElevationPx * 0.5f + 0.5f)
}
when (inOutline) {
is Outline.Rectangle -> {
val r = inOutline.rect
internalSkiaCanvas.drawRRect(SkRRect.makeLTRB(r.left, r.top + vOffsetY, r.right, r.bottom + vOffsetY, 0f), vPaint)
}
is Outline.Rounded -> {
val rr = inOutline.roundRect
internalSkiaCanvas.drawRRect(SkRRect.makeLTRB(rr.left, rr.top + vOffsetY, rr.right, rr.bottom + vOffsetY, rr.topLeftCornerRadius.x), vPaint)
}
is Outline.Generic -> {
internalSkiaCanvas.save()
internalSkiaCanvas.translate(0f, vOffsetY)
internalSkiaCanvas.drawPath(inOutline.path.materializeSkiaPath(), vPaint)
internalSkiaCanvas.restore()
}
}
vPaint.close()
}
}
@@ -0,0 +1,183 @@
@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE", "LESS_VISIBLE_TYPE_ACCESS_IN_INLINE")
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// DERIVED (copy + edit) from compose-multiplatform-core -- MANUAL VENDOR, NON-IDEMPOTENT.
// VENDOR-BASE: compose/ui/ui-graphics/src/skikoMain/kotlin/androidx/compose/ui/graphics/SkiaImageAsset.skiko.kt @ v1.12.0-beta03+dev4483
// FLATTENED for our single-native-source-set layout: upstream splits Image.toBitmap /
// ByteArray.putBytesInto into expect (here) + actual (skikoExcludingWebMain / nativeMain).
// Those collapse into the same skikoRenderer source set here, which is illegal, so the two
// bodies are inlined (impls from Actuals.skikoExcludingWeb.kt + NativeImageAsset.native.kt).
// Everything else is byte-for-byte upstream. See RENDERER.md B6.1.
package androidx.compose.ui.graphics
import androidx.compose.ui.graphics.colorspace.ColorSpace
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.posix.memcpy
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import kotlin.math.abs
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorInfo
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageInfo
/**
* Create an [ImageBitmap] from the given [Bitmap]. Note this does
* not create a copy of the original [Bitmap] and changes to it
* will modify the returned [ImageBitmap]
*/
fun Bitmap.asComposeImageBitmap(): ImageBitmap = SkiaBackedImageBitmap(this)
/**
* Create an [ImageBitmap] from the given [Image].
*/
fun Image.toComposeImageBitmap(): ImageBitmap = SkiaBackedImageBitmap(toBitmap())
// Split upstream into expect/actual (fast non-web impl); inlined here (flat source set).
internal fun Image.toBitmap(): Bitmap {
val bitmap = Bitmap()
bitmap.allocPixels(ImageInfo.makeN32(width, height, ColorAlphaType.PREMUL))
val canvas = org.jetbrains.skia.Canvas(bitmap)
canvas.drawImage(this, 0f, 0f)
bitmap.setImmutable()
return bitmap
}
internal actual fun ActualImageBitmap(
width: Int,
height: Int,
config: ImageBitmapConfig,
hasAlpha: Boolean,
colorSpace: ColorSpace
): ImageBitmap {
require(width > 0 && height > 0) { "width and height must be > 0" }
val colorType = config.toSkiaColorType()
val alphaType = if (hasAlpha) ColorAlphaType.PREMUL else ColorAlphaType.OPAQUE
val skiaColorSpace = colorSpace.toSkiaColorSpace()
val colorInfo = ColorInfo(colorType, alphaType, skiaColorSpace)
val imageInfo = ImageInfo(colorInfo, width, height)
val bitmap = Bitmap()
bitmap.allocPixels(imageInfo)
return SkiaBackedImageBitmap(bitmap)
}
/**
* Obtain a reference to the [org.jetbrains.skia.Bitmap]
*
* @Throws UnsupportedOperationException if this [ImageBitmap] is not backed by an
* org.jetbrains.skia.Image
*/
fun ImageBitmap.asSkiaBitmap(): Bitmap =
when (this) {
is SkiaBackedImageBitmap -> bitmap
else -> throw UnsupportedOperationException("Unable to obtain org.jetbrains.skia.Image")
}
private class SkiaBackedImageBitmap(val bitmap: Bitmap) : ImageBitmap {
override val colorSpace = bitmap.colorSpace.toComposeColorSpace()
override val config = bitmap.colorType.toComposeConfig()
override val hasAlpha = !bitmap.isOpaque
override val height get() = bitmap.height
override val width get() = bitmap.width
override fun prepareToDraw() = Unit
override fun readPixels(
buffer: IntArray,
startX: Int,
startY: Int,
width: Int,
height: Int,
bufferOffset: Int,
stride: Int
) {
// similar to https://cs.android.com/android/platform/superproject/+/42c50042d1f05d92ecc57baebe3326a57aeecf77:frameworks/base/graphics/java/android/graphics/Bitmap.java;l=2007
val lastScanline: Int = bufferOffset + (height - 1) * stride
require(startX >= 0 && startY >= 0)
require(width > 0 && startX + width <= this.width)
require(height > 0 && startY + height <= this.height)
require(abs(stride) >= width)
require(bufferOffset >= 0 && bufferOffset + width <= buffer.size)
require(lastScanline >= 0 && lastScanline + width <= buffer.size)
// similar to https://cs.android.com/android/platform/superproject/+/9054ca2b342b2ea902839f629e820546d8a2458b:frameworks/base/libs/hwui/jni/Bitmap.cpp;l=898;bpv=1
val colorInfo = ColorInfo(
ColorType.BGRA_8888,
ColorAlphaType.UNPREMUL,
org.jetbrains.skia.ColorSpace.sRGB
)
val imageInfo = ImageInfo(colorInfo, width, height)
val bytesPerPixel = 4
val bytes = bitmap.readPixels(imageInfo, stride * bytesPerPixel, startX, startY)!!
bytes.putBytesInto(buffer, bufferOffset, bytes.size / bytesPerPixel)
}
}
@OptIn(ExperimentalForeignApi::class)
internal fun ByteArray.putBytesInto(array: IntArray, offset: Int, length: Int) {
this.usePinned { bytes ->
array.usePinned { ints ->
// Assuming little endian.
memcpy(ints.addressOf(offset), bytes.addressOf(0), (length * 4).toULong())
}
}
}
// TODO(demin): [API] maybe we should use:
// `else -> throw UnsupportedOperationException()`
// in toSkiaColorType/toComposeConfig/toComposeColorSpace/toSkiaColorSpace
// see [https://android-review.googlesource.com/c/platform/frameworks/support/+/1429835/comment/c219501b_63c3d1fe/]
private fun ImageBitmapConfig.toSkiaColorType() = when (this) {
ImageBitmapConfig.Argb8888 -> ColorType.N32
ImageBitmapConfig.Alpha8 -> ColorType.ALPHA_8
ImageBitmapConfig.Rgb565 -> ColorType.RGB_565
ImageBitmapConfig.F16 -> ColorType.RGBA_F16
else -> ColorType.N32
}
private fun ColorType.toComposeConfig() = when (this) {
ColorType.N32 -> ImageBitmapConfig.Argb8888
ColorType.ALPHA_8 -> ImageBitmapConfig.Alpha8
ColorType.RGB_565 -> ImageBitmapConfig.Rgb565
ColorType.RGBA_F16 -> ImageBitmapConfig.F16
else -> ImageBitmapConfig.Argb8888
}
private fun org.jetbrains.skia.ColorSpace?.toComposeColorSpace(): ColorSpace {
return when (this) {
org.jetbrains.skia.ColorSpace.sRGB -> ColorSpaces.Srgb
org.jetbrains.skia.ColorSpace.sRGBLinear -> ColorSpaces.LinearSrgb
org.jetbrains.skia.ColorSpace.displayP3 -> ColorSpaces.DisplayP3
else -> ColorSpaces.Srgb
}
}
// TODO(demin): support all color spaces.
// to do this we need to implement SkColorSpace::MakeRGB in skia
private fun ColorSpace.toSkiaColorSpace(): org.jetbrains.skia.ColorSpace {
return when (this) {
ColorSpaces.Srgb -> org.jetbrains.skia.ColorSpace.sRGB
ColorSpaces.LinearSrgb -> org.jetbrains.skia.ColorSpace.sRGBLinear
ColorSpaces.DisplayP3 -> org.jetbrains.skia.ColorSpace.displayP3
else -> org.jetbrains.skia.ColorSpace.sRGB
}
}
@@ -0,0 +1,13 @@
package com.compose.sdl.graphics
import androidx.compose.ui.graphics.GraphicsContext
import androidx.compose.ui.graphics.SkiaGraphicsContext
// ==================
// MARK: createGraphicsContext — Skia renderer actual (B6.2)
// ==================
/* The Skia leg uses upstream's SkiaGraphicsContext, which owns a skiko
RenderNodeContext and creates upstream GraphicsLayer(skiko.RenderNode) instances.
See RENDERER.md (B6.2, section 4). */
internal actual fun createGraphicsContext(): GraphicsContext = SkiaGraphicsContext()
@@ -1,503 +0,0 @@
package com.compose.sdl.renderer.skia
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.LinearGradient
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.Path as ComposePath
import androidx.compose.ui.graphics.RadialGradient
import androidx.compose.ui.graphics.SkiaBackedPath
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.SweepGradient
import androidx.compose.ui.graphics.TileMode as ComposeTileMode
import androidx.compose.ui.graphics.asSkiaColorFilter
import androidx.compose.ui.graphics.asSkiaPath
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.graphics.StrokeCap as ComposeStrokeCap
import androidx.compose.ui.graphics.Vertices
import androidx.compose.ui.graphics.toSkia
import com.compose.sdl.graphics.PathCommand
import com.compose.sdl.graphics.ProjectPath
import com.compose.sdl.graphics.a8
import com.compose.sdl.graphics.b8
import com.compose.sdl.graphics.g8
import com.compose.sdl.graphics.gradientCenter
import com.compose.sdl.graphics.gradientColors
import com.compose.sdl.graphics.gradientEnd
import com.compose.sdl.graphics.gradientRadius
import com.compose.sdl.graphics.gradientStart
import com.compose.sdl.graphics.gradientStops
import com.compose.sdl.graphics.gradientTileMode
import com.compose.sdl.graphics.r8
import org.jetbrains.skia.Canvas as SkCanvas
import org.jetbrains.skia.ClipMode
import org.jetbrains.skia.Color as SkColor
import org.jetbrains.skia.Color4f
import org.jetbrains.skia.FilterTileMode
import org.jetbrains.skia.Gradient
import org.jetbrains.skia.Paint as SkPaint
import org.jetbrains.skia.PaintMode
import org.jetbrains.skia.PaintStrokeCap
import org.jetbrains.skia.Path as SkPath
import org.jetbrains.skia.PathBuilder
import org.jetbrains.skia.Point
import org.jetbrains.skia.Shader as SkShader
import org.jetbrains.skia.RRect
import org.jetbrains.skia.Rect as SkRect
// ==================
// MARK: SkiaCanvas
// ==================
/*
Phase 9 — Skia implementation of `androidx.compose.ui.graphics.Canvas`, mirror of
`Sdl3Canvas`. Wraps a live `org.jetbrains.skia.Canvas` (from the active
SkiaBridge — Metal / OpenGL / CPU raster) and translates every upstream draw
call into a Skia primitive.
Text + painter bridges delegate to SkiaTextRenderer / SkiaImageCache exactly
like Sdl3Canvas. Gradient brushes are currently solid-color fallback (Skia's
Shader API takes a Gradient descriptor that we don't build yet).
*/
internal class SkiaCanvas(
private val fCanvas: SkCanvas,
@Suppress("UNUSED_PARAMETER") inSize: Size,
private val fTextRenderer: SkiaTextRenderer? = null,
private val fImageCache: SkiaImageCache? = null,
) : Canvas,
com.compose.sdl.text.NativeTextCanvas,
com.compose.sdl.graphics.NativePainterCanvas,
com.compose.sdl.graphics.NativeShadowCanvas {
fun finish() { /* Skia save/restore is balanced per call */ }
// ============
// Drop shadow (NativeShadowCanvas) — a real Gaussian blur MaskFilter on
// the layer's outline, offset downward for the spot-light look. Called by
// ProjectOwnedLayer in layer-local coords (canvas already translated).
override fun drawDropShadow(
inOutline: androidx.compose.ui.graphics.Outline,
inElevationPx: Float,
inAmbientColor: androidx.compose.ui.graphics.Color,
inSpotColor: androidx.compose.ui.graphics.Color,
) {
if (inElevationPx <= 0f) return
val vOffsetY = inElevationPx * 0.4f
val vPaint = SkPaint().apply {
color = toSkiaColor(inSpotColor.copy(alpha = 0.28f * inSpotColor.alpha))
maskFilter = org.jetbrains.skia.MaskFilter.makeBlur(
org.jetbrains.skia.FilterBlurMode.NORMAL,
sigma = inElevationPx * 0.5f + 0.5f,
)
}
when (inOutline) {
is androidx.compose.ui.graphics.Outline.Rectangle -> {
val vR = inOutline.rect
fCanvas.drawRRect(
org.jetbrains.skia.RRect.makeLTRB(vR.left, vR.top + vOffsetY, vR.right, vR.bottom + vOffsetY, 0f),
vPaint,
)
}
is androidx.compose.ui.graphics.Outline.Rounded -> {
val vRr = inOutline.roundRect
fCanvas.drawRRect(
org.jetbrains.skia.RRect.makeLTRB(
vRr.left, vRr.top + vOffsetY, vRr.right, vRr.bottom + vOffsetY,
vRr.topLeftCornerRadius.x,
),
vPaint,
)
}
is androidx.compose.ui.graphics.Outline.Generic -> {
// Blur the ACTUAL path — CutCornerShape / GenericShape shadows
// follow the real outline instead of the bounding rect.
fCanvas.save()
fCanvas.translate(0f, vOffsetY)
fCanvas.drawPath(toSkiaPath(inOutline.path), vPaint)
fCanvas.restore()
}
}
vPaint.close()
}
// ============
// Transform / clip state — delegated to Skia's own stack.
override fun save() { fCanvas.save() }
override fun restore() { fCanvas.restore() }
override fun saveLayer(bounds: Rect, paint: Paint) {
// Ignore caller-computed bounds and pass a huge rect. ProjectOwnedLayer.drawLayer
// computes bounds from THIS layer's own fPosition + fTranslation + fSize —
// it can't see inner-layer translations that would paint outside those bounds.
// Concrete failure: dragging a tab with `graphicsLayer(alpha=0.65f, translationX=X)`
// — the layer's saveLayer bounds don't include any subsequent inner-layer
// translation, so on Skia GPU backends the offscreen buffer is allocated too
// small and the ghost gets clipped as it slides. Even with the single-layer app
// fix, Skia's saveLayer(bounds, alphaPaint) allocates an offscreen SIZED to
// bounds — passing tight (fPos + fTrans + size) bounds is exactly the layer's
// own painted rect, so anything drawn OUTSIDE that rect via child modifiers
// (padding overshoot, border stroke overshoot, drop shadow) is trimmed.
// A large explicit rect avoids overload resolution ambiguity that `null as
// SkRect?` had (didn't seem to hit the null branch consistently). Alpha compositing
// still works — Skia does the alpha modulation via layerPaint on restore().
// SaveLayer paint carries alpha (for compositing) — no shape geometry, so
// pass zero size / origin; the gradient/shader branches don't fire for a
// layer paint.
val vLayerPaint = toSkiaPaint(paint, Size(0f, 0f), Offset.Zero)
fCanvas.saveLayer(-100_000f, -100_000f, 100_000f, 100_000f, vLayerPaint)
vLayerPaint.close()
}
override fun translate(dx: Float, dy: Float) { fCanvas.translate(dx, dy) }
override fun scale(sx: Float, sy: Float) { fCanvas.scale(sx, sy) }
override fun rotate(degrees: Float) { fCanvas.rotate(degrees) }
override fun skew(sx: Float, sy: Float) { fCanvas.skew(sx, sy) }
override fun concat(matrix: Matrix) {
val vM = org.jetbrains.skia.Matrix33(
matrix.values[0], matrix.values[4], matrix.values[12],
matrix.values[1], matrix.values[5], matrix.values[13],
matrix.values[3], matrix.values[7], matrix.values[15],
)
fCanvas.concat(vM)
}
override fun clipRect(left: Float, top: Float, right: Float, bottom: Float, clipOp: ClipOp) {
fCanvas.clipRect(
SkRect.makeLTRB(left, top, right, bottom),
if (clipOp == ClipOp.Difference) ClipMode.DIFFERENCE else ClipMode.INTERSECT,
true,
)
}
override fun clipPath(path: ComposePath, clipOp: ClipOp) {
fCanvas.clipPath(
toSkiaPath(path),
if (clipOp == ClipOp.Difference) ClipMode.DIFFERENCE else ClipMode.INTERSECT,
true,
)
}
// ============
// Draw primitives.
override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
val vSize = Size(right - left, bottom - top)
val vSk = toSkiaPaint(paint, vSize, Offset(left, top))
fCanvas.drawRect(SkRect.makeLTRB(left, top, right, bottom), vSk)
vSk.close()
}
override fun drawRoundRect(
left: Float, top: Float, right: Float, bottom: Float,
radiusX: Float, radiusY: Float, paint: Paint,
) {
val vSize = Size(right - left, bottom - top)
val vSk = toSkiaPaint(paint, vSize, Offset(left, top))
fCanvas.drawRRect(
RRect.makeLTRB(left, top, right, bottom, radiusX, radiusY),
vSk,
)
vSk.close()
}
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
val vSize = Size(right - left, bottom - top)
val vSk = toSkiaPaint(paint, vSize, Offset(left, top))
fCanvas.drawOval(SkRect.makeLTRB(left, top, right, bottom), vSk)
vSk.close()
}
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
val vSize = Size(radius * 2f, radius * 2f)
val vSk = toSkiaPaint(paint, vSize, Offset(center.x - radius, center.y - radius))
fCanvas.drawCircle(center.x, center.y, radius, vSk)
vSk.close()
}
override fun drawArc(
left: Float, top: Float, right: Float, bottom: Float,
startAngle: Float, sweepAngle: Float, useCenter: Boolean, paint: Paint,
) {
val vSize = Size(right - left, bottom - top)
val vSk = toSkiaPaint(paint, vSize, Offset(left, top))
fCanvas.drawArc(left, top, right, bottom, startAngle, sweepAngle, useCenter, vSk)
vSk.close()
}
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
val vSk = toSkiaPaint(paint, Size(0f, 0f), Offset.Zero)
fCanvas.drawLine(p1.x, p1.y, p2.x, p2.y, vSk)
vSk.close()
}
override fun drawPath(path: ComposePath, paint: Paint) {
val vSk = toSkiaPaint(paint, Size(0f, 0f), Offset.Zero)
fCanvas.drawPath(toSkiaPath(path), vSk)
vSk.close()
}
// ============
// Native bridges.
override fun drawNativeText(
inText: String,
inSpans: List<androidx.compose.ui.text.AnnotatedString.Range<androidx.compose.ui.text.SpanStyle>>?,
inX: Float,
inY: Float,
inBoxWidth: Float,
inBoxHeight: Float,
inColor: ComposeColor,
inFontSizePx: Int,
inTextAlign: androidx.compose.ui.text.style.TextAlign,
inSoftWrap: Boolean,
inFontFamily: String?,
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?,
inBaseItalic: Boolean,
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
) {
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
val vLineThrough = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.LineThrough) == true
fTextRenderer?.drawText(
inCanvas = fCanvas,
inText = inText,
inX = inX,
inY = inY,
inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = inBoxHeight.toInt(),
inColor = inColor,
inFontSize = inFontSizePx,
inAlign = inTextAlign,
inSoftWrap = inSoftWrap,
inFontFamily = inFontFamily,
inFontVariations = inFontVariations,
inSpans = inSpans,
inBaseItalic = inBaseItalic,
inBaseUnderline = vUnderline,
inBaseLineThrough = vLineThrough,
)
}
override fun drawNativePainter(
inResourcePath: String,
inKind: com.compose.sdl.res.ResourceKind,
inX: Float,
inY: Float,
inWidth: Float,
inHeight: Float,
inContentScale: androidx.compose.ui.layout.ContentScale,
inAlpha: Float,
) {
fImageCache?.draw(
inCanvas = fCanvas,
inPath = inResourcePath,
inKind = inKind,
inX = inX,
inY = inY,
inW = inWidth,
inH = inHeight,
inScale = inContentScale,
inAlpha = inAlpha,
)
}
// ============
// Native image (ImageBitmap-backed vector — see SkiaOffscreen). The offscreen
// registers itself lazily, so anything not backed by a SkiaImageBitmap surface
// (a hypothetical stub) is silently skipped.
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
val vBmp = image as? SkiaImageBitmap ?: return
val vImg = vBmp.snapshot()
val vSkPaint = imageBlitPaint(paint)
fCanvas.drawImage(vImg, topLeftOffset.x, topLeftOffset.y, vSkPaint)
vSkPaint.close()
}
override fun drawImageRect(
image: ImageBitmap,
srcOffset: androidx.compose.ui.unit.IntOffset,
srcSize: androidx.compose.ui.unit.IntSize,
dstOffset: androidx.compose.ui.unit.IntOffset,
dstSize: androidx.compose.ui.unit.IntSize,
paint: Paint,
) {
val vBmp = image as? SkiaImageBitmap ?: return
val vImg = vBmp.snapshot()
val vSrc = SkRect.makeXYWH(
srcOffset.x.toFloat(), srcOffset.y.toFloat(),
srcSize.width.toFloat(), srcSize.height.toFloat(),
)
val vDst = SkRect.makeXYWH(
dstOffset.x.toFloat(), dstOffset.y.toFloat(),
dstSize.width.toFloat(), dstSize.height.toFloat(),
)
val vSkPaint = imageBlitPaint(paint)
fCanvas.drawImageRect(vImg, vSrc, vDst, vSkPaint, true)
vSkPaint.close()
}
// Blit paint: alpha modulated as the paint colour's alpha; ColorFilter (Icon
// tint arrives here) forwarded to Skia so the vendored VectorPainter's
// ColorFilter.tint recolours the icon at composite time.
private fun imageBlitPaint(inPaint: Paint): SkPaint {
val vP = SkPaint()
vP.color = SkColor.makeARGB(
(inPaint.alpha * 255f).toInt().coerceIn(0, 255), 255, 255, 255,
)
vP.isAntiAlias = true
inPaint.colorFilter?.let {
vP.colorFilter = it.asSkiaColorFilter()
}
return vP
}
override fun drawPoints(pointMode: PointMode, points: List<Offset>, paint: Paint) {}
override fun drawRawPoints(pointMode: PointMode, points: FloatArray, paint: Paint) {}
override fun drawVertices(vertices: Vertices, blendMode: BlendMode, paint: Paint) {}
override fun enableZ() {}
override fun disableZ() {}
// ============
// Paint translation. Recovers a gradient Brush stashed on paint.shader (see
// CanvasPaintActuals.native.kt — ShaderBrush.applyTo drops everything but
// color=Black otherwise) and builds the matching Skia Shader, using the
// shape's [inShapeSize] to resolve infinite-anchor gradients. Forwards
// paint.colorFilter and paint.blendMode (BlendMode.Clear zeroes the region,
// which DrawCache relies on to clear the offscreen before rasterising a
// vector).
private fun toSkiaPaint(inPaint: Paint, inShapeSize: Size, inShapeOrigin: Offset): SkPaint {
val vPaint = SkPaint()
vPaint.isAntiAlias = true
vPaint.mode = if (inPaint.style == PaintingStyle.Stroke) PaintMode.STROKE else PaintMode.FILL
vPaint.strokeWidth = inPaint.strokeWidth
vPaint.strokeCap = when (inPaint.strokeCap) {
ComposeStrokeCap.Round -> PaintStrokeCap.ROUND
ComposeStrokeCap.Square -> PaintStrokeCap.SQUARE
else -> PaintStrokeCap.BUTT
}
val vBrush = inPaint.shader?.brush
val vBaseColor = SkColor.makeARGB(
(inPaint.color.alpha * inPaint.alpha * 255f).toInt().coerceIn(0, 255),
(inPaint.color.red * 255f).toInt().coerceIn(0, 255),
(inPaint.color.green * 255f).toInt().coerceIn(0, 255),
(inPaint.color.blue * 255f).toInt().coerceIn(0, 255),
)
when (vBrush) {
null, is SolidColor -> {
vPaint.color = vBaseColor
}
is LinearGradient -> {
vPaint.color = whiteAlpha(inPaint.alpha)
vPaint.shader = makeLinearShader(vBrush, inShapeSize, inShapeOrigin)
}
is RadialGradient -> {
vPaint.color = whiteAlpha(inPaint.alpha)
vPaint.shader = makeRadialShader(vBrush, inShapeSize, inShapeOrigin)
}
is SweepGradient -> {
vPaint.color = whiteAlpha(inPaint.alpha)
vPaint.shader = makeSweepShader(vBrush, inShapeSize, inShapeOrigin)
}
else -> {
vPaint.color = vBaseColor
}
}
inPaint.colorFilter?.let { vPaint.colorFilter = it.asSkiaColorFilter() }
vPaint.blendMode = inPaint.blendMode.toSkia()
return vPaint
}
private fun whiteAlpha(inAlpha: Float): Int =
SkColor.makeARGB((inAlpha * 255f).toInt().coerceIn(0, 255), 255, 255, 255)
private fun makeLinearShader(inB: LinearGradient, inSize: Size, inOrigin: Offset): SkShader {
val vSx = inOrigin.x + resolveX(inB.gradientStart.x, inSize.width)
val vSy = inOrigin.y + resolveY(inB.gradientStart.y, inSize.height)
val vEx = inOrigin.x + resolveX(inB.gradientEnd.x, inSize.width)
val vEy = inOrigin.y + resolveY(inB.gradientEnd.y, inSize.height)
return SkShader.makeLinearGradient(
p0 = Point(vSx, vSy),
p1 = Point(vEx, vEy),
gradient = Gradient(skiaColorsFor(inB.gradientColors, inB.gradientStops, inB.gradientTileMode.toSkia())),
)
}
private fun makeRadialShader(inB: RadialGradient, inSize: Size, inOrigin: Offset): SkShader {
val vCx = inOrigin.x + resolveX(inB.gradientCenter.x, inSize.width)
val vCy = inOrigin.y + resolveY(inB.gradientCenter.y, inSize.height)
val vR = if (inB.gradientRadius.isFinite()) inB.gradientRadius else (inSize.minDimension / 2f)
return SkShader.makeRadialGradient(
center = Point(vCx, vCy),
radius = vR,
gradient = Gradient(skiaColorsFor(inB.gradientColors, inB.gradientStops, inB.gradientTileMode.toSkia())),
)
}
private fun makeSweepShader(inB: SweepGradient, inSize: Size, inOrigin: Offset): SkShader {
val vCx = inOrigin.x + resolveX(inB.gradientCenter.x, inSize.width)
val vCy = inOrigin.y + resolveY(inB.gradientCenter.y, inSize.height)
return SkShader.makeSweepGradient(
center = Point(vCx, vCy),
gradient = Gradient(skiaColorsFor(inB.gradientColors, inB.gradientStops, FilterTileMode.CLAMP)),
)
}
private fun skiaColorsFor(
inColors: List<ComposeColor>,
inStops: List<Float>?,
inTile: FilterTileMode,
): Gradient.Colors = Gradient.Colors(
colors = Array(inColors.size) { Color4f(r = inColors[it].r8 / 255f, g = inColors[it].g8 / 255f, b = inColors[it].b8 / 255f, a = inColors[it].a8 / 255f) },
positions = inStops?.toFloatArray(),
tileMode = inTile,
)
private fun resolveX(inV: Float, inW: Float): Float = if (inV.isFinite()) inV else inW
private fun resolveY(inV: Float, inH: Float): Float = if (inV.isFinite()) inV else inH
private fun ComposeTileMode.toSkia(): FilterTileMode = when (this) {
ComposeTileMode.Clamp -> FilterTileMode.CLAMP
ComposeTileMode.Repeated -> FilterTileMode.REPEAT
ComposeTileMode.Mirror -> FilterTileMode.MIRROR
ComposeTileMode.Decal -> FilterTileMode.DECAL
else -> FilterTileMode.CLAMP
}
// ============
// Path translation. Two possible source types since our skikoRenderer source
// set inherits BOTH the commonMain `ProjectPath` and the vendored
// `SkiaBackedPath` (`actual fun Path(): Path = SkiaBackedPath()` lives in
// vendor/skikoRenderer/…/SkiaBackedPath.skiko.kt). Upstream callers that build
// a Path via `Path()` — including `MultiParagraph.getPathForRange`, which
// drives text-selection highlight rendering — get a SkiaBackedPath. Our own
// `ProjectPath` still surfaces from code paths that build via
// `com.compose.sdl.graphics.ProjectPath()` directly. Try
// SkiaBackedPath's own Skia handle first (zero-copy, avoids replaying a
// command list); otherwise, replay ProjectPath's commands.
//
// Regression that motivated the branch: selection highlight painted an empty
// Skia path — the SkiaBackedPath fell through the `as? ProjectPath` check and
// returned an empty PathBuilder snapshot.
private fun toSkiaPath(inPath: ComposePath): SkPath {
(inPath as? SkiaBackedPath)?.let { return (it as ComposePath).asSkiaPath() }
val vB = PathBuilder()
val vCommands = (inPath as? ProjectPath)?.commands ?: return vB.snapshot().also { vB.close() }
for (vCmd in vCommands) when (vCmd) {
is PathCommand.MoveTo -> vB.moveTo(vCmd.x, vCmd.y)
is PathCommand.LineTo -> vB.lineTo(vCmd.x, vCmd.y)
is PathCommand.QuadTo -> vB.quadTo(vCmd.cx, vCmd.cy, vCmd.x, vCmd.y)
is PathCommand.CubicTo -> vB.cubicTo(vCmd.c1x, vCmd.c1y, vCmd.c2x, vCmd.c2y, vCmd.x, vCmd.y)
PathCommand.Close -> vB.closePath()
}
return vB.snapshot().also { vB.close() }
}
}
@@ -0,0 +1,20 @@
package com.compose.sdl.renderer.skia
// ==================
// MARK: SkiaLeafDrawer — port text/image renderers behind the upstream canvas (B6.1)
// ==================
/* The Skia leg draws through upstream's `SkiaBackedCanvas` (real gradients/paint/
shader), but keeps the port's text engine (`SkiaTextRenderer`, incl. the P3.1
metric work) and resource-image cache (`SkiaImageCache`). `SkiaBackedCanvas` is
manual-vendored to implement the port `NativeTextCanvas`/`NativePainterCanvas`
contracts and forwards to this drawer, which holds the two long-lived renderers
the way the old per-frame `SkiaCanvas` used to. Set once by `SkiaRenderBackend`;
any `SkiaBackedCanvas` (frame, offscreen, or a GraphicsLayer recording — B6.2)
can then draw text/images onto its own `internalSkiaCanvas`. */
internal var skiaLeafDrawer: SkiaLeafDrawer? = null
internal class SkiaLeafDrawer(
val textRenderer: SkiaTextRenderer,
val imageCache: SkiaImageCache,
)
@@ -1,93 +1,9 @@
package com.compose.sdl.renderer.skia
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageBitmapConfig
import androidx.compose.ui.graphics.colorspace.ColorSpace
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import androidx.compose.ui.graphics.toComposeImageBitmap
import com.compose.sdl.graphics.EncodedImageDecoder
import com.compose.sdl.graphics.OffscreenRenderer
import org.jetbrains.skia.Image
import org.jetbrains.skia.Surface
// ==================
// MARK: Skia offscreen bitmap support
// ==================
// An ImageBitmap backed by a Skia Surface. The vector rasterises INTO the
// surface (via a Canvas returned by the factory) and SkiaCanvas.drawImageRect
// blits it back — with an Icon's tint applied through the paint's ColorFilter.
// prepareToDraw() invalidates the cached snapshot so the next blit re-samples
// the freshly-drawn surface.
internal class SkiaImageBitmap(
override val width: Int,
override val height: Int,
override val config: ImageBitmapConfig,
override val hasAlpha: Boolean,
override val colorSpace: ColorSpace,
) : ImageBitmap {
// Raster (CPU) surface: matches SkiaImageCache.rasterizeSvg's pattern and
// works on every SkiaBridge (Metal / OpenGL / software raster). Icon-sized
// vectors are tiny; a GPU surface would need a shared GrContext with the
// active bridge which Skiko doesn't expose portably here.
val surface: Surface = Surface.makeRasterN32Premul(width.coerceAtLeast(1), height.coerceAtLeast(1))
// Cached snapshot of the surface pixels. Cleared by prepareToDraw() so a
// re-rasterised vector reuploads on the next blit; retained across frames
// when the vector doesn't change.
private var fSnapshot: Image? = null
fun snapshot(): Image {
val vExisting = fSnapshot
if (vExisting != null) return vExisting
val vFresh = surface.makeImageSnapshot()
fSnapshot = vFresh
return vFresh
}
override fun readPixels(
buffer: IntArray,
startX: Int,
startY: Int,
width: Int,
height: Int,
bufferOffset: Int,
stride: Int,
) {
}
override fun prepareToDraw() {
fSnapshot?.close()
fSnapshot = null
}
}
// Registered on SkiaRenderBackend; hands the vendored DrawCache a real offscreen.
internal class SkiaOffscreenRenderer(
private val fTextRenderer: SkiaTextRenderer?,
private val fImageCache: SkiaImageCache?,
) : OffscreenRenderer {
override fun createImageBitmap(
width: Int,
height: Int,
config: ImageBitmapConfig,
hasAlpha: Boolean,
colorSpace: ColorSpace,
): ImageBitmap = SkiaImageBitmap(width, height, config, hasAlpha, colorSpace)
override fun createCanvas(image: ImageBitmap): Canvas? {
val vBmp = image as? SkiaImageBitmap ?: return null
return SkiaCanvas(
vBmp.surface.canvas,
Size(vBmp.width.toFloat(), vBmp.height.toFloat()),
fTextRenderer,
fImageCache,
)
}
}
// ==================
// MARK: SkiaEncodedImageDecoder
@@ -98,7 +14,12 @@ internal class SkiaOffscreenRenderer(
(painterResource / SVG). Raster formats go through Image.makeFromEncoded;
when that fails the bytes are retried as SVG through the same SVGDOM
rasterisation SkiaImageCache uses. Pure CPU raster (no GrContext), so it is
safe on the resources pipeline's Dispatchers.Default workers. */
safe on the resources pipeline's Dispatchers.Default workers.
B6.1: produces an UPSTREAM ImageBitmap (Image.toComposeImageBitmap) — the Skia
leg now uses upstream's SkiaBackedCanvas/SkiaImageAsset, so the offscreen +
ImageBitmap-backed paths go through upstream's own actuals (the project
SkiaImageBitmap / SkiaOffscreenRenderer are retired). */
internal class SkiaEncodedImageDecoder : EncodedImageDecoder {
override fun decode(inBytes: ByteArray): ImageBitmap? {
@@ -106,13 +27,7 @@ internal class SkiaEncodedImageDecoder : EncodedImageDecoder {
val vImg = runCatching { Image.makeFromEncoded(inBytes) }.getOrNull()
?: rasterizeSvg(inBytes)
?: return null
val vBmp = SkiaImageBitmap(
vImg.width, vImg.height,
config = ImageBitmapConfig.Argb8888,
hasAlpha = true,
colorSpace = ColorSpaces.Srgb,
)
vBmp.surface.canvas.drawImage(vImg, 0f, 0f)
val vBmp = vImg.toComposeImageBitmap()
vImg.close()
return vBmp
}
@@ -5,6 +5,7 @@ import com.compose.sdl.res.ImageLoader
import com.compose.sdl.res.ResourceKind
import com.compose.sdl.text.TextMeasurer
import com.compose.sdl.*
import androidx.compose.ui.graphics.asComposeCanvas
import org.jetbrains.skia.Canvas
import org.jetbrains.skia.Color as SkColor
@@ -19,10 +20,10 @@ import org.jetbrains.skia.Color as SkColor
AUTO is resolved at the call site (ComposeWindow); this class only
sees concrete modes.
NOTE: Phase 9 pivoted rendering to the upstream engine via drawRoot(host).
A real SkiaCanvas : androidx.compose.ui.graphics.Canvas actual is TODO —
until then the Skia path compiles but paints nothing (drawRoot inherits
the RenderBackend default no-op). */
B6.1: the composition draws through upstream's SkiaBackedCanvas (real
paint/shader/gradients). The port's text engine (SkiaTextRenderer) +
resource-image cache (SkiaImageCache) + elevation shadows ride behind it
via skiaLeafDrawer + the SkiaBackedCanvas manual-vendor. */
internal class SkiaRenderBackend(
private val sdl: SDL3Backend,
private val gpuMode: GpuMode,
@@ -45,6 +46,15 @@ internal class SkiaRenderBackend(
}
}
// B6.1: the Skia leg draws through upstream SkiaBackedCanvas; its port draw
// contracts (text/painter/shadow) forward to the port renderers via this global
// drawer. It is per-WINDOW state (each window has its own text renderer + image
// cache), so it must be re-pointed at THIS backend before every frame — exactly
// like ComposeWindow.installGlobals() does for currentTextMeasurer. Setting it
// once in the ctor left it dangling at a CLOSED window's (destroyed) renderer
// after a multi-window teardown → crash on the surviving window's next frame.
private val fLeafDrawer = SkiaLeafDrawer(fSkiaTextRenderer, fSkiaImageCache)
override val textMeasurer: TextMeasurer
get() = fSkiaTextRenderer.textMeasurer
@@ -69,10 +79,8 @@ internal class SkiaRenderBackend(
override fun beginFrame(inDpr: Float) {
val canvas = fBridge.canvas
// Clear the surface to Material dark. Without this the Metal back
// buffer shows uninitialized GPU memory (pink) since Sdl3Renderer.draw
// no longer runs and drawRoot is a no-op inherited default until
// SkiaCanvas lands. Clearing before scale so it covers the whole
// physical target.
// buffer shows uninitialized GPU memory (pink). Clearing before scale
// so it covers the whole physical target.
canvas.clear(SkColor.makeARGB(0xFF, 0x12, 0x12, 0x12))
canvas.save()
if (inDpr != 1f) canvas.scale(inDpr, inDpr)
@@ -86,19 +94,17 @@ internal class SkiaRenderBackend(
fCurrentCanvas = canvas
}
override fun drawRoot(inHost: com.compose.sdl.node.ComposeRootHost) {
override fun drawRoot(inDraw: (androidx.compose.ui.graphics.Canvas) -> Unit) {
val canvas = fCurrentCanvas ?: return
// Register the offscreen (ImageBitmap) render path so the vendored
// VectorPainter / DrawCache pipeline — hence material3's ImageVector
// icons — renders. Idempotent.
if (com.compose.sdl.graphics.offscreenRenderer == null) {
com.compose.sdl.graphics.offscreenRenderer =
SkiaOffscreenRenderer(fSkiaTextRenderer, fSkiaImageCache)
}
val vSize = androidx.compose.ui.geometry.Size(sdl.pixelWidth.toFloat(), sdl.pixelHeight.toFloat())
val vCanvas = SkiaCanvas(canvas, vSize, fSkiaTextRenderer, fSkiaImageCache)
inHost.rootNode.draw(vCanvas, null)
vCanvas.finish()
// Point the leaf-draw global at THIS window's renderers before drawing.
skiaLeafDrawer = fLeafDrawer
// Wrap the live skia canvas as upstream's SkiaBackedCanvas (real gradients/
// paint/shader). Its port draw contracts forward to skiaLeafDrawer. The
// ImageBitmap-backed offscreen (VectorPainter / DrawCache) now goes through
// upstream's own ActualCanvas, so no project offscreenRenderer registration.
val vCanvas = canvas.asComposeCanvas()
inDraw(vCanvas)
(vCanvas as? com.compose.sdl.graphics.NativeFinishableCanvas)?.finish()
}
override fun endFrame() {
@@ -110,6 +116,9 @@ internal class SkiaRenderBackend(
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = fBridge.snapshotBgra()
override fun destroy() {
// Drop the global if it still points at this (about-to-be-destroyed) backend,
// so a stray draw before the next window's drawRoot can't hit freed renderers.
if (skiaLeafDrawer === fLeafDrawer) skiaLeafDrawer = null
fSkiaImageCache.destroy()
fSkiaTextRenderer.destroy()
fBridge.destroy()
@@ -163,11 +163,22 @@ class SkiaTextRenderer {
inBaseItalic: Boolean = false,
inBaseUnderline: Boolean = false,
inBaseLineThrough: Boolean = false,
// The paragraph's per-line band (TextStyle.lineHeight) in px — stack lines at
// the same advance layout used. <= 0 → font-metrics height (legacy callers).
inLineHeightPx: Float = 0f,
// Compat-trim (raw styles): the first line keeps the tight font cell. False for
// M3's LineHeightStyle(Trim.None) — every line is the full band.
inTrimFirstLine: Boolean = true,
) {
val vKey = TypefaceKey(inFontFamily, variationsKey(inFontVariations))
val vBaseFont = getFont(vKey, inFontFamily, inFontVariations, inFontSize)
val vBaseMetrics = vBaseFont.metrics
val vBaseLineHeight = (vBaseMetrics.descent - vBaseMetrics.ascent).coerceAtLeast(1f)
// Per-mode stacking (mirrors SdlParagraph): compat trim keeps the FIRST line at
// the tight font cell; M3's Trim.None makes every line the full band. Following
// lines advance by the band (inLineHeightPx) when supplied, else the font cell.
val vFontCellH = (vBaseMetrics.descent - vBaseMetrics.ascent).coerceAtLeast(1f)
val vBaseLineHeight = if (inLineHeightPx > 0f) inLineHeightPx else vFontCellH
val vFirstLineH = if (inTrimFirstLine || inLineHeightPx <= 0f) vFontCellH else vBaseLineHeight
val vCapHeight = if (vBaseMetrics.capHeight > 0f) vBaseMetrics.capHeight
else inFontSize * 0.7f
@@ -191,9 +202,10 @@ class SkiaTextRenderer {
inCanvas.drawString(expandTabs(vLines[0]), vPenX, vBaseline, vBaseFont, vFastPaint)
} else {
for ((vIdx, vLine) in vLines.withIndex()) {
val vSlotTop = inY + vIdx * vBaseLineHeight
if (vSlotTop + vBaseLineHeight < inViewTop || vSlotTop > inViewBottom) continue
val vBaseline = vSlotTop + (vBaseLineHeight + vCapHeight) / 2f
val vSlotH = if (vIdx == 0) vFirstLineH else vBaseLineHeight
val vSlotTop = inY + (if (vIdx == 0) 0f else vFirstLineH + (vIdx - 1) * vBaseLineHeight)
if (vSlotTop + vSlotH < inViewTop || vSlotTop > inViewBottom) continue
val vBaseline = vSlotTop + (vSlotH + vCapHeight) / 2f
val vPenX = alignX(inX, inBoxWidth, estimateTextWidth(vLine, inFontSize, inFontFamily, inFontVariations).toFloat(), inAlign)
inCanvas.drawString(expandTabs(vLine), vPenX, vBaseline, vBaseFont, vFastPaint)
}
@@ -212,11 +224,14 @@ class SkiaTextRenderer {
val vLineStart = vWrapped.lineStarts.getOrElse(vIdx) { 0 }
val vLineH =
if (vMetricSpans && inSpans != null) {
styledLineCellHeight(
vLine, vLineStart, inSpans, inFontSize, fDensity,
textMeasurer, inFontFamily, inFontVariations,
kotlin.math.max(
styledLineCellHeight(
vLine, vLineStart, inSpans, inFontSize, fDensity,
textMeasurer, inFontFamily, inFontVariations,
),
if ((vIdx == 0 && inTrimFirstLine) || inLineHeightPx <= 0f) 0f else inLineHeightPx,
).coerceAtLeast(1f)
} else vBaseLineHeight
} else if (vIdx == 0) vFirstLineH else vBaseLineHeight
if (vLineY + vLineH < inViewTop || vLineY > inViewBottom) {
vLineY += vLineH
continue
+15 -9
View File
@@ -13,15 +13,15 @@ plugins {
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
// JVM parity target's Compose version — the dev build matching the pinned
// JVM parity target's Compose version — the Maven build matching the pinned
// COMPOSE_CORE_REF (see scripts/compose-fork/compose.properties). material3
// rides its OWN release train: same +dev build number, different base
// version. Gradle orders "+dev" qualifiers BELOW the plain version, so
// currentOs's beta01 would win conflict resolution — force the core-repo
// groups on every jvm configuration. Umbrella-repo groups (desktop,
// components) are NOT forced: their dev numbering differs.
val vComposeJvmVersion = "1.12.0-beta01+dev4324"
val vComposeM3JvmVersion = "1.12.0-alpha03+dev4324"
// rides its OWN release train (different base version). Forcing keeps the
// core-repo groups locked to the pin on every jvm configuration even when
// the umbrella plugin's own version would win conflict resolution (essential
// for "+dev" pins, which Gradle orders BELOW the plain version). Umbrella-repo
// groups (desktop, components) are NOT forced: their versioning differs.
val vComposeJvmVersion = libs.versions.compose.get()
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
val vComposeJvmForced = mapOf(
"org.jetbrains.compose.runtime" to vComposeJvmVersion,
"org.jetbrains.compose.ui" to vComposeJvmVersion,
@@ -111,7 +111,7 @@ kotlin {
// visibility check. The runtime is the official klib
// everywhere, never substituted (jvm configs force the +dev
// build above).
implementation("org.jetbrains.compose.runtime:runtime:1.11.1")
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-text:$vComposeJvmVersion")
@@ -309,4 +309,10 @@ tasks.named<ProcessResources>("jvmProcessResources") {
from(vFontFile) { into("font") }
dependsOn(vDownloadTask)
}
// P0.3 (RENDERER.md §8): stage the SAME default font the native leg bundles
// (data.kres font/NotoSans.ttf) onto the JVM classpath, so the parity harness's JVM leg
// renders text with NotoSans too — collapsing the font-drift baseline to (near) just
// rasterizer AA. MainJvm loads it from /font/NotoSans.ttf.
from(notoSansFile) { into("font") }
dependsOn(":ui:downloadNotoFonts")
}
@@ -0,0 +1,18 @@
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.toKString
// Current resident set (MB) via `ps -o rss=` (KB). See currentResidentMb in MainNative.kt.
@OptIn(ExperimentalForeignApi::class)
internal actual fun currentResidentMb(): Long {
val vPid = platform.posix.getpid()
val vFp = platform.posix.popen("ps -o rss= -p $vPid", "r") ?: return -1
val vKb = memScoped {
val vBuf = allocArray<kotlinx.cinterop.ByteVar>(64)
val vLine = platform.posix.fgets(vBuf, 64, vFp)?.toKString()?.trim()
vLine?.toLongOrNull() ?: -1L
}
platform.posix.pclose(vFp)
return if (vKb < 0) -1 else vKb / 1024
}
@@ -42,55 +42,9 @@ internal fun GraphicsLayerScreen() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTitle(
"Modifier.graphicsLayer",
"2D transform (scale / rotation / translation) + alpha + optional cacheKey opt-in. " +
"When cacheKey is set, the renderer renders the subtree into an offscreen target once " +
"and reuses it while the key compares equal — change the key to invalidate.",
"2D transform (scale / rotation / translation) + alpha, applied to a subtree.",
)
Section(
"Cached subtree (cacheKey)",
"Identical to a fresh-drawn row visually, but the subtree is rendered once and reused while " +
"cacheKey doesn't change. Useful for static screens — no per-frame redraw of the chrome.",
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
for (vI in 0..3) {
Box(
modifier = Modifier
.size(64.dp)
.graphicsLayer(),
) {
Canvas(modifier = Modifier.size(64.dp)) {
drawCircle(color = vPrimary, radius = 22f, center = Offset(32f, 32f))
drawCircle(color = vSecondary, radius = 12f, center = Offset(32f, 32f))
}
}
}
Text("(cached)", color = vPrimary, fontSize = 12.sp)
}
}
Section("Combined (cache + rotation)", "Cached subtree blitted with live rotation around its centre.") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
for (vI in 0..4) {
Box(
modifier = Modifier
.size(64.dp)
.graphicsLayer(
rotationZ = vSpin + vI * 30f,
),
) {
Canvas(modifier = Modifier.size(64.dp)) {
drawRect(
color = if (vI % 2 == 0) vPrimary else vSecondary,
topLeft = Offset(10f, 10f),
size = Size(44f, 44f),
)
}
}
}
}
}
Section("Rotation (live)", "Each tile holds the same shape rotated at multiples of the current spin angle.") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
for (vI in 0..4) {
+154 -13
View File
@@ -4,14 +4,20 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.platform.Font
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
@@ -40,6 +46,10 @@ fun main(args: Array<String>) {
)
return
}
if (args.any { it == "--metrics" }) {
printParagraphMetrics()
return
}
application {
Window(
onCloseRequest = ::exitApplication,
@@ -56,37 +66,168 @@ fun main(args: Array<String>) {
private fun Array<String>.intArg(name: String, default: Int): Int =
firstOrNull { it.startsWith("$name=") }?.substringAfter('=')?.toIntOrNull() ?: default
// P0.5 render-to-quiescence (mirrors the native leg): step a VIRTUAL 60fps clock and
// re-render while the scene still has invalidations, so entrance animations settle
// before capture. Infinite animations are CANCELLED via the upstream test
// InfiniteAnimationPolicy (they freeze at their initial value), so looping screens
// settle too. The cap only guards never-settling content the policy can't reach.
private const val FRAME_NANOS = 16_666_667L
private const val MAX_FRAMES = 300
private object CancelInfiniteAnimations : androidx.compose.ui.platform.InfiniteAnimationPolicy {
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
throw kotlinx.coroutines.CancellationException("infinite animations disabled for parity screenshots")
}
/* Render each registered screen headlessly (density 1 to match the native
physical-pixel screenshots) and write a PNG per screen. */
physical-pixel screenshots) to quiescence and write a PNG per screen. */
@OptIn(ExperimentalComposeUiApi::class)
private fun screenshotAllScreens(outDir: File, width: Int, height: Int) {
outDir.mkdirs()
val screens = allCategories().flatMap { it.screens }.distinctBy { it.name }
for (screen in screens) {
val scene = ImageComposeScene(width, height, density = Density(1f)) {
val scene = ImageComposeScene(
width, height, density = Density(1f),
coroutineContext = kotlinx.coroutines.Dispatchers.Unconfined + CancelInfiniteAnimations,
) {
ScreenHost { screen.content() }
}
try {
val image = scene.render()
var nanos = 0L
var frames = 0
var image = scene.render(nanos)
while (scene.hasInvalidations() && frames < MAX_FRAMES) {
nanos += FRAME_NANOS
image = scene.render(nanos)
frames++
}
val png = image.encodeToData(EncodedImageFormat.PNG) ?: continue
File(outDir, "${screen.name}.png").writeBytes(png.bytes)
println("jvm screenshot: ${screen.name} (settled after $frames frame(s))")
} finally {
scene.close()
}
println("jvm screenshot: ${screen.name}")
}
}
/* Same wrapper as MainNative's --screen path. */
// P0.3 (RENDERER.md §8): the SAME default font the native leg bundles
// (font/NotoSans.ttf, staged onto the JVM classpath by jvmProcessResources). Loading it
// into the parity JVM leg collapses the font-drift baseline to (near) just rasterizer AA —
// so the parity % measures real divergence, not "different default typeface". Null (→ no
// alignment, harness still runs) if the resource is missing.
private val notoSans: FontFamily? by lazy {
val bytes = object {}.javaClass.getResourceAsStream("/font/NotoSans.ttf")?.readBytes()
if (bytes == null) {
println("parity(jvm): /font/NotoSans.ttf not on classpath — font NOT aligned")
null
} else FontFamily(Font(identity = "NotoSans", data = bytes))
}
/* A copy of the M3 Typography with every core style forced to [family] (Typography styles
carry their own fontFamily, so overriding LocalTextStyle alone wouldn't reach them). */
private fun notoTypography(family: FontFamily): Typography {
val b = Typography()
return b.copy(
displayLarge = b.displayLarge.copy(fontFamily = family),
displayMedium = b.displayMedium.copy(fontFamily = family),
displaySmall = b.displaySmall.copy(fontFamily = family),
headlineLarge = b.headlineLarge.copy(fontFamily = family),
headlineMedium = b.headlineMedium.copy(fontFamily = family),
headlineSmall = b.headlineSmall.copy(fontFamily = family),
titleLarge = b.titleLarge.copy(fontFamily = family),
titleMedium = b.titleMedium.copy(fontFamily = family),
titleSmall = b.titleSmall.copy(fontFamily = family),
bodyLarge = b.bodyLarge.copy(fontFamily = family),
bodyMedium = b.bodyMedium.copy(fontFamily = family),
bodySmall = b.bodySmall.copy(fontFamily = family),
labelLarge = b.labelLarge.copy(fontFamily = family),
labelMedium = b.labelMedium.copy(fontFamily = family),
labelSmall = b.labelSmall.copy(fontFamily = family),
)
}
/* Upstream-Paragraph metrics for a font-size sweep at density 1 (NotoSans — the same
font the native leg bundles). The JVM half of the metrics-alignment probe: MainNative's
`--metricsprobe` prints the same table from SdlParagraph; aligning the numbers kills
the accumulating vertical text drift in parity (P3.1). Runs inside a headless scene so
LocalFontFamilyResolver supplies the platform resolver. */
@OptIn(ExperimentalComposeUiApi::class)
private fun printParagraphMetrics() {
val scene = ImageComposeScene(100, 100, density = Density(1f)) {
val resolver = androidx.compose.ui.platform.LocalFontFamilyResolver.current
val density = Density(1f)
fun paragraph(text: String, size: Int, lineHeight: Int?, m3Style: Boolean): androidx.compose.ui.text.Paragraph =
androidx.compose.ui.text.Paragraph(
text = text,
style = androidx.compose.ui.text.TextStyle(
fontSize = size.sp,
lineHeight = lineHeight?.sp ?: androidx.compose.ui.unit.TextUnit.Unspecified,
fontFamily = notoSans,
lineHeightStyle = if (m3Style) {
androidx.compose.ui.text.style.LineHeightStyle(
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
)
} else null,
),
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 10_000),
density = density,
fontFamilyResolver = resolver,
)
// What the demo's Text actually resolves to on this leg — is lineHeightStyle set?
println("metrics: m3 bodyMedium = ${Typography().bodyMedium.fontSize}/${Typography().bodyMedium.lineHeight} lineHeightStyle=${Typography().bodyMedium.lineHeightStyle}")
for (size in listOf(11, 12, 14, 16, 22, 24)) {
val lh = size + 6
val cell = paragraph("Hg", size, null, false)
val one = paragraph("Hg", size, lh, false)
val three = paragraph("Hg\nHg\nHg", size, lh, false)
val oneM3 = paragraph("Hg", size, lh, true)
val threeM3 = paragraph("Hg\nHg\nHg", size, lh, true)
println(
"metrics: size=$size lh=$lh cell=${cell.height} " +
"one=${one.height} three=${three.height} " +
"base1=${one.firstBaseline} base3=${three.lastBaseline} " +
"oneM3=${oneM3.height} threeM3=${threeM3.height} base1M3=${oneM3.firstBaseline}"
)
}
// h-boundary cases: does upstream apply lineHeight at exactly 1em?
for ((s, l) in listOf(24 to 24, 24 to 25, 32 to 24, 16 to 16)) {
val b = paragraph("Hg", s, l, true)
println("metrics: boundary $s/$l m3=${b.height} base=${b.firstBaseline}")
}
// The band-smaller-than-cell case (Counter's fontSize=48 under body lineHeight).
val big = paragraph("42", 48, 24, false)
val bigM3 = paragraph("42", 48, 24, true)
println("metrics: big48/lh24 raw=${big.height} base=${big.firstBaseline} m3=${bigM3.height} baseM3=${bigM3.firstBaseline}")
println("metrics: DONE")
}
try {
scene.render()
} finally {
scene.close()
}
}
/* Same wrapper as MainNative's --screen path — plus NotoSans font-alignment (P0.3) so the
parity diff isn't dominated by the JVM's default typeface. */
@Composable
private fun ScreenHost(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(24.dp),
) { content() }
val family = notoSans
MaterialTheme(
colorScheme = darkColorScheme(),
typography = if (family != null) notoTypography(family) else Typography(),
) {
val base = LocalTextStyle.current
CompositionLocalProvider(
LocalTextStyle provides (if (family != null) base.copy(fontFamily = family) else base),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(24.dp),
) { content() }
}
}
}
@@ -0,0 +1,18 @@
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.toKString
// Current resident set (MB) via `ps -o rss=` (KB). See currentResidentMb in MainNative.kt.
@OptIn(ExperimentalForeignApi::class)
internal actual fun currentResidentMb(): Long {
val vPid = platform.posix.getpid()
val vFp = platform.posix.popen("ps -o rss= -p $vPid", "r") ?: return -1
val vKb = memScoped {
val vBuf = allocArray<kotlinx.cinterop.ByteVar>(64)
val vLine = platform.posix.fgets(vBuf, 64, vFp)?.toKString()?.trim()
vLine?.toLongOrNull() ?: -1L
}
platform.posix.pclose(vFp)
return if (vKb < 0) -1 else vKb / 1024
}
@@ -0,0 +1,3 @@
// Windows has no `ps`/popen; the memory soak (--soaktest) is a macOS/Linux gate
// (see scripts/verify-mac.sh), so current RSS is not sampled here.
internal actual fun currentResidentMb(): Long = -1L
+183 -2
View File
@@ -25,6 +25,10 @@ import screens.ExtraWindows
import utils.encodeBmpBgra32
import utils.parseArgs
import utils.writeFile
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.toKString
// ==================
// MARK: Entry point
@@ -95,6 +99,12 @@ fun main(args: Array<String>) {
runParagraphTest()
return
}
// Prints Paragraph cell/line metrics for a size sweep — compare against the JVM
// leg's `--metrics` output to align the SDL text metrics with upstream (P3.1).
if (args.any { it == "--metricsprobe" }) {
runMetricsProbe()
return
}
// Verifies the Dialog appearance animation (upstream Dialog.skiko.kt parity):
// opens a real m3 AlertDialog via an injected click, dumps mid-animation and
// settled screenshots — the mid shots must show the dialog fainter and lower.
@@ -117,6 +127,10 @@ fun main(args: Array<String>) {
runNav3Test()
return
}
if (args.any { it == "--soaktest" }) {
runSoakTest()
return
}
val vCli = parseArgs(args)
val vTitle = buildString {
@@ -125,6 +139,15 @@ fun main(args: Array<String>) {
append(" [").append(vCli.gpu).append("]")
}
// Screenshot runs freeze infinite animations (rememberInfiniteTransition & co. cancel
// at their initial value) so every screen can reach quiescence, and step the frame
// clocks on VIRTUAL time (16.6ms/frame, like the JVM leg's render(nanos)) so animation
// races resolve identically every run — both must be set before the first window composes.
if (vCli.screenshot != null) {
com.compose.sdl.disableInfiniteAnimations = true
com.compose.sdl.useVirtualFrameTime = true
}
// Multi-window app shell: the showcase window plus any extra windows opened
// from WindowScreen's "Multi-window" section (state-driven, Compose Desktop
// style — the count IS the windows' lifetime).
@@ -136,14 +159,23 @@ fun main(args: Array<String>) {
height = vCli.height,
gpu = vCli.gpu,
onFrame = if (vCli.screenshot != null) {
// P0.5 render-to-quiescence: capture once the window reports no pending
// invalidations for a few consecutive frames (entrance animations settled,
// async loads applied), or at the --frames cap as a safety net. Replaces
// the fixed frame-6 capture, whose mid-animation timing was run-dependent.
var vQuietFrames = 0
{ bridge, frameIndex ->
if (frameIndex == vCli.frames) {
vQuietFrames = if (com.compose.sdl.windowHasInvalidations()) 0 else vQuietFrames + 1
if (vQuietFrames >= 3 || frameIndex >= vCli.maxFrames) {
if (frameIndex >= vCli.maxFrames) {
println("Screenshot: quiescence not reached by frame $frameIndex - capturing anyway")
}
val vSnap = bridge.snapshotBgra()
if (vSnap != null) {
val (vW, vH, vBgra) = vSnap
val vBmp = encodeBmpBgra32(vW, vH, vBgra)
writeFile(vCli.screenshot, vBmp)
println("Wrote screenshot: ${vCli.screenshot} (${vW}x${vH})")
println("Wrote screenshot: ${vCli.screenshot} (${vW}x${vH}, settled at frame $frameIndex)")
} else println("Screenshot snapshot was null")
false // quit
} else true
@@ -447,6 +479,96 @@ private fun runSearchEscTest() {
Crashes here (e.g. enableSavedStateHandles' INITIALIZED/CREATED contract) never
reproduce under --screen, which composes during the initial CREATED composition.
PASS = a screenshot gets written and the app exits cleanly. */
/* P2.2 soak — cycle through EVERY registered screen kCycles times in ONE process,
disposing each via a changing key() so composition + layer (skiko RenderNode / SDL
node) allocate-and-release is exercised repeatedly. After each full cycle, GC then
record peak RSS (getrusage ru_maxrss). Peak RSS is monotonic, so after cycle 1 it
already reflects visiting every screen once; if there is NO leak it plateaus, if
there IS one it keeps climbing each cycle. PASS iff last-cycle peak stays within a
ceiling of the first-cycle peak. Runs on both renderer legs. */
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class, ExperimentalForeignApi::class)
private fun runSoakTest() {
// Disable never-settling animations so RSS reflects composition/layer lifetime,
// not live animation-state churn (same seed the screenshot path uses).
com.compose.sdl.disableInfiniteAnimations = true
com.compose.sdl.useVirtualFrameTime = true
val vAll = allCategories().flatMap { it.screens }
// CDN_SOAK_SCREEN=<name> repeats ONE screen 40x/cycle (bisect a specific screen);
// default cycles through every screen.
val vTarget = platform.posix.getenv("CDN_SOAK_SCREEN")?.toKString()
val vScreens = if (vTarget != null) { val vS = vAll.first { it.name.equals(vTarget, ignoreCase = true) }; List(40) { vS } }
else vAll
val vIndex = mutableStateOf(0)
// CDN_SOAK_STATIC=1: mount ONE screen once, never remount — measure RSS every 120 frames.
// Isolates a per-FRAME leak (RSS climbs with no remounts) from a per-MOUNT leak.
val vStatic = platform.posix.getenv("CDN_SOAK_STATIC")?.toKString() == "1"
val kFramesPerScreen = if (vStatic) 120 else 3
val kCycles = platform.posix.getenv("CDN_SOAK_CYCLES")?.toKString()?.toIntOrNull() ?: 3
val vRssPerCycleMb = mutableListOf<Long>()
var vShown = 0
nativeComposeWindow(
title = "soaktest",
width = 1000,
height = 700,
onFrame = { _, vFrame ->
if (vFrame > 0 && vFrame % kFramesPerScreen == 0) {
if (vStatic) {
// No remount — just pump frames on the one mounted screen.
kotlin.native.runtime.GC.collect()
vRssPerCycleMb.add(currentResidentMb())
println("soaktest[static]: measure ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
} else {
vShown++
vIndex.value = vShown % vScreens.size
if (vShown % vScreens.size == 0) {
kotlin.native.runtime.GC.collect()
vRssPerCycleMb.add(currentResidentMb())
println("soaktest: cycle ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
}
}
}
if (vRssPerCycleMb.size >= kCycles) {
val vFirst = vRssPerCycleMb.first()
val vLast = vRssPerCycleMb.last()
val vGrowth = vLast - vFirst
val vCeiling = maxOf(48L, vFirst / 4) // 25% or 48MB, whichever larger
println("soaktest: currentRSS/cycle(MB)=$vRssPerCycleMb (${vScreens.size} screens x $kCycles cycles)")
if (vGrowth <= vCeiling) {
println("soaktest: PASS (current RSS grew ${vGrowth}MB over cycles 1->$kCycles, within ${vCeiling}MB ceiling)")
} else {
println("soaktest: FAIL (current RSS grew ${vGrowth}MB > ${vCeiling}MB ceiling — possible leak)")
}
false
} else true
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
val vScroll = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(vScroll)
.padding(24.dp),
) {
// key(vShown) forces a FULL dispose+recompose each advance (even when the
// target screen repeats) — the allocate/release churn we soak. Reading
// vIndex.value (a State) is what triggers the recomposition each mount.
key(vShown) {
vScreens[vIndex.value].content()
}
}
}
}
}
/* CURRENT resident set (MB): current RSS can DROP after GC, so it distinguishes a
true leak (ratchets up) from K/N allocator high-water, unlike getrusage's
monotonic peak. posix reads it from `ps`; mingw has no `ps`/popen and returns
-1 (the soak gate runs on macOS/Linux — see scripts/verify-mac.sh). */
internal expect fun currentResidentMb(): Long
private fun runNav3Test() {
val vShow = mutableStateOf(false)
nativeComposeWindow(
@@ -652,6 +774,65 @@ private fun runParagraphTest() {
}
}
/* Prints paragraph metrics (single-line cell, lineHeight-styled single + triple line,
first baseline) for a font-size sweep at density 1 — the native half of the
metrics-alignment probe. Mirror of MainJvm's `--metrics`; both must print the same
numbers for the parity text drift to vanish. */
private fun runMetricsProbe() {
fun paragraph(inText: String, inSize: Int, inLineHeight: Int?, inM3Style: Boolean): androidx.compose.ui.text.Paragraph =
androidx.compose.ui.text.Paragraph(
text = inText,
style = androidx.compose.ui.text.TextStyle(
fontSize = inSize.sp,
lineHeight = inLineHeight?.sp ?: androidx.compose.ui.unit.TextUnit.Unspecified,
lineHeightStyle = if (inM3Style) {
androidx.compose.ui.text.style.LineHeightStyle(
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
)
} else null,
),
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 10_000),
density = androidx.compose.ui.unit.Density(1f),
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
)
nativeComposeWindow(
title = "metricsprobe",
width = 300,
height = 200,
onFrame = { _, frameIndex ->
if (frameIndex == 10) {
for (vSize in listOf(11, 12, 14, 16, 22, 24)) {
val vLh = vSize + 6
val vCell = paragraph("Hg", vSize, null, false)
val vOne = paragraph("Hg", vSize, vLh, false)
val vThree = paragraph("Hg\nHg\nHg", vSize, vLh, false)
val vOneM3 = paragraph("Hg", vSize, vLh, true)
val vThreeM3 = paragraph("Hg\nHg\nHg", vSize, vLh, true)
println(
"metrics: size=$vSize lh=$vLh cell=${vCell.height} " +
"one=${vOne.height} three=${vThree.height} " +
"base1=${vOne.firstBaseline} base3=${vThree.lastBaseline} " +
"oneM3=${vOneM3.height} threeM3=${vThreeM3.height} base1M3=${vOneM3.firstBaseline}"
)
}
for ((vS, vL) in listOf(24 to 24, 24 to 25, 32 to 24, 16 to 16)) {
val vB = paragraph("Hg", vS, vL, true)
println("metrics: boundary $vS/$vL m3=${vB.height} base=${vB.firstBaseline}")
}
val vBig = paragraph("42", 48, 24, false)
val vBigM3 = paragraph("42", 48, 24, true)
println("metrics: big48/lh24 raw=${vBig.height} base=${vBig.firstBaseline} m3=${vBigM3.height} baseM3=${vBigM3.firstBaseline}")
println("metricsprobe: DONE")
false
} else true
},
) {
Box(modifier = Modifier.fillMaxSize()) {}
}
}
// ==================
// MARK: clicktest
// ==================
+5 -4
View File
@@ -13,9 +13,10 @@ import com.compose.sdl.GpuMode
sdl3.opengl | sdl3.metal | sdl3.vulkan |
sdl3.d3d11 | sdl3.d3d12 (default: auto)
--screen=Buttons | TextField | ... (default: full app w/ sidebar)
--screenshot=path.bmp capture after frames and quit
--screenshot=path.bmp capture at quiescence and quit
--width=W --height=H (default 1000 / 700)
--frames=N screenshot delay in frames
--frames=N quiescence cap: capture at frame N even if
the screen never settles (default 300)
Names match the Screen registry entries (case-insensitive). */
internal data class CliArgs(
@@ -24,7 +25,7 @@ internal data class CliArgs(
val screenshot: String? = null,
val width: Int = 1000,
val height: Int = 700,
val frames: Int = 6,
val maxFrames: Int = 300,
)
/* Translates the --gpu= string to the right GpuMode sealed instance.
@@ -62,7 +63,7 @@ internal fun parseArgs(argv: Array<String>): CliArgs {
"screenshot" -> vArgs.copy(screenshot = value)
"width" -> vArgs.copy(width = value.toIntOrNull() ?: vArgs.width)
"height" -> vArgs.copy(height = value.toIntOrNull() ?: vArgs.height)
"frames" -> vArgs.copy(frames = value.toIntOrNull() ?: vArgs.frames)
"frames" -> vArgs.copy(maxFrames = value.toIntOrNull() ?: vArgs.maxFrames)
else -> vArgs
}
}
@@ -45,20 +45,35 @@ dependencyResolutionManagement {
```
```kotlin
// module build.gradle.kts — official coords, everywhere
// module build.gradle.kts — official coords, everywhere.
// Applying the plugin HERE exposes the `composeDesktopNative` extension, which
// reports the exact Compose versions the port tracks, so you never hand-match
// them against compose.properties.
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("com.bitsycore.compose-desktop-native.bridge")
}
kotlin {
macosArm64(); linuxX64(); mingwX64() // + jvm()/android()/ios if you like
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.compose.runtime:runtime:1.11.1")
implementation("org.jetbrains.compose.ui:ui:<cmp-version>")
implementation("org.jetbrains.compose.foundation:foundation:<cmp-version>")
implementation("org.jetbrains.compose.material3:material3:<cmp-m3-version>")
implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
}
}
}
```
`composeDesktopNative` exposes four read-only values: `compose` (ui /
foundation / animation / material), `composeMaterial3` (versioned separately
upstream), `composeRuntime`, and `version` (the port klib version being
substituted). You can still write literal versions if you prefer; the extension
just removes the drift.
### Settings-wide or per-module
The plugin applies at either level:
@@ -80,6 +95,16 @@ version (so module-level applications can omit it); the top-level
Rule of thumb: single app module → apply in the module; multi-module builds →
apply once in settings.
When applied from settings, the type-safe `composeDesktopNative` accessor is not
generated in the module scripts. The same values are still available through
project extra properties:
```kotlin
val compose = project.extra["composeDesktopNative.compose"] as String
val material3 = project.extra["composeDesktopNative.composeMaterial3"] as String
val runtime = project.extra["composeDesktopNative.composeRuntime"] as String
```
## compose.desktop.native — the application block for native
The native counterpart of `compose.desktop { application { mainClass } }`:
@@ -122,10 +147,12 @@ overrides follow the default hierarchy (a `mingwX64Main` resource beats a
- The substituted klib version defaults to the plugin's own version (both ship
from the same tag). Override with `composeDesktopNative.version=<x>` in
`gradle.properties` when mixing releases.
- Match `<cmp-version>` to the CMP release the port's vendored sources track
(see `scripts/compose-fork/compose.properties` at the release tag) —
substitution replaces whatever version you request on native, but your
jvm/android targets resolve the official artifacts at the version you write.
- Use `composeDesktopNative.compose` / `.composeMaterial3` / `.composeRuntime`
(above) for the official coords rather than hardcoding a version. Substitution
replaces the requested version on native, but your jvm/android targets resolve
the official artifacts at the version you write, so matching what the port
tracks keeps every target on the same API. The values come straight from the
release the plugin shipped with; no need to read `compose.properties` by hand.
- Requires Gradle 8.8+ when applied in settings (`gradle.lifecycle.beforeProject`).
- App windowing/main-loop (`com.bitsycore.compose.sdl:window`) and the icon
font module (`material-symbols`) are the port's own APIs — depend on them
@@ -31,16 +31,30 @@ gradlePlugin {
}
// The plugin defaults the substitution version to ITS OWN version (plugin and
// klibs ship from the same tag), read at runtime from this generated resource.
// klibs ship from the same tag) and also ADVERTISES the official Compose
// Multiplatform versions the port tracks, so consumers declare the matching
// coords without reading compose.properties by hand. Both are read at runtime
// from this generated resource, sourced from the version catalog (single truth).
val vVersionDir = layout.buildDirectory.dir("generated/bridge-version")
val vGenerateVersion = tasks.register("generateBridgeVersionResource") {
val vVersion = project.version.toString()
val vCompose = libs.versions.compose.get()
val vComposeMaterial3 = libs.versions.composeMaterial3.get()
val vComposeRuntime = libs.versions.composeRuntime.get()
val vOut = vVersionDir.get().file("com/bitsycore/compose/sdl/gradle/bridge-version.properties").asFile
inputs.property("version", vVersion)
inputs.property("compose", vCompose)
inputs.property("composeMaterial3", vComposeMaterial3)
inputs.property("composeRuntime", vComposeRuntime)
outputs.dir(vVersionDir)
doLast {
vOut.parentFile.mkdirs()
vOut.writeText("version=$vVersion\n")
vOut.writeText(
"version=$vVersion\n" +
"compose=$vCompose\n" +
"composeMaterial3=$vComposeMaterial3\n" +
"composeRuntime=$vComposeRuntime\n"
)
}
}
sourceSets["main"].resources.srcDir(vVersionDir)
@@ -45,6 +45,45 @@ private const val portGroup = "com.bitsycore.compose.sdl"
/** Gradle property overriding the substitution version (default: the plugin's own). */
private const val versionProperty = "composeDesktopNative.version"
// ==================
// MARK: composeDesktopNative { } — version info exposed to the consumer
// ==================
/**
* Read-only info the bridge exposes to the consuming build as the project
* extension `composeDesktopNative`. Use it to declare the official Compose
* Multiplatform coordinates that match the port's vendored sources, so
* jvm/android targets stay aligned with what the native klibs were built from
* (no more reading compose.properties at the release tag by hand):
*
* ```kotlin
* commonMain.dependencies {
* implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
* implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
* implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
* implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
* }
* ```
*
* The same values are mirrored into `project.extra` (keys `composeDesktopNative.compose`,
* `.composeMaterial3`, `.composeRuntime`, `.version`) for builds that apply the
* plugin from settings, where the type-safe accessor is not generated.
*
* The substituted PORT klib version is overridden with the
* `composeDesktopNative.version` Gradle property (see [version]); this extension
* only reports the effective value.
*/
open class ComposeDesktopNativeBridgeExtension(
/** The port klib version substituted on native targets (after the `composeDesktopNative.version` override). */
val version: String,
/** Official Compose version for ui / foundation / animation / material. */
val compose: String,
/** Official material3 version (versioned separately upstream). */
val composeMaterial3: String,
/** Official Compose runtime version (never substituted; serves every target). */
val composeRuntime: String,
)
// ==================
// MARK: Plugin
// ==================
@@ -58,7 +97,9 @@ private const val versionProperty = "composeDesktopNative.version"
*
* Apply in settings.gradle.kts (covers every project) or in a single module's
* build.gradle.kts. The substituted version defaults to the plugin's own
* version — override with `composeSdl.version` in gradle.properties.
* version — override with `composeDesktopNative.version` in gradle.properties.
* The official Compose versions the port tracks are exposed via the
* `composeDesktopNative` extension (see [ComposeDesktopNativeBridgeExtension]).
*/
class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
@@ -81,7 +122,30 @@ class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
private fun installBridge(project: Project) {
installResourcePackaging(project)
installNativeApplicationDsl(project)
val version = project.providers.gradleProperty(versionProperty).orNull ?: pluginVersion
// The effective port version: the override property, else the plugin's own.
val version = project.providers.gradleProperty(versionProperty).orNull ?: bridgeProperty("version")
// Advertise the Compose versions the port tracks so consumers align their
// official coords without guessing. Extension for the common per-module apply
// (type-safe accessor); extra properties for the settings apply (no accessor).
if (project.extensions.findByName("composeDesktopNative") == null) {
project.extensions.create(
"composeDesktopNative",
ComposeDesktopNativeBridgeExtension::class.java,
version,
bridgeProperty("compose"),
bridgeProperty("composeMaterial3"),
bridgeProperty("composeRuntime"),
)
}
project.extensions.extraProperties.let { extra ->
extra["composeDesktopNative.version"] = version
extra["composeDesktopNative.compose"] = bridgeProperty("compose")
extra["composeDesktopNative.composeMaterial3"] = bridgeProperty("composeMaterial3")
extra["composeDesktopNative.composeRuntime"] = bridgeProperty("composeRuntime")
}
project.configurations.configureEach { configuration ->
if (nativeTargetTokens.any { configuration.name.contains(it, ignoreCase = true) }) {
configuration.resolutionStrategy.dependencySubstitution { substitutions ->
@@ -98,12 +162,17 @@ private fun installBridge(project: Project) {
}
}
/** The plugin's own version, stamped into a resource at build time. */
private val pluginVersion: String by lazy {
/* The plugin's own version + the Compose versions it tracks, stamped into a
resource at build time (from the version catalog). `version` is the plugin's
own release; the rest are the official CMP versions the port's vendored
sources build against. */
private val bridgeProperties: Properties by lazy {
val stream = ComposeDesktopNativeBridgePlugin::class.java
.getResourceAsStream("bridge-version.properties")
?: throw GradleException("com.bitsycore.compose-desktop-native.bridge: missing bridge-version.properties resource")
val properties = Properties().apply { stream.use { load(it) } }
properties.getProperty("version")
?: throw GradleException("com.bitsycore.compose-desktop-native.bridge: bridge-version.properties has no 'version'")
Properties().apply { stream.use { load(it) } }
}
private fun bridgeProperty(key: String): String =
bridgeProperties.getProperty(key)
?: throw GradleException("com.bitsycore.compose-desktop-native.bridge: bridge-version.properties has no '$key'")
+11 -1
View File
@@ -1,6 +1,16 @@
[versions]
kotlin = "2.4.0"
compose = "1.12.0-beta01"
# Official Compose Multiplatform versions the port's vendored sources track and
# the JVM parity leg forces. SINGLE SOURCE: consumed by the bridge plugin
# (stamped into bridge-version.properties and exposed to consumers) and by
# demo / apidemo / material-symbols. Keep in lockstep with the vendored ref in
# scripts/compose-fork/compose.properties on each bump.
# compose = ui / foundation / animation / material
# composeMaterial3 = material3 (versioned separately upstream)
# composeRuntime = the official runtime klibs (never substituted)
compose = "1.12.0-beta02"
composeMaterial3 = "1.12.0-alpha03"
composeRuntime = "1.11.1"
kotlinxCoroutines = "1.11.0"
kotlinxDateTime = "0.8.0"
kotlinxSerialization = "1.11.0"
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
# Vendor-clean check (P0.7, RENDERER.md) - fails if src/vendor/ has drifted from
# what the pinned upstream refs produce.
#
# src/vendor/ is REGENERATED OUTPUT (gitignored): nothing hand-made may live there. A
# hand-edit that leaks in "works on my machine" but silently diverges from every clean
# checkout, and is overwritten by the next sync. This check makes that loud:
#
# 1. hash every file under each module's src/vendor/ (and snapshot each compose-fork.txt)
# 2. run sync.py (regenerates the trees byte-for-byte from the pinned clones,
# deleting stale files and re-annotating manifests)
# 3. re-hash and diff: ANY change/add/remove = the local tree did not match the pin
#
# On failure the tree has already been RESTORED to the pinned state (that is sync.py's
# normal behaviour) - the report tells you what drifted. If the edit was intentional,
# manual-vendor it instead (CLAUDE.md vendoring rule 3: move the file out of src/vendor/,
# comment its manifest line out, re-sync). Manifest churn means the annotations were
# stale - commit the refreshed manifest.
#
# Usage (from anywhere; ASCII-only output for cp1252 consoles):
# python scripts/compose-fork/check-vendor-clean.py
# Exit: 0 clean · 1 drift found (tree now restored) · 2 sync itself failed
import hashlib
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
PRUNE_DIRS = {'build', '.gradle', '.git', 'node_modules', 'scripts'}
# ============
# Every module dir carrying a compose-fork.txt (same discovery rules as sync.py).
def module_dirs():
found = []
for dirpath, dirs, files in os.walk(REPO_ROOT):
dirs[:] = [d for d in dirs if d not in PRUNE_DIRS]
if 'compose-fork.txt' in files:
found.append(dirpath)
return sorted(found)
# ============
# {repo-relative-path: sha1} over one directory tree (empty dict if absent).
def hash_tree(root):
out = {}
if not os.path.isdir(root):
return out
for dr, _, fs in os.walk(root):
for fn in fs:
p = os.path.join(dr, fn)
with open(p, 'rb') as fh:
digest = hashlib.sha1(fh.read()).hexdigest()
out[os.path.relpath(p, REPO_ROOT).replace(os.sep, '/')] = digest
return out
def read_text(path):
with open(path, 'rb') as fh:
return fh.read()
def main():
modules = module_dirs()
if not modules:
sys.stderr.write('check-vendor-clean: no compose-fork.txt manifests found\n')
return 2
# ---- 1. pre-sync snapshot: vendor hashes + manifest bytes
before = {}
manifests_before = {}
for mod in modules:
before.update(hash_tree(os.path.join(mod, 'src', 'vendor')))
mf = os.path.join(mod, 'compose-fork.txt')
manifests_before[mf] = read_text(mf)
print('check-vendor-clean: %d vendored files across %d modules - running sync.py'
% (len(before), len(modules)))
# ---- 2. regenerate from the pinned refs
rc = subprocess.run([sys.executable, os.path.join(HERE, 'sync.py')], cwd=REPO_ROOT).returncode
if rc != 0:
sys.stderr.write('check-vendor-clean: sync.py failed (rc=%d) - cannot judge cleanliness\n' % rc)
return 2
# ---- 3. post-sync diff
after = {}
for mod in modules:
after.update(hash_tree(os.path.join(mod, 'src', 'vendor')))
changed = sorted(p for p in before.keys() & after.keys() if before[p] != after[p])
removed = sorted(before.keys() - after.keys())
added = sorted(after.keys() - before.keys())
stale_manifests = sorted(
os.path.relpath(mf, REPO_ROOT).replace(os.sep, '/')
for mf, text in manifests_before.items() if read_text(mf) != text)
ok = True
if changed or removed or added:
ok = False
print('\ncheck-vendor-clean: FAIL - src/vendor/ did not match the pinned refs '
'(now restored by the sync):')
for p in changed:
print(' changed : %s' % p)
for p in removed:
print(' stale : %s (removed - no manifest entry produces it)' % p)
for p in added:
print(' missing : %s (recreated - was absent locally)' % p)
print('src/vendor/ is regenerated output - never hand-edit it. Intentional edits '
'must be MANUAL-VENDORED (move out of src/vendor/, comment the manifest line, '
're-sync); see CLAUDE.md vendoring rule 3.')
if stale_manifests:
ok = False
print('\ncheck-vendor-clean: FAIL - manifest annotations were stale (sync refreshed '
'them; review + commit):')
for p in stale_manifests:
print(' manifest: %s' % p)
if ok:
print('check-vendor-clean: PASS - src/vendor/ matches the pinned refs '
'(%d files) and all manifests are stable' % len(after))
return 0
return 1
if __name__ == '__main__':
sys.exit(main())
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Vendor-drift tripwire (RENDERER.md P0.6 / §8).
Manually-vendored files (copy-edited from upstream, NON-IDEMPOTENT) carry a
machine-readable provenance line near their header:
// VENDOR-BASE: <upstream-repo-relative-path> @ <commit-or-tag>
// VENDOR-BASE(<VARNAME>): <path> @ <ref> for a non-core repo, naming the
compose.properties pin it tracks
(e.g. COMPOSE_REF for the umbrella repo)
recording the exact upstream ref the file was last reconciled against. This script
compares each to the CURRENT pin for its repo (COMPOSE_CORE_REF by default) from
scripts/compose-fork/compose.properties. If they differ, the manual vendor MAY be
stale — upstream could have changed that file since it was reconciled — and must be
re-checked by hand. Exits non-zero if any file is stale, so it can gate a ref bump.
Deeper (optional) check: if the repo's local clone is found (../cmp-ref[-core] for the
core repo, ../cmp-ref-compose-multiplatform for COMPOSE_REF), it runs
`git diff <base>..<pin> -- <path>` and reports whether the upstream file ACTUALLY changed
between the recorded base and the current pin — turning "might be stale" into
"did / didn't change".
Run at each pin bump (see RENDERER.md §9 ref-bump runbook).
No third-party deps; Windows / macOS / Linux.
"""
import re, sys, subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
PROPS = REPO / "scripts" / "compose-fork" / "compose.properties"
MARKER = re.compile(r"VENDOR-BASE(?:\(([A-Z_][A-Z0-9_]*)\))?:\s*(\S+)\s*@\s*(\S+)")
# Pin variable -> candidate clone dir names (sibling of the repo checkout).
CLONES = {
"COMPOSE_CORE_REF": ("cmp-ref", "cmp-ref-core"),
"COMPOSE_REF": ("cmp-ref-compose-multiplatform",),
}
def read_pins() -> dict:
pins = {}
for line in PROPS.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s and not s.startswith("#") and "=" in s:
name, val = s.split("=", 1)
pins[name.strip()] = val.strip()
if "COMPOSE_CORE_REF" not in pins:
raise SystemExit("check-vendor-drift: COMPOSE_CORE_REF not found in compose.properties")
return pins
def find_clone(var: str):
for name in CLONES.get(var, ()):
p = REPO.parent / name
if (p / ".git").exists():
return p
return None
def upstream_changed(clone: Path, path: str, base: str, pin: str):
"""True if <path> differs between base..pin in the clone; None if it couldn't run
(including the path not existing at the pin — a rename/move needs human eyes)."""
try:
present = subprocess.run(
["git", "-C", str(clone), "ls-tree", "--name-only", pin, "--", path],
capture_output=True, text=True,
)
if present.returncode != 0 or not present.stdout.strip():
return None # ref missing locally or path gone at the pin
r = subprocess.run(
["git", "-C", str(clone), "diff", "--quiet", f"{base}..{pin}", "--", path],
capture_output=True,
)
return r.returncode != 0 # `git diff --quiet` exits 1 when there ARE differences
except Exception:
return None
def main() -> int:
pins = read_pins()
entries = []
for f in REPO.rglob("*.kt"):
if "/src/vendor/" in f.as_posix():
continue # gitignored verbatim re-sync tree — not manual vendors
try:
text = f.read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
for m in MARKER.finditer(text):
var = m.group(1) or "COMPOSE_CORE_REF"
entries.append((f, var, m.group(2), m.group(3)))
if not entries:
print("check-vendor-drift: no VENDOR-BASE annotations found.")
return 0
used_vars = sorted({var for _, var, _, _ in entries})
for var in used_vars:
print(f"check-vendor-drift: pin {var} = {pins.get(var, '(NOT IN compose.properties!)')}"
f" clone = {find_clone(var) or '(none — ref-compare only)'}")
print()
stale = []
for f, var, path, base in sorted(entries, key=lambda e: e[0].as_posix()):
rel = f.relative_to(REPO).as_posix()
pin = pins.get(var)
if pin is None:
stale.append(rel)
print(f" STALE {rel}\n tracks unknown pin variable {var!r}")
continue
if base == pin:
print(f" OK {rel} (base == pin {var})")
continue
note = ""
clone = find_clone(var)
if clone:
ch = upstream_changed(clone, path, base, pin)
note = (" <-- upstream CHANGED base..pin; RECONCILE" if ch is True
else " (upstream unchanged base..pin; safe to bump the VENDOR-BASE ref)"
if ch is False else " (clone diff unavailable)")
stale.append(rel)
print(f" STALE {rel}\n base @ {base}\n pin @ {pin} ({var}){note}")
if stale:
print(f"\ncheck-vendor-drift: {len(stale)} stale manual-vendor(s). Reconcile each against "
f"its pinned upstream, then update its VENDOR-BASE ref.")
return 1
print("\ncheck-vendor-drift: all manual vendors match their pins. OK.")
return 0
if __name__ == "__main__":
sys.exit(main())
+10 -3
View File
@@ -2,8 +2,15 @@
# module's compose-fork.txt as SET_REPO=<url>@<VARNAME>. Bump a ref here and
# re-run scripts/compose-fork/sync.sh to re-sync everything to it.
# JetBrains/compose-multiplatform-core (tag v1.12.0-beta01+dev4324)
COMPOSE_CORE_REF=1be9d64ad12db0c79f44aedf9d9388e644c60871
# JetBrains/compose-multiplatform-core -- pinned to the durable TAG (fetchable by name
# from any fresh clone, unlike a bare unreferenced SHA which upstream could GC).
# 2026-07-18 O.2 bump: beta02 -> v1.12.0-beta03+dev4483. No CLEAN beta03/rc/stable tag
# exists on either repo yet, and beta03 is NOT on Maven Central, so this is the latest
# DURABLE (fetchable-by-name tag) ref. VENDORED (native) side only: the JVM parity leg's
# Maven forcing (compose 1.12.0-beta02 / m3 1.12.0-alpha03, latest PUBLISHED) stays put --
# a documented native(beta03+dev) vs JVM(beta02) skew until beta03 hits Maven Central, at
# which point bump the vComposeJvmVersion forcing in demo/apidemo/material-symbols to match.
COMPOSE_CORE_REF=v1.12.0-beta03+dev4483
# JetBrains/compose-multiplatform (umbrella repo -- :components-resources runtime)
COMPOSE_REF=v1.12.0-beta01
COMPOSE_REF=v1.12.0-beta03+dev4483
+8 -2
View File
@@ -359,8 +359,14 @@ def ensure_clone(url, ref, sparse_dirs):
git(clone_dir, ['sparse-checkout', 'set', *sparse_dirs], quiet=True)
if git(clone_dir, ['checkout', '-q', ref], quiet=True, check=False).returncode != 0:
print('ref %s not present locally -- fetching' % ref)
git(clone_dir, ['fetch', 'origin', ref])
git(clone_dir, ['checkout', '-q', ref])
# Try as a TAG first, mapping it to a local tag ref -- a plain `fetch origin <tag>`
# only lands in FETCH_HEAD, so the checkout below would fail and every future sync
# would need the network again. Branches / bare SHAs fall back to the plain fetch.
if git(clone_dir, ['fetch', 'origin', 'refs/tags/%s:refs/tags/%s' % (ref, ref)],
quiet=True, check=False).returncode != 0:
git(clone_dir, ['fetch', 'origin', ref])
if git(clone_dir, ['checkout', '-q', ref], quiet=True, check=False).returncode != 0:
git(clone_dir, ['checkout', '-q', 'FETCH_HEAD'])
desc = subprocess.run(['git', '-C', clone_dir, 'describe', '--tags', '--always'],
capture_output=True, text=True).stdout.strip()
print('upstream %s @ %s' % (url.rstrip('/').split('/')[-1], desc))
+179
View File
@@ -0,0 +1,179 @@
{
"macosArm64/sdl3": {
"Animation": 3.11,
"AnnotatedString": 7.46,
"AppBars": 1.99,
"BasicText": 4.62,
"BasicTextField": 4.99,
"Brushes": 2.18,
"Buttons": 1.92,
"ButtonsExtra": 2.24,
"Canvas": 5.03,
"Cards": 2.28,
"Carousel": 2.06,
"Chips": 1.62,
"Clipboard": 2.94,
"Colors": 0.68,
"Counter": 0.65,
"CustomLayout": 19.13,
"Dialogs": 2.54,
"DragAndDrop": 1.85,
"Drawers": 1.3,
"Fab": 1.94,
"FabExtra": 1.35,
"FlowLayout": 2.46,
"FocusRequester": 2.85,
"Gestures": 2.68,
"GraphicsLayer": 4.7,
"GridsExtra": 1.29,
"Icons": 1.29,
"Images": 16.81,
"Interaction": 1.41,
"InteractionSource": 2.96,
"Layout": 2.12,
"LazyColumn": 2.7,
"LazyExtra": 2.91,
"LazyGrid": 1.67,
"Lists": 2.45,
"M3Misc": 1.65,
"MaterialSymbols": 2.99,
"ModShortcuts": 2.27,
"Modifiers": 2.28,
"NavRails": 1.38,
"Navigation": 1.79,
"Navigation3": 2.71,
"Pager": 0.98,
"Path": 6.22,
"Pickers": 19.47,
"PointerInput": 2.68,
"Recomposition": 1.51,
"Remember": 1.74,
"Scroll": 2.82,
"Search": 4.5,
"Shadows": 1.49,
"Shapes": 1.34,
"Sheets": 1.35,
"Tabs": 1.88,
"Text": 3.37,
"TextField": 1.95,
"Widgets": 2.21
},
"macosArm64/skia": {
"Animation": 3.11,
"AnnotatedString": 7.46,
"AppBars": 1.99,
"BasicText": 4.62,
"BasicTextField": 4.99,
"Brushes": 2.18,
"Buttons": 1.92,
"ButtonsExtra": 2.24,
"Canvas": 5.03,
"Cards": 2.28,
"Carousel": 2.06,
"Chips": 1.62,
"Clipboard": 2.94,
"Colors": 0.68,
"Counter": 0.65,
"CustomLayout": 19.13,
"Dialogs": 2.54,
"DragAndDrop": 1.85,
"Drawers": 1.3,
"Fab": 1.94,
"FabExtra": 1.35,
"FlowLayout": 2.46,
"FocusRequester": 2.85,
"Gestures": 2.68,
"GraphicsLayer": 4.65,
"GridsExtra": 1.29,
"Icons": 1.29,
"Images": 16.81,
"Interaction": 1.41,
"InteractionSource": 2.96,
"Layout": 2.12,
"LazyColumn": 2.7,
"LazyExtra": 2.91,
"LazyGrid": 1.67,
"Lists": 2.45,
"M3Misc": 1.65,
"MaterialSymbols": 2.99,
"ModShortcuts": 2.27,
"Modifiers": 2.28,
"NavRails": 1.38,
"Navigation": 1.79,
"Navigation3": 2.71,
"Pager": 0.98,
"Path": 6.22,
"Pickers": 19.47,
"PointerInput": 2.68,
"Recomposition": 1.51,
"Remember": 1.74,
"Scroll": 2.82,
"Search": 4.5,
"Shadows": 1.49,
"Shapes": 1.34,
"Sheets": 1.35,
"Tabs": 1.88,
"Text": 3.37,
"TextField": 1.95,
"Widgets": 2.21
},
"mingwX64/sdl3": {
"Animation": 3.0,
"AnnotatedString": 8.13,
"AppBars": 1.96,
"BasicText": 2.81,
"BasicTextField": 1.82,
"Brushes": 2.21,
"Buttons": 1.9,
"ButtonsExtra": 2.19,
"Canvas": 4.3,
"Cards": 2.29,
"Carousel": 1.9,
"Chips": 1.59,
"Clipboard": 3.03,
"Colors": 0.65,
"Counter": 0.58,
"CustomLayout": 4.1,
"Dialogs": 2.64,
"DragAndDrop": 1.84,
"Drawers": 1.38,
"Fab": 1.86,
"FabExtra": 1.38,
"FlowLayout": 2.34,
"FocusRequester": 2.84,
"Gestures": 2.62,
"GraphicsLayer": 2.46,
"GridsExtra": 1.33,
"Icons": 1.3,
"Images": 16.61,
"Interaction": 1.44,
"InteractionSource": 2.87,
"Layout": 2.04,
"LazyColumn": 3.46,
"LazyExtra": 2.85,
"LazyGrid": 1.62,
"Lists": 2.33,
"M3Misc": 1.67,
"MaterialSymbols": 2.91,
"ModShortcuts": 1.87,
"Modifiers": 2.3,
"NavRails": 1.35,
"Navigation": 1.68,
"Navigation3": 2.25,
"Pager": 1.0,
"Path": 3.56,
"Pickers": 19.47,
"PointerInput": 2.77,
"Recomposition": 1.48,
"Remember": 1.72,
"Scroll": 2.78,
"Search": 5.27,
"Shadows": 1.53,
"Shapes": 1.31,
"Sheets": 1.4,
"Tabs": 1.77,
"Text": 3.08,
"TextField": 1.93,
"Widgets": 2.2
}
}
+146 -20
View File
@@ -1,20 +1,23 @@
#!/usr/bin/env python3
"""
Parity harness — render every demo screen on the NATIVE (SDL/Skia) stack and on
Parity harness - render every demo screen on the NATIVE (SDL/Skia) stack and on
the JVM upstream-Compose stack, then pixel-diff them per screen.
The two stacks share the exact same commonMain screen composables, so a screen
that suddenly diverges from its usual difference level is a PORT REGRESSION
(missing content, wrong shape/colour, broken clip). Absolute pixel-perfection
is NOT the goal — fonts differ between stacks, so text-heavy screens carry a
is NOT the goal - fonts differ between stacks, so text-heavy screens carry a
steady baseline difference. The signal is the RANKING: a screen that jumps from
~8% to ~60% different is the bug. Four of the renderer regressions this project
hit would have surfaced here.
Usage (from repo root):
python scripts/parity/parity.py # all screens
python scripts/parity/parity.py # all screens, host's native target
python scripts/parity/parity.py Buttons Shapes # a subset
python scripts/parity/parity.py --no-build # reuse existing screenshots
python scripts/parity/parity.py --renderer=sdl3 # force the SDL leg (default on mingw;
# on macOS/Linux this -Prenderer=sdl3 build)
python scripts/parity/parity.py --target=macosArm64 # cross/explicit native target
Outputs to build/parity/ (gitignored):
<pct>_<Name>_compare.png native | jvm | amplified-diff, side by side
@@ -22,16 +25,42 @@ Outputs to build/parity/ (gitignored):
report.txt ranked table
The <pct> prefix is zero-padded so a plain file listing sorts worst-first.
Windows-only for the native leg today (mingwX64 exe). Needs Pillow.
Native uses the SDL renderer; pass --gpu to change.
TARGET-AWARE (P0.1): the native leg runs on whatever host you invoke it from - Windows
(mingwX64, always the SDL renderer), or macOS/Linux (default the Skia renderer, or the SDL
renderer under --renderer=sdl3). macOS/Linux run BOTH renderers, so one Mac verifies both
legs. Needs Pillow.
"""
import subprocess, sys, os, shutil
import subprocess, sys, os, shutil, platform, json
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "parity"
NATIVE_EXE = REPO / "demo" / "build" / "bin" / "mingwX64" / "debugExecutable" / "demo.exe"
# native target -> (gradle link-task suffix, K/N executable filename).
TARGETS = {
"mingwX64": ("MingwX64", "demo.exe"),
"macosArm64": ("MacosArm64", "demo.kexe"),
"macosX64": ("MacosX64", "demo.kexe"),
"linuxX64": ("LinuxX64", "demo.kexe"),
"linuxArm64": ("LinuxArm64", "demo.kexe"),
}
def host_target() -> str:
s, m = platform.system(), platform.machine().lower()
arm = m in ("arm64", "aarch64")
if s == "Windows": return "mingwX64"
if s == "Darwin": return "macosArm64" if arm else "macosX64"
if s == "Linux": return "linuxArm64" if arm else "linuxX64"
raise SystemExit(f"parity: unsupported host {s}/{m}")
def native_exe(target: str) -> Path:
_, exe = TARGETS[target]
return REPO / "demo" / "build" / "bin" / target / "debugExecutable" / exe
WIDTH, HEIGHT = 1000, 700
# Per-channel tolerance: below this a pixel counts as "same" (JPEG-ish noise,
# sub-pixel AA, font hinting). Tuned so unrelated screens sit well under it.
@@ -45,26 +74,50 @@ def run(cmd, **kw):
return subprocess.run(cmd, cwd=REPO, **kw)
def build():
run([GRADLEW, ":demo:linkDebugExecutableMingwX64", "--console=plain"], check=True)
def build(target: str, renderer: str):
suffix, _ = TARGETS[target]
cmd = [GRADLEW, f":demo:linkDebugExecutable{suffix}", "--console=plain"]
# macOS/Linux pick the renderer at BUILD time via -Prenderer; mingw is always sdl3
# (no property needed, and omitting it keeps the default Windows invocation stable).
if renderer == "sdl3" and target != "mingwX64":
cmd.append("-Prenderer=sdl3")
run(cmd, check=True)
def jvm_shots(dst: Path):
run([GRADLEW, ":demo:run", f"--args=--screenshot-all={dst}", "--console=plain"], check=True)
def native_shot(name: str, dst: Path, gpu: str):
def native_shot(name: str, dst: Path, exe: Path, gpu: str):
bmp = dst / f"{name}.bmp"
run([str(NATIVE_EXE), f"--screen={name}", f"--screenshot={bmp}",
f"--gpu={gpu}", f"--width={WIDTH}", f"--height={HEIGHT}"],
check=False, timeout=60)
cmd = [str(exe), f"--screen={name}", f"--screenshot={bmp}",
f"--width={WIDTH}", f"--height={HEIGHT}"]
if gpu: # runtime driver within the renderer; omit to let the app pick its default
cmd.append(f"--gpu={gpu}")
run(cmd, check=False, timeout=60)
if bmp.exists():
Image.open(bmp).convert("RGB").save(dst / f"{name}.png")
bmp.unlink()
def align_native(native: Image.Image, jvm: Image.Image) -> Image.Image:
"""Normalise the native render to the JVM reference's dimensions.
The native leg draws at the machine's physical pixel density (2000x1400 on a
Retina Mac, DPR 2.0), while the JVM ImageComposeScene reference is fixed at
density 1.0 (1000x700). Both represent the SAME 1000x700-dp viewport, so a
proportional comparison must resize native down to the reference size rather
than crop it (a top-left crop would compare only a 2x-magnified quarter — the
old behaviour, invisible on Windows where both legs were already 1000x700).
A no-op when the sizes already match."""
if native.size != jvm.size:
return native.resize(jvm.size, Image.LANCZOS)
return native
def diff_pair(native: Image.Image, jvm: Image.Image):
"""Return (percent_differing, amplified_diff_image)."""
native = align_native(native, jvm)
w = min(native.width, jvm.width)
h = min(native.height, jvm.height)
a = native.crop((0, 0, w, h)).convert("RGB")
@@ -80,6 +133,7 @@ def diff_pair(native: Image.Image, jvm: Image.Image):
def side_by_side(native, jvm, diff, path):
native = align_native(native, jvm)
w = min(native.width, jvm.width)
h = min(native.height, jvm.height)
canvas = Image.new("RGB", (w * 3 + 20, h + 20), (16, 16, 16))
@@ -89,11 +143,51 @@ def side_by_side(native, jvm, diff, path):
canvas.save(path)
# P0.4 (RENDERER.md §8): per-(target/renderer) golden baselines so parity GATES
# (non-zero exit) instead of only ranking. A screen regresses if it exceeds its baseline by
# more than max(ABS_MARGIN pts, baseline*REL_MARGIN) - tolerant of run-to-run AA jitter,
# strict on a real jump. `--update-baselines` reseeds the current target/renderer.
BASELINES = REPO / "scripts" / "parity" / "baselines.json"
ABS_MARGIN = 3.0 # percentage points
REL_MARGIN = 0.25 # +25% of the baseline
def load_baselines() -> dict:
if BASELINES.exists():
try:
return json.loads(BASELINES.read_text(encoding="utf-8"))
except Exception:
print("parity: baselines.json unreadable - treating as empty", file=sys.stderr)
return {}
def threshold(base: float) -> float:
return base + max(ABS_MARGIN, base * REL_MARGIN)
def main():
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
flags = [a for a in sys.argv[1:] if a.startswith("--")]
no_build = "--no-build" in flags
gpu = next((f.split("=", 1)[1] for f in flags if f.startswith("--gpu=")), "sdl3")
target = next((f.split("=", 1)[1] for f in flags if f.startswith("--target=")), host_target())
if target not in TARGETS:
print(f"parity: unknown --target={target} (known: {', '.join(TARGETS)})", file=sys.stderr)
return 2
# Renderer: mingw is always SDL; macOS/Linux default to Skia unless --renderer=sdl3.
renderer = next((f.split("=", 1)[1] for f in flags if f.startswith("--renderer=")),
"sdl3" if target == "mingwX64" else "skia")
# Runtime GPU driver: default "sdl3" for the SDL renderer; omit for Skia (app default,
# e.g. Metal on macOS). Explicit --gpu= always wins.
gpu = next((f.split("=", 1)[1] for f in flags if f.startswith("--gpu=")),
"sdl3" if renderer == "sdl3" else "")
exe = native_exe(target)
update_baselines = "--update-baselines" in flags
baseline_key = f"{target}/{renderer}"
baselines = load_baselines()
base_for_key = baselines.get(baseline_key, {})
print(f"parity: target={target} renderer={renderer} gpu={gpu or '(default)'} exe={exe}")
print(f"parity: baseline key '{baseline_key}' - {len(base_for_key)} screen(s) baselined"
f"{' [--update-baselines: WILL RESEED]' if update_baselines else ''}")
OUT.mkdir(parents=True, exist_ok=True)
jvm_dir = OUT / "_jvm"
@@ -103,13 +197,13 @@ def main():
old.unlink()
if not no_build:
build()
build(target, renderer)
if jvm_dir.exists():
shutil.rmtree(jvm_dir)
jvm_dir.mkdir(parents=True)
jvm_shots(jvm_dir)
# Screen set: the JVM run enumerates the full registry → use its PNGs as the
# Screen set: the JVM run enumerates the full registry -> use its PNGs as the
# source of truth for names (native takes one exe launch each).
names = sorted(p.stem for p in jvm_dir.glob("*.png"))
if argv:
@@ -123,7 +217,7 @@ def main():
for name in names:
jvm_png = jvm_dir / f"{name}.png"
if not no_build or not (OUT / f"{name}.native.png").exists():
native_shot(name, OUT, gpu)
native_shot(name, OUT, exe, gpu)
src = OUT / f"{name}.png"
if src.exists():
src.replace(OUT / f"{name}.native.png")
@@ -134,21 +228,53 @@ def main():
native = Image.open(native_png).convert("RGB")
jvm = Image.open(jvm_png).convert("RGB")
pct, amp = diff_pair(native, jvm)
# Zero-padded pct prefix → worst-first in any file listing.
# Zero-padded pct prefix -> worst-first in any file listing.
prefix = f"{pct:06.2f}"
amp.save(OUT / f"{prefix}_{name}_diff.png")
side_by_side(native, jvm, amp, OUT / f"{prefix}_{name}_compare.png")
results.append((name, pct))
print(f" {name:28s} {pct:6.2f}% differing")
# --update-baselines: write the current run as the golden baseline for this key.
if update_baselines:
baselines[baseline_key] = {n: round(p, 2) for n, p in results if p is not None}
BASELINES.write_text(json.dumps(baselines, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"\nparity: reseeded {len(baselines[baseline_key])} baseline(s) for "
f"'{baseline_key}' -> {BASELINES}")
return 0
results.sort(key=lambda r: (-1 if r[1] is None else r[1]), reverse=True)
lines = ["screen %differ", "-" * 40]
regressions = []
lines = [f"screen %differ baseline verdict ({baseline_key})", "-" * 66]
for name, pct in results:
lines.append(f"{name:28s} {'NATIVE FAILED' if pct is None else f'{pct:6.2f}%'}")
base = base_for_key.get(name)
if pct is None:
verdict = "NATIVE FAILED"
regressions.append(name)
lines.append(f"{name:28s} {'-':>7} {base if base is None else f'{base:6.2f}%':>8} {verdict}")
continue
if base is None:
verdict = "(no baseline)"
elif pct > threshold(base):
verdict = f"REGRESSION (+{pct - base:.2f})"
regressions.append(name)
else:
verdict = "ok"
base_s = " - " if base is None else f"{base:6.2f}%"
lines.append(f"{name:28s} {pct:6.2f}% {base_s:>8} {verdict}")
report = "\n".join(lines)
(OUT / "report.txt").write_text(report + "\n", encoding="utf-8")
print("\n" + report)
print(f"\nDiff images + report in {OUT}")
if not base_for_key:
print(f"\nparity: NO baselines for '{baseline_key}' - not gating. "
f"Seed with: python scripts/parity/parity.py --update-baselines")
return 0
if regressions:
print(f"\nparity: FAIL - {len(regressions)} regression(s): {', '.join(regressions)}")
return 1
print(f"\nparity: PASS - no screen exceeds its baseline (+{ABS_MARGIN}pts / +{REL_MARGIN:.0%}).")
return 0
+35
View File
@@ -0,0 +1,35 @@
# Probe driver
Launches a native compose-desktop-native app window, sends **window-relative**
synthetic input, and captures the client area — for reproducing visual bugs
deterministically (this is the packaged form of the rigs that caught the
square-on-click and TLS-chain regressions).
Why window-relative + PrintWindow: input coordinates are FRACTIONS of the
window client rect and addressed by process name, so a probe doesn't depend on
where the window landed or on it being foreground; capture uses `PrintWindow`,
which grabs the window even when occluded or unfocused (a plain screen grab
would catch whatever's on top).
```bash
# screenshot a demo screen
python scripts/probe/probe.py demo --screen=Images --shot images.png
# press-and-hold a point (fraction of the window) and capture DURING the press
python scripts/probe/probe.py demo --screen=Buttons --hold 0.1,0.32 --shot press.png
# hover then capture
python scripts/probe/probe.py demo --screen=Shapes --hover 0.3,0.45 --shot hov.png
# an app built elsewhere (e.g. the bridge example)
python scripts/probe/probe.py shared --exe /path/to/shared.exe --click 0.5,0.5 --shot x.png
```
- `proc` = the window's process name (`demo`, `apidemo`, `shared`).
- Actions run in order; `--hold` captures mid-press (async), `--click` /
`--hover` complete first. `--shot` captures after `--settle` seconds.
- Windows-only (uses Win32 input + PrintWindow). Pillow only needed if you
post-process the PNG.
Pairs with `scripts/parity/` (whole-screen diffing); this is for targeted
interaction repro.
+28
View File
@@ -0,0 +1,28 @@
param([string]$proc, [string]$out)
# Capture a window's CLIENT area by process name, via PrintWindow — works even
# when the window is occluded or not focused (unlike a screen grab). Used by the
# probe driver to screenshot native app windows deterministically.
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Cap {
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
public struct RECT { public int L, T, R, B; }
}
"@
$h = (Get-Process $proc -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).MainWindowHandle
if (-not $h -or $h -eq [IntPtr]::Zero) { Write-Output "NO WINDOW"; exit 1 }
$r = New-Object Cap+RECT
[Cap]::GetClientRect($h, [ref]$r) | Out-Null
$w = $r.R - $r.L; $ht = $r.B - $r.T
if ($w -le 0 -or $ht -le 0) { Write-Output "ZERO SIZE"; exit 1 }
$bmp = New-Object System.Drawing.Bitmap $w, $ht
$g = [System.Drawing.Graphics]::FromImage($bmp)
$hdc = $g.GetHdc()
# flags=3 : PW_CLIENTONLY | PW_RENDERFULLCONTENT (captures accelerated content)
[Cap]::PrintWindow($h, $hdc, 3) | Out-Null
$g.ReleaseHdc($hdc)
$bmp.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
Write-Output "captured ${w}x${ht}"
+42
View File
@@ -0,0 +1,42 @@
param(
[string]$proc, # process name of the target window
[string]$action, # click | hold | hover | move
[double]$fx, # fractional X within the window client area (0..1)
[double]$fy, # fractional Y
[int]$holdMs = 80 # press duration for 'hold'
)
# Window-CLIENT-relative synthetic input against a native app window, addressed
# by process name — so probes don't depend on where the window landed or on it
# being the foreground window (it's raised to foreground first). Coordinates are
# FRACTIONS of the client rect, so they're resolution/DPI independent.
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class In {
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr h, ref POINT p);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, UIntPtr e);
public struct RECT { public int L, T, R, B; }
public struct POINT { public int X, Y; }
public const uint DOWN = 2, UP = 4;
}
"@
$h = (Get-Process $proc -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).MainWindowHandle
if (-not $h -or $h -eq [IntPtr]::Zero) { Write-Output "NO WINDOW"; exit 1 }
$r = New-Object In+RECT
[In]::GetClientRect($h, [ref]$r) | Out-Null
$p = New-Object In+POINT
$p.X = [int](($r.R - $r.L) * $fx); $p.Y = [int](($r.B - $r.T) * $fy)
[In]::ClientToScreen($h, [ref]$p) | Out-Null
[In]::SetForegroundWindow($h) | Out-Null
Start-Sleep -Milliseconds 120
[In]::SetCursorPos($p.X, $p.Y) | Out-Null
switch ($action) {
"move" { }
"hover" { Start-Sleep -Milliseconds 250 }
"click" { Start-Sleep -Milliseconds 120; [In]::mouse_event([In]::DOWN,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 60; [In]::mouse_event([In]::UP,0,0,0,[UIntPtr]::Zero) }
"hold" { Start-Sleep -Milliseconds 120; [In]::mouse_event([In]::DOWN,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds $holdMs; [In]::mouse_event([In]::UP,0,0,0,[UIntPtr]::Zero) }
}
Write-Output "$action at $($p.X),$($p.Y)"
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
Drive a native compose-desktop-native app window for manual/automated visual
checks: launch it, send window-relative input (click / hover / hold / move),
and capture the client area via PrintWindow (works even when occluded).
Built from the ad-hoc rigs used to reproduce the square-on-click and TLS-chain
bugs — window-CLIENT-relative and process-addressed, so it doesn't care where
the window lands or whether it's focused.
Examples:
# boot the demo's Images screen, screenshot after 4s
python scripts/probe/probe.py demo --screen=Images --shot=images.png
# boot bubble-wrap (example repo), press-and-hold a bubble mid-sheet, capture
python scripts/probe/probe.py shared --exe <path-to>/shared.exe \\
--hold 0.55,0.55 --shot press.png
# hover then capture
python scripts/probe/probe.py demo --screen=Shapes --hover 0.3,0.4 --shot hov.png
`proc` is the window's process name (demo / apidemo / shared). Native apps
built here use the mingwX64 debug exe by default; override with --exe. Actions
run in order; --shot captures after them. Windows-only. Needs Pillow only if
you pass --crop.
"""
import argparse, subprocess, sys, time, os, signal
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
DEFAULT_EXE = {
"demo": REPO / "demo/build/bin/mingwX64/debugExecutable/demo.exe",
"apidemo": REPO / "apidemo/build/bin/mingwX64/debugExecutable/apidemo.exe",
}
def ps(script: str, *args: str):
return subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-File", str(HERE / script), *args],
capture_output=True, text=True,
)
def frac(s: str):
x, y = s.split(",")
return float(x), float(y)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("proc", help="window process name (demo / apidemo / shared)")
ap.add_argument("--exe", help="path to the executable (default: mingwX64 debug for demo/apidemo)")
ap.add_argument("--screen", help="pass --screen=<Name> to the app (demo)")
ap.add_argument("--args", default="", help="extra args passed verbatim to the exe")
ap.add_argument("--boot", type=float, default=4.0, help="seconds to wait after launch")
ap.add_argument("--click", help="fractional X,Y to click (e.g. 0.5,0.5)")
ap.add_argument("--hold", help="fractional X,Y to press-and-hold")
ap.add_argument("--hover", help="fractional X,Y to hover")
ap.add_argument("--hold-ms", type=int, default=900, help="hold duration")
ap.add_argument("--settle", type=float, default=0.3, help="seconds between action and capture")
ap.add_argument("--shot", help="capture the window to this PNG after the actions")
args = ap.parse_args()
exe = Path(args.exe) if args.exe else DEFAULT_EXE.get(args.proc)
if not exe or not exe.exists():
print(f"exe not found: {exe} (build it, or pass --exe)", file=sys.stderr)
return 1
cmd = [str(exe)]
if args.screen:
cmd.append(f"--screen={args.screen}")
if args.args:
cmd += args.args.split()
proc = subprocess.Popen(cmd)
try:
time.sleep(args.boot)
if args.hover:
fx, fy = frac(args.hover)
print(ps("_input.ps1", "-proc", args.proc, "-action", "hover", "-fx", str(fx), "-fy", str(fy)).stdout.strip())
if args.click:
fx, fy = frac(args.click)
print(ps("_input.ps1", "-proc", args.proc, "-action", "click", "-fx", str(fx), "-fy", str(fy)).stdout.strip())
if args.hold:
fx, fy = frac(args.hold)
# hold runs async so we can capture DURING the press
p = subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-File", str(HERE / "_input.ps1"),
"-proc", args.proc, "-action", "hold", "-fx", str(fx), "-fy", str(fy), "-holdMs", str(args.hold_ms)])
time.sleep(args.settle)
if args.shot:
out = str(Path(args.shot).resolve())
r = ps("_capture.ps1", "-proc", args.proc, "-out", out)
print(f"{r.stdout.strip()} -> {out}")
finally:
subprocess.run(["taskkill", "/F", "/IM", exe.name], capture_output=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# MAC-VERIFY runbook (P0.2, RENDERER.md) - the one-command gate for renderer work.
#
# Runs on macOS or Linux and exercises BOTH renderers:
# 0. DRIFT-CHECK: vendor-clean (sync.py + diff src/vendor, P0.7) + manual-vendor
# provenance tripwire (P0.6)
# 1. Build :demo + :apidemo for the host target (Skia leg, then -Prenderer=sdl3)
# 2. Run the interaction probes on each leg, gate on their PASS/FAIL output
# 3. Run scripts/parity/parity.py for each leg (gates on baselines.json when seeded)
# 4. CDN_PROFILE draw-ms spot-check on LazyColumn/Tabs (fails on >20% regression vs the
# last recorded run; build/verify-mac/perf-baseline.txt, seeded on first run)
#
# Usage:
# scripts/verify-mac.sh # full run, non-zero exit on any failure
# scripts/verify-mac.sh --update-perf-baseline # re-seed the perf baseline first
#
# Notes:
# - Probes self-terminate on a frame counter; a hang means the leg is broken anyway.
# - Toggling -Prenderer= keys a separate Gradle configuration-cache entry; if the SDL
# leg fails with "couldn't find sdl3_ttf", rm -rf .gradle/configuration-cache (see
# CLAUDE.md "Common pitfalls") and re-run.
set -u
REPO="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO"
# ============
# Host target
case "$(uname -s)/$(uname -m)" in
Darwin/arm64) TARGET=MacosArm64; EXE_DIR=macosArm64 ;;
Darwin/x86_64) TARGET=MacosX64; EXE_DIR=macosX64 ;;
Linux/aarch64) TARGET=LinuxArm64; EXE_DIR=linuxArm64 ;;
Linux/x86_64) TARGET=LinuxX64; EXE_DIR=linuxX64 ;;
*) echo "verify-mac: unsupported host $(uname -s)/$(uname -m)" >&2; exit 2 ;;
esac
DEMO_EXE="demo/build/bin/$EXE_DIR/debugExecutable/demo.kexe"
PROBES=(nav3test backtest clicktest scrolltest multiwintest)
PERF_DIR="build/verify-mac"
PERF_BASELINE="$PERF_DIR/perf-baseline.txt"
mkdir -p "$PERF_DIR"
[ "${1:-}" = "--update-perf-baseline" ] && rm -f "$PERF_BASELINE"
FAILURES=()
note() { echo; echo "==== verify-mac: $*"; }
fail() { FAILURES+=("$1"); echo "verify-mac: FAIL - $1" >&2; }
# ============
# Perf spot-check: run one screen for ~8s under the frame profiler, parse the last
# "draw=<avg>/<max>ms" line, compare avg against the recorded baseline (+20%, +0.2ms
# absolute guard so sub-ms values don't false-positive on noise).
perf_check() { # $1=leg $2=screen
local leg="$1" s="$2"
local log="$PERF_DIR/profile_${leg}_${s}.log"
rm -f "$log"
note "[$leg] perf spot-check $s"
CDN_PROFILE="$log" CDN_FORCERENDER=1 "$DEMO_EXE" --screen="$s" &
local pid=$!
sleep 8
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
local avg
avg=$(tail -1 "$log" 2>/dev/null | grep -o 'draw=[0-9.]*' | head -1 | cut -d= -f2)
if [ -z "$avg" ]; then
fail "[$leg] perf $s (no profiler output in $log)"
return
fi
local base
base=$(grep "^$leg $s " "$PERF_BASELINE" 2>/dev/null | awk '{print $3}')
if [ -z "$base" ]; then
echo "$leg $s $avg" >> "$PERF_BASELINE"
echo "verify-mac: [$leg] $s draw=${avg}ms (baseline seeded)"
elif awk -v a="$avg" -v b="$base" 'BEGIN { exit !(a > b * 1.2 && a > b + 0.2) }'; then
fail "[$leg] perf $s regression: draw=${avg}ms vs baseline ${base}ms (+20% gate)"
else
echo "verify-mac: [$leg] $s draw=${avg}ms (baseline ${base}ms) ok"
fi
}
# ============
# One leg = build + probes + parity + perf.
run_leg() { # $1=skia|sdl3
local leg="$1"
local props=()
[ "$leg" = "sdl3" ] && props=(-Prenderer=sdl3)
note "[$leg] build :demo + :apidemo ($TARGET)"
if ! ./gradlew ${props[@]+"${props[@]}"} ":demo:linkDebugExecutable$TARGET" \
":apidemo:linkDebugExecutable$TARGET" --console=plain; then
fail "[$leg] build"
return
fi
local p out
for p in "${PROBES[@]}"; do
note "[$leg] probe --$p"
out=$("$DEMO_EXE" "--$p" 2>&1 | tail -40)
if ! echo "$out" | grep -q "PASS"; then
echo "$out"
fail "[$leg] probe $p"
fi
done
note "[$leg] parity ($leg leg)"
python3 scripts/parity/parity.py --renderer="$leg" || fail "[$leg] parity"
# P2.2 memory soak: cycle every screen x6, assert current RSS doesn't ratchet up
# (regression guard for the snapshot-observation / dispose leak fixed in c59caf72).
note "[$leg] soak (memory)"
out=$(CDN_SOAK_CYCLES=6 "$DEMO_EXE" --soaktest 2>&1 | tail -5)
if ! echo "$out" | grep -q "soaktest: PASS"; then
echo "$out"
fail "[$leg] soak"
fi
perf_check "$leg" LazyColumn
perf_check "$leg" Tabs
}
# ============
# DRIFT-CHECK first (cheap, cross-platform): the legs must build from a vendor tree
# that matches the pinned refs, with no manual vendor lagging the pin.
note "drift-check: vendor-clean (sync.py + diff src/vendor)"
python3 scripts/compose-fork/check-vendor-clean.py || fail "drift-check: src/vendor drifted from the pinned refs"
note "drift-check: manual-vendor provenance"
python3 scripts/compose-fork/check-vendor-drift.py || fail "drift-check: a manual vendor lags the pinned ref"
run_leg skia
run_leg sdl3
# ============
# Summary
echo
if [ ${#FAILURES[@]} -gt 0 ]; then
echo "verify-mac: FAILED (${#FAILURES[@]}):"
printf ' - %s\n' "${FAILURES[@]}"
exit 1
fi
echo "verify-mac: ALL GREEN (both legs: build + probes + parity + perf)"
exit 0
+3 -3
View File
@@ -47,8 +47,8 @@ val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
// material3 rides its own release train (same +dev build, different base).
// Gradle orders "+dev" BELOW the plain version, so force the core-repo
// groups on jvm configurations (mirrors :demo's forcing).
val vComposeJvmVersion = "1.12.0-beta01+dev4324"
val vComposeM3JvmVersion = "1.12.0-alpha03+dev4324"
val vComposeJvmVersion = libs.versions.compose.get()
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
val vComposeJvmForced = mapOf(
"org.jetbrains.compose.runtime" to vComposeJvmVersion,
"org.jetbrains.compose.ui" to vComposeJvmVersion,
@@ -79,7 +79,7 @@ kotlin {
commonMain.dependencies {
// The official runtime klibs — the SAME artifact/version the port
// itself uses (:ui pins it); resolves for metadata, jvm AND native.
api("org.jetbrains.compose.runtime:runtime:1.11.1")
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
// Official Maven coordinates for the API surface (Modifier / Color
// / Dp / material3's LocalContentColor). Metadata + jvm resolve
// them from Maven; NATIVE configurations substitute them for