Files
Bitsy fb8e97b74d feat(material3): port material3-adaptive to desktop native
Closes the request in issue #4. org.jetbrains.compose.material3.adaptive:*
1.3.0-rc01 publishes ios_arm64 / ios_simulator_arm64 / macos_arm64 and
nothing else - no linux, no mingw. (It does carry macosArm64, which
answers the reporter's open question.)

All four artifacts are now vendored and built for every desktop-native
target, with ZERO source edits. Two things made it a pure re-target:

- every platform actual the native legs need already exists upstream:
  adaptive/nonAndroidMain (calculatePosture), adaptive-layout/skikoMain
  (~75 l10n tables + Strings + the drag handle) and
  adaptive-layout/nativeMain (identityHashCode, isGraphicsLayerElement)
- androidx.window:window-core - the dependency `adaptive` api-exposes and
  the obvious suspect for the apple-only publication - has shipped
  linux_x64 / linux_arm64 / mingw_x64 / macos_arm64 klibs all along, under
  BOTH the google and jetbrains coordinates

So the artifacts were missing only because upstream never enabled the
targets, not because anything blocked them.

125 files vendored off the existing COMPOSE_CORE_REF pin - no new
SET_REPO. Upstream's skikoMain lands in src/vendor/native/ beside
upstream's own nativeMain; the two actualise disjoint expects so they
share one source set.

Both bridges substitute all four coordinates, so consumers keep declaring
the official org.jetbrains.compose.material3.adaptive:* coords.

Verified: all four compile for mingwX64 + linuxX64 + linuxArm64, API
dumps generated, vendor-clean and audit-exclusions pass, and
`demo --screen=Adaptive` renders a live ListDetailPaneScaffold with the
window size class resolving correctly. adaptive-navigation3 builds and
dumps API but has no demo screen yet, so its nav3 SceneStrategy path is
unproven at runtime.

Two notes for later:
- the new demo screen hit the granular-metadata trap documented in
  CLAUDE.md: because `adaptive` is substituted on native configs, KGP
  drops its transitives from the commonMain metadata classpath, so
  window-core had to be declared DIRECTLY in demo's commonMain
- demo's JVM parity leg moves navigation3-ui 1.2.0-alpha02 -> 1.1.2 to
  follow CMP 1.12.1's navigation3 reset onto the 1.1 line
2026-09-22 16:07:23 +02:00

53 KiB

CLAUDE.md

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 - public overview + quickstart (bridge plugin, nativeComposeWindow, sample apps).
  • Renderer - one Skia RenderBackend (Metal on macOS / OpenGL on Linux+Windows / CPU-raster fallback) driving upstream's vendored GraphicsLayer + Canvas engine. Retained per-node display lists: transform / alpha / clip changes REPLAY without re-recording (only a content change or resize re-records; dirty-region rendering is a non-goal). Text is a reduced-local port over Skia skparagraph. Read these before touching renderer / graphics-actual / layer-engine code: SkiaRenderBackend.kt, RenderBackend.kt, GpuMode.kt, ComposeRootHost.kt (root host + snapshot-observation sweep - omitting clearInvalidObservations() once leaked the whole graph), ComposeOwner.kt, and SkiaParagraph.native.kt / SkiaParagraphEngine.kt. Open renderer/fidelity work lives in PLAN.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.
  • PLAN.md §2 - audited list of no-ops, stubs, and hardcodes left in the port, with P0/P1/P2 severity, plus the road-to-1.0.0 fidelity / SDL / release work (this subsumes the former TODO.md, which was never committed).
  • 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 on SDL3, no JVM. Compiles to native binaries for macOS (arm64), Linux (x64/arm64), Windows (mingwX64).

Rendering is Skia everywhere behind one RenderBackend - Metal / OpenGL / CPU raster:

  • macOS + Linux link the OFFICIAL Skiko klibs from Maven.
  • Windows (mingwX64) links the bitsycore skiko FORK - skiko+Skia compiled into skiko-windows-x64.dll with a flat extern-C surface, bound from K/N via an embedded GNU import lib, published to https://maven.bitsycore.com/releases as com.bitsycore.skiko:skiko:0.150.1-mingw.2 (override with -PskikoMingwVersion). The runtime DLL is auto-provisioned next to the exe by the bridge plugin (installWindowsSkiaDll).

Windowing, input, audio, filesystem access, and the OS-integration surface (file dialogs, clipboard, "open in Finder/Explorer"…) all go through SDL3. The runtime (androidx.compose.runtime.*: composition, snapshots, recomposer, mutableStateOf, remember, …) is the official org.jetbrains.compose.runtime klibs from Maven - this project only re-implements the layers on top (androidx.compose.ui.*, .foundation.*, .animation.*, .material3.*).

Module layout

Library modules mirror upstream Compose Multiplatform's compose/ tree. The SDL layer is two modules: :sdl-core (the NAKED sdl3 cinterop + platform primitives - zero Compose dep, like skiko) at sdl/sdl-core/, and :desktop-native-window (the SDL3 main-loop shell + app entry point) at compose/desktop/native/desktop-native-window/. :ui depends on :sdl-core and its renderer

  • SDL↔Compose bridges pick the cinterop from it.

One Gradle module per upstream artifact; the directory mirrors the upstream compose/ path, and the gradle path mirrors the directory (no projectDir redirection).

compose/
├── ui/
│   ├── ui/                          → :compose:ui:ui        - androidx.compose.ui.* CORE (Modifier, LayoutNode,
│   │                                               composition, semantics, input, focus) + com.compose.sdl.* -
│   │                                               the Skia RenderBackend + GPU bridges + the SDL↔Compose
│   │                                               bridges (events / clipboard / cursors / window). Depends on
│   │                                               :ui-graphics, :ui-text, :sdl-core. (ui-graphics + ui-text +
│   │                                               the sdl3 cinterop were split OUT - upstream layout.)
│   ├── ui-graphics/                 → :compose:ui:ui-graphics - androidx.compose.ui.graphics.* + the Skia actuals
│   │                                               (SkiaBackedCanvas/Path/Paint, GraphicsLayer, SkiaImageCache,
│   │                                               painter/image + resource seams). → skiko; SDL-free.
│   ├── ui-text/                     → :compose:ui:ui-text   - androidx.compose.ui.text.* + the skiko text engine
│   │                                               (SkiaParagraph → NativeParagraphOps → SkiaFonts, IconFont,
│   │                                               NamedFont). → :ui-graphics, skiko; SDL-free.
│   ├── ui-util/                     → :compose:ui:ui-util       - androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
│   ├── ui-geometry/                 → :compose:ui:ui-geometry   - androidx.compose.ui.geometry.*
│   ├── ui-unit/                     → :compose:ui:ui-unit       - androidx.compose.ui.unit.*
│   ├── ui-backhandler/              → :compose:ui:ui-backhandler - androidx.compose.ui.backhandler.*
│   └── ui-tooling-preview/          → :compose:ui:ui-tooling-preview - androidx.compose.ui.tooling.preview.*
│                                                     (the common @Preview + PreviewParameterProvider,
│                                                     vendored verbatim; the Maven artifact ships no
│                                                     mingwX64/linux klibs). IDE-only metadata - previews
│                                                     render through the apps' jvm parity targets.
├── animation/
│   ├── animation-core/              → :compose:animation:animation-core     - androidx.compose.animation.core.*
│   ├── animation/                   → :compose:animation:animation          - androidx.compose.animation.* (non-core)
│   └── animation-graphics/          → :compose:animation:animation-graphics - androidx.compose.animation.graphics.*
├── foundation/
│   ├── foundation/                  → :compose:foundation:foundation       - androidx.compose.foundation.*
│   └── foundation-layout/           → :compose:foundation:foundation-layout - androidx.compose.foundation.layout.*
├── material3/
│   ├── material3/                   → :compose:material3:material3   - androidx.compose.material3.*
│   └── adaptive/                    → the material3-adaptive family, VENDORED VERBATIM.
│       │                              org.jetbrains.compose.material3.adaptive:* publishes
│       │                              ios + macosArm64 ONLY - no linux, no mingw (issue #4).
│       │                              Nothing in the sources blocks those targets: every
│       │                              actual the native legs need already exists upstream
│       │                              (nonAndroidMain / skikoMain / nativeMain), and the one
│       │                              real dependency, androidx.window:window-core, ALREADY
│       │                              publishes linux + mingw + macos. The artifacts were
│       │                              missing purely because upstream never enabled the
│       │                              targets - so this port is vendor-only, ZERO source
│       │                              edits. Upstream's skikoMain (the ~75 l10n tables +
│       │                              Strings / drag-handle actuals) lands in src/vendor/
│       │                              native/ next to upstream's own nativeMain actuals;
│       │                              they actualise disjoint expects so they coexist.
│       │                              com.bitsycore.compose.material3.adaptive:<module>.
│       ├── adaptive/                → :compose:material3:adaptive:adaptive  - WindowAdaptiveInfo /
│       │                              Posture / window size class. api-exposes window-core.
│       ├── adaptive-layout/         → :compose:material3:adaptive:adaptive-layout - ListDetail /
│       │                              SupportingPane / ThreePane scaffolds + pane motion
│       ├── adaptive-navigation/     → :compose:material3:adaptive:adaptive-navigation -
│       │                              ThreePaneScaffoldNavigator + back behaviour
│       └── adaptive-navigation3/    → :compose:material3:adaptive:adaptive-navigation3 -
│                                      SceneStrategy bindings for nav3 (→ :navigation3-ui)
├── material/
│   └── material-ripple/             → :compose:material:material-ripple - androidx.compose.material.ripple.*
└── desktop/native/desktop-native-window/ → :compose:desktop:native:desktop-native-window - nativeComposeApp { Window(...) {} }
                                                    multi-window shell + SDL3 main loop; nativeComposeWindow()
                                                    wrapper (project app-shell, not an upstream CMP artifact).
                                                    Published as com.bitsycore.compose:desktop-native-window.

sdl/
└── sdl-core/                        → :sdl:sdl-core   - the NAKED sdl3 cinterop + platform primitives
                                                    (zero Compose dep, like skiko); the single `sdl3` cinterop
                                                    lives here. :ui depends on it. com.bitsycore.compose.sdl:sdl-core.

utils/
└── material-symbols/                → :utils:material-symbols - codepoints + all three style objects
                                                    (Outlined / Rounded / Sharp). COMMON API (usable from
                                                    shared app code) + per-stack actuals: native renders
                                                    via :foundation IconFontIcon (Skia), jvm() via Skiko directly
                                                    (Typeface.makeClone per axes - upstream's FontCache
                                                    drops variationSettings from its key). Its commonMain
                                                    declares official Maven compose coords - the root
                                                    build's FULL-COMMONIZATION BRIDGE substitutes the
                                                    whole ui / foundation / animation / material3 /
                                                    nav3-ui / components-resources family to project
                                                    modules on native configs
                                                    (:demo and :apidemo commonMains rely on the same
                                                    bridge; :compose-desktop-native-bridge ships it to
                                                    consumers). Apps get one dep; the consumer Zip task
                                                    bundles only the fonts used (native) and
                                                    jvmProcessResources stages the same fonts (jvm).

components/
└── resources/components-resources/  → :components:resources:components-resources - the OFFICIAL Compose resources
                                                    runtime (org.jetbrains.compose.components:
                                                    components-resources), VENDORED from the
                                                    compose-multiplatform UMBRELLA repo (the first
                                                    SET_REPO manifest) because the Maven artifact has
                                                    no mingwX64/linux klibs. Platform layer is project
                                                    code: data.kres ResourceReader, pure-Kotlin
                                                    DomXmlParser (upstream's is Darwin NSXMLParser),
                                                    image decode via the :ui EncodedImageDecoder hook
                                                    (Skia), NamedFont registration, SDL locale/theme env.
                                                    Apps' JVM targets keep the Maven artifact - the
                                                    generated Res accessors work against BOTH.

koin/                → the Koin DI modules upstream stops publishing for desktop native.
├── koin-core-viewmodel/             → :koin:koin-core-viewmodel   - org.koin.viewmodel.* + module DSL
├── koin-compose/                    → :koin:koin-compose           - org.koin.compose.*
├── koin-compose-viewmodel/          → :koin:koin-compose-viewmodel - org.koin.compose.viewmodel.*
└── koin-compose-navigation3/        → :koin:koin-compose-navigation3 - org.koin.compose.navigation3.*
                                                    VENDORED verbatim from InsertKoinIO/koin
                                                    (KOIN_REF). koin-core ITSELF publishes mingwX64
                                                    + linux and is a plain Maven dep - only these
                                                    four stop at apple+android upstream. NOTE it is
                                                    the NAV3 module, not koin-compose-viewmodel-
                                                    navigation: that one sits on nav2's
                                                    navigation-compose, which has no mingwX64/linux
                                                    klibs under EITHER coordinate set.
                                                    com.bitsycore.koin:<module>.

coil/                → Coil 3 image loading. Upstream ships linux but NO mingwX64 for
│                      core/network/svg, and NO desktop native at all for the compose
│                      layer. VENDORED verbatim from coil-kt/coil (COIL_REF).
├── coil-core/                       → :coil:coil-core            - coil3.* core, Skia-based decode
├── coil/                            → :coil:coil                 - singleton ImageLoader facade
├── coil-compose-core/               → :coil:coil-compose-core    - AsyncImage internals
├── coil-compose/                    → :coil:coil-compose         - public compose entry points
├── coil-svg/                        → :coil:coil-svg             - SVG via skiko SVGDOM
├── coil-network-core/               → :coil:coil-network-core    - fetcher plumbing
└── coil-network-ktor3/              → :coil:coil-network-ktor3   - Ktor 3 fetcher
                                                    Upstream's nonAndroid / nonJvmCommon /
                                                    nonJsCommon sets all apply here (no android /
                                                    jvm / js target) but must stay ABOVE nativeMain
                                                    - they declare expects whose actuals live in
                                                    nativeMain. coil-core / coil-svg /
                                                    coil-compose-core use org.jetbrains.skia
                                                    directly, so they carry TWO PARALLEL skiko
                                                    branches (official for macOS/Linux, the
                                                    bitsycore fork for mingwX64) exactly like
                                                    :compose:ui:ui-graphics. com.bitsycore.coil3:<m>.

pulse/               → Pulse MVI. Upstream (bitsycore/pulse-mvi, PULSE_REF) targets
│                      android / jvm / ios / js / wasm and publishes NO desktop-native
│                      artifact, so all four modules are vendored. commonMain only.
├── pulse/                           → :pulse:pulse            - MVI core (store/state/intents)
├── pulse-viewmodel/                 → :pulse:pulse-viewmodel  - ViewModel binding
├── pulse-savedstate/                → :pulse:pulse-savedstate - SavedStateHandle persistence
└── pulse-compose/                   → :pulse:pulse-compose    - Compose bindings
                                                    Published as com.bitsycore.compose.desktop.native
                                                    .pulse:<module>, NOT upstream's com.bitsycore.lib:
                                                    <module>. Pulse is the one vendored dep that is
                                                    ITSELF a com.bitsycore library, so the republish is
                                                    namespaced under this project rather than a bare
                                                    com.bitsycore.pulse that would read like upstream;
                                                    the bridge substitutes the upstream coord on
                                                    native configs.

navigation3/
└── navigation3-ui/                  → :navigation3:navigation3-ui - androidx.navigation3.ui.* + scene machinery,
                                                    VENDORED verbatim from upstream (SET_FOLDER manifest).
                                                    Navigation 3's runtime layers (navigation3-runtime,
                                                    lifecycle-viewmodel-navigation3) are real Maven KMP
                                                    artifacts used as-is (see "Known Compatible" below);
                                                    only this UI module has no K/N desktop artifact. The
                                                    native actual (NavDisplay.native.kt) is MANUALLY
                                                    VENDORED: it mirrors the ANDROID transition defaults
                                                    (700ms fades, predictive-pop spring/scaleOut) because
                                                    the upstream macos actual ships all-None (no animation).

demo/                → :demo      - flagship showcase app (30+ screens) + the CLI probe suite.
                                    MULTIPLATFORM: also has a jvm() target running the SAME shared
                                    screens on stock JVM Compose Desktop (`./gradlew :demo:run`,
                                    MainJvmKt) - the parity reference; differences vs native = port bugs
apidemo/             → :apidemo   - Postman-style REST API manager. MULTIPLATFORM like :demo
                                    (`./gradlew :apidemo:run`): the whole UI lives in commonMain
                                    against the official Maven coords; SDL-backed APIs go through
                                    expect/actual seams (compat/Compat.kt - native actuals delegate
                                    to com.compose.sdl, jvm actuals use AWT + upstream desktop).
                                    mTLS / TLS-chain inspection stays native-only (bundled libcurl).
                                    DOGFOODS the bridge plugin: data.kres packaging + app icon come
                                    from compose.desktop.native {}; the font pipeline (Noto +
                                    Material Symbols subsetting) comes from the shared buildSrc
                                    helper (registerComposeFontBundling).
buildSrc/            → shared build logic for the app modules: ComposeFontBundling.kt -
                      registerComposeFontBundling { } (everything OPT-IN; no flag = no-op)
                      registers downloadNotoFonts (bundleNotoSans / bundleNotoSansMono /
                      autoDetectNotoSansMono), detects the Material Symbols styles the
                      sources use (bundleMaterialSymbols), wires the font/ entries into
                      every data.kres Zip (the bridge plugin's package* tasks; hand-rolled
                      copy*ComposeResources* tasks also match) and stages the same fonts on
                      the JVM classpath; hb-subset pipeline behind enableIconSubsetting
                      (findMaterialSymbolsUsage + subsetMaterialSymbols<Style>).
gradle-plugin/
└── compose-desktop-native-bridge/ → the CONSUMER-side bridge as a published Gradle plugin
                                    (id com.bitsycore.compose-desktop-native.bridge, applies to
                                    Settings or Project). An INCLUDED build (pluginManagement.
                                    includeBuild in settings.gradle.kts), NOT a subproject - a
                                    plugins{} block can only resolve plugins from repositories or
                                    included builds; :demo and :apidemo apply it from source
                                    (dogfooding: data.kres packaging - and apidemo's app icon -
                                    run through the plugin).
                                    Three halves: (1) substitution - third-party apps declare
                                    OFFICIAL CMP coords in commonMain and native configurations
                                    swap in the published com.bitsycore.compose.sdl klibs
                                    (version defaults to the plugin's own; override:
                                    composeDesktopNative.version property; disable:
                                    composeDesktopNative.substitution=false - set repo-wide in
                                    gradle.properties since the root build's FULL-COMMONIZATION
                                    BRIDGE substitutes to project modules in-repo); (2) data.kres
                                    packaging (package<Variant>ComposeResources<Target> per native
                                    executable, honours -PcompressResources); (3) the
                                    compose.desktop.native { entryPoint / icon {} } DSL (.rgba
                                    runtime icons + windres .exe embed - injected into
                                    hand-declared executables too). Published by the WINDOWS
                                    publish job.
scripts/             → vendor-sync + python helper scripts (compose-coverage = API
                      coverage/fidelity vs upstream, material-symbols generate/subset)
                      + compose-fork/;
                      scripts/build-sdl/ = static-lib build script (python)
libs/                → gitignored per-host static SDL3 output of
                      scripts/build-sdl/build-all.py on Windows

Module PATHS mirror the DIRECTORY 1:1 - :compose:ui:ui, :compose:foundation:foundation, :compose:desktop:native:desktop-native-window, :sdl:sdl-core, :utils:material-symbols, :navigation3:navigation3-ui, :components:resources:components-resources. There is NO projectDir redirection in settings.gradle.kts; a plain include(...) per module is the whole story, and the intermediate container projects (:compose, :compose:ui, …) hold no code.

INVARIANT: the leaf directory IS the published artifactId. Don't break it to mirror an upstream directory name. KMP derives every target publication's coordinate as <project.name>-<target>, and a target DISABLED on the building host (all Apple targets on Windows/Linux) has no publication object to retarget afterwards - while the root kotlinMultiplatform metadata, which the WINDOWS publish job owns, still emits an available-at for it. A mismatched leaf therefore ships a root module pointing at a nonexistent <leaf>-macosarm64, breaking macOS consumers, and no artifactId = … override can reach it. Two directories are renamed away from upstream's layout to hold this: compose/desktop/native/desktop-native-window (upstream: window) and components/resources/components-resources (upstream: library + a hand-set artifactId). scripts/compose-coverage.py's kTargets carries the upstream→local mapping for the latter.

androidx.collection is a plain Maven dependency (androidx.collection:collection), not a module - same as other simple androidx KMP libs.

Dependency graph

Written with LEAF names for readability (:ui = :compose:ui:ui, :foundation = :compose:foundation:foundation, …); build files use the full path shown in the module layout above.

:ui  ←  :animation-core  ←  :animation          ←  :foundation  ←  :material3  ← :demo, :apidemo
:ui  ←  :foundation-layout  ←──────────────────────┘   ↑              ↑
                        :material-ripple  ←────────────┘──────────────┘
:foundation, :animation-core  ←  :desktop-native-window ;  :foundation, :material3  ←  :material-symbols

All edges are api, so a consumer of :foundation / :material3 transitively sees the split modules. Full DAG: :ui-util → collection; :ui-geometry → :ui-util; :ui-unit → :ui-geometry, :ui-util; :ui-backhandler → :ui-util, navigationevent; :sdl-core → sdl3 cinterop (NAKED - no Compose); :ui-graphics → :ui-geometry, :ui-unit, :ui-util, skiko; :ui-text → :ui-graphics, :ui-unit, :ui-util, skiko; :ui → :ui-graphics, :ui-text, :sdl-core, :ui-util, :ui-geometry, :ui-unit, :ui-backhandler; :animation-core → :ui; :foundation-layout → :ui; :animation → :animation-core, :foundation-layout; :foundation → :animation, :foundation-layout, :animation-core, :ui; :material-ripple → :foundation, :animation-core; :material3 → :foundation, :material-ripple, :animation-core, :foundation-layout. The adaptive family sits beside material3 rather than under it - it depends on :ui / :foundation / :animation-core, NOT on :material3: :adaptive → :ui, :foundation, window-core; :adaptive-layout → :adaptive, :ui, :animation-core, :animation, :foundation, :foundation-layout, :ui-geometry, collection, window-core; :adaptive-navigation → :adaptive-layout, :foundation, :ui-util; :adaptive-navigation3 → :adaptive-navigation, :navigation3-ui, collection, navigationevent-compose.

:ui is the Compose core + the Skia RenderBackend + the SDL↔Compose bridges; it sits on :ui-graphics / :ui-text (the graphics/text primitives + their skiko actuals, SDL-free) and the naked :sdl-core (sdl3 cinterop). The pure lower artifacts (:ui-util, :ui-geometry, :ui-unit, :ui-backhandler) are below. Everything above :ui touches renderer internals only via its public surface. :desktop-native-window depends on :ui + :foundation (needs LazyList-style scaffolding to install the popup / scaffold layer at the composition root).

Vendoring philosophy - read this before writing any androidx.compose.* code

Prefer vendoring verbatim from upstream Compose Multiplatform over hand-rolling anything.

Every module that ships androidx.compose.* code carries a <module>/compose-fork.txt manifest. Each one declares its upstream repo + pinned ref up top with SET_REPO=<https-url>@<ref>, where <ref> is normally a <VARNAME> resolved from scripts/compose-fork/compose.properties - a NAME=value file that version-tags every pinned ref in ONE place (e.g. COMPOSE_CORE_REF for compose-multiplatform-core, COMPOSE_REF for the compose-multiplatform umbrella repo that :components-resources vendors from). There is no implicit default - SET_REPO is required. scripts/compose-fork/sync.sh walks all manifests and copies each selected file byte-for-byte from the pinned checkout (each distinct repo sparse-cloned to ../cmp-ref[-<name>]) into <module>/src/vendor/{common,native,skikoRenderer}/kotlin/. The src/vendor/ tree is gitignored - you don't check it in, you re-sync on demand.

Manifests are folder-style: a SET_FOLDER=<module>/src line sets an upstream base, then <sourceSet>/kotlin/ -> src/vendor/<area>/kotlin/ grabs a whole source set (every .kt under it). !<sourceSet>/kotlin/<pkg>/<File>.kt refuses one file inside a grabbed folder - use it for files hand-vendored + edited under src/{commonMain,…} so the folder copy doesn't shadow them, or for upstream files the port doesn't want. A plain <src> -> <dest> still pins (or renames on copy) a single file. Re-declare SET_FOLDER to draw from a second upstream module (:ui pulls from ui + ui-graphics + ui-text). Every sync re-annotates the manifest in place: commented # | src -> dest lines under each folder directive list what it expands to, and a trailing # >>> DIAGNOSTIC GAPS block lists every upstream .kt under SET_FOLDER that no directive selects (grouped by source set) so new upstream files surface as comments to uncomment. That whole annotated tail is generated - never hand-edit it; edit only the directives up top and re-run sync.

Two categories of code live in each module:

  1. Vendored (in src/vendor/…) - copied byte-for-byte from upstream. Never hand-edit these. If upstream diverges, adjust the manifest or the pinned ref and re-sync. This is the bulk of the codebase (~1500 files across the modules).
  2. Project code (in src/commonMain/, src/nativeMain/, src/skikoRendererMain/) - code we author (project actuals, glue between Compose and SDL3, project-specific extensions).

The 5 rules for adding upstream Compose surface

  1. commonMain should contain NO androidx.compose.* code you authored. Anything under androidx.compose.* in commonMain MUST be a vendored copy. Hand-rolled commonMain is fine only when the package is com.compose.sdl.* (project code).

  2. Vendor the actuals too whenever possible. Upstream ships skikoMain and native-flavored actuals. If they compile against the current source-set hierarchy, add them to the manifest and let them come in verbatim. That includes .skiko.kt files (go to src/vendor/skikoRenderer/kotlin/) and .native.kt files (go to src/vendor/native/kotlin/).

  3. If an upstream file needs a small edit to compile / behave correctly for us, copy it locally and edit - MANUAL VENDORING, 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. 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
    

    A file that is a fresh REIMPLEMENTATION rather than a derived copy (same package + signatures, different body - see the fallback rule at the end of this section) gets // VENDOR-REIMPL: <path> @ <tag> instead. That marks it as deliberately NOT drift-tracked: upstream edits to the counterpart need no reconciling, only a SIGNATURE change does. scripts/compose-fork/audit-exclusions.py classifies every ! exclusion as manual vendor / reimpl / orphan and FAILS on any local counterpart carrying neither marker, so nothing rots invisibly.

    scripts/compose-fork/check-vendor-drift.py reads the VENDOR-BASE lines 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 a skikoRendererMain source set. When upstream ships a .skiko.kt file that uses Skiko's Canvas / Paragraph / …, the .skiko.kt variant is fine to vendor into skikoRendererMain - graphics into :ui-graphics (SkiaBackedCanvas.skiko.kt, SkiaImageCache.kt, …), text into :ui-text (SkiaParagraphEngine.kt), the render backend into :ui (SkiaRenderBackend.kt). All native targets attach this source set; mingwX64 layers its fork actuals on top under skikoRendererMingwMain.

  5. Multi-OS project code goes through SDL3, not hand-rolled per-target ifdefs. SDL3 already handles the platform differences for filesystem paths (SDL_GetBasePath, SDL_GetPrefPath), clipboard, cursor, fullscreen, high-DPI, subprocess launch, MessageBox, and more. file dialog / "reveal in Finder or Explorer" / app-data path - do it through SDL3 first, hand-roll the target-specific version only if SDL3 doesn't expose it (currently the file-open/save dialog uses SDL3's SDL_ShowOpenFileDialog / SDL_ShowSaveFileDialog).

If a piece of upstream is too Compose-specific to make sense on our stack (e.g. Android AWT layer, iOS UIView, JVM Toolkit), do a fresh reimpl in project code - same signature (same package, same params) so call sites don't care.

Source-set hierarchy (:ui only)

:ui owns the Skia RenderBackend + GPU bridges. The SAME source-set tree is used by :ui-graphics / :ui-text for their skiko actuals (only the deps differ); the sdl3 cinterop lives in :sdl-core (see below). :ui's tree:

commonMain
└── nativeMain                                  (vendored .native.kt + project native code)
      ├── skikoRendererMain                     (Skia drawing pipeline; official Skiko on classpath)
      │     ├── skikoRendererMacosMain          (macOS-only Skia actuals - Metal bridge)
      │     └── skikoRendererLinuxMain          (Linux-only Skia actuals - OpenGL)
      │            attached to: macosArm64Main / linuxX64Main / linuxArm64Main.
      └── skikoRendererMingwSharedMain
            └── skikoRendererMingwMain          (mingwX64-only Skia actuals - the bitsycore
                                                 skiko FORK: skiko-windows-x64.dll bound via
                                                 an embedded GNU import lib; OpenGL context)
                   attached to: mingwX64Main.

createRenderBackend(…) + rendererPreferredGpuMode() are expects in :ui's nativeMain with actuals in skikoRendererMain (shared by every native target). rendererPreferredGpuMode() picks Metal on macOS, OpenGL on Linux + Windows, with a Software (CPU raster) auto-fallback if the GPU context fails to come up.

Cinterop

The sdl3 cinterop lives in the NAKED :sdl-core module (sdl/sdl-core/src/nativeInterop/cinterop/sdl3.def: SDL_Window / SDL_Event / SDL_GetBasePath / clipboard / dialogs / GL+Metal context / SDL_Renderer-for-CPU- raster). :ui depends on :sdl-core (api), so :ui's renderer + SDL↔Compose bridges - and :desktop-native-window downstream - see sdl3.* and inherit SDL3's static-lib + linker-opt propagation (the .def bakes in staticLibraries = libSDL3.a + the per-OS linkerOpts, carried through the klib chain to the final exe, so apps link SDL just by depending on :ui/:desktop-native-window). :sdl-core has NO Compose dependency, so :ui → :sdl-core is cycle-free - the compose way (like ui → skiko).

Density flow (Option B - layout in physical pixels)

We use the physical-pixel layout flow on HiDPI. Concretely:

  • SDL_WINDOW_HIGH_PIXEL_DENSITY is set on the SDL window.
  • LocalDensity = the DPR (2.0 on Retina, 1.0 otherwise) - from pixelWidth / windowWidth.
  • Constraints passed to rootNode.measure(…) are the physical pixel size, not logical points.
  • renderBackend.beginFrame(1f) - no renderer-side scale; layout already ran in physical pixels.
  • Pointer coords from SDL are logical points; they're multiplied by DPR before dispatch so the whole event pipeline is in the same physical-px coord space as layout.

Consequence: Modifier.width(20.dp) at density 2 → 40 physical pixels wide. onSizeChanged { it.width } reports physical pixels. If you're passing pixel integers into pixel-based modifiers, use the lambda forms (Modifier.offset { IntOffset(x, y) }) - they take pixels directly. Passing raw px.dp will double-scale on Retina.

Building

# macOS Apple Silicon, default Skia (Metal on macOS)
./gradlew :demo:runDebugExecutableMacosArm64
./gradlew :apidemo:runDebugExecutableMacosArm64

# Linux x64
./gradlew :demo:runDebugExecutableLinuxX64
./gradlew :apidemo:runDebugExecutableLinuxX64

# Windows (from Windows - mingw cross-build from macOS/Linux fails at cinterop).
# Links the bitsycore skiko FORK from maven.bitsycore.com (com.bitsycore.skiko:skiko:
# 0.150.1-mingw.2, override -PskikoMingwVersion); the bridge plugin drops
# skiko-windows-x64.dll next to the exe (installWindowsSkiaDll).
gradlew.bat :demo:runDebugExecutableMingwX64
gradlew.bat :apidemo:runDebugExecutableMingwX64

# Stock JVM Compose Desktop (any host) - the parity reference: the SAME shared
# screens on upstream Compose; differences vs the native build = port bugs.
./gradlew :demo:run
./gradlew :apidemo:run

System dependencies

SDL3 is built from source as a static library on every OS and linked straight into the executable - no brew/apt SDL packages. Build it once per host with python scripts/build-sdl/build-all.py. Per-host toolchain requirements and the step breakdown are in TOOLING.md.

Skia comes in through the Skiko klibs: the official Maven ones on macOS/Linux (no runtime .so/.dylib - Skia is statically inside the klib), and the bitsycore fork on Windows, whose skiko-windows-x64.dll the bridge plugin provisions next to the exe. So a macOS/Linux distributable is <app> + data.kres; a Windows one is <app>.exe + data.kres + skiko-windows-x64.dll.

Runtime bundling - data.kres

Every app ships <app>.exe + data.kres (a STORED zip alongside the executable, loaded via SDL_GetBasePath()). Contents:

  • App drawables + files under composeResources/{drawable,files}/
  • font/NotoSans.ttf - the default variable font. Bundling it is the APP's job: each app opts in via registerComposeFontBundling { bundleNotoSans = true; … } (buildSrc - every flag is opt-in, no flag = no-op), which registers its downloadNotoFonts task (into <app>/build/fonts/); the library ships no download task. Pass -PbundleDefaultFont=false to skip.
  • font/NotoSansMono.ttf - autoDetectNotoSansMono = true bundles it when FontFamily.Monospace appears in the app sources (demo); bundleNotoSansMono = true forces it (apidemo's body font, loaded through its own seam)
  • Material Symbols fonts for the styles the app actually uses (bundleMaterialSymbols = true) - the Zip task scans the app's Kotlin sources for MaterialSymbolsOutlined / Rounded / Sharp and only bundles the fonts referenced.
  • -PsubsetIcons=true (default on, opted into per app via enableIconSubsetting = true - apidemo yes, demo no): scripts/subset-material-symbols.py scans app sources for MaterialSymbols.<Name> usage and hb-subsets each bundled font down to just those glyphs. Needs hb-subset on PATH (brew install harfbuzz / apt install harfbuzz-utils) - falls back to the full font if absent.

Vendor sync workflow

# Sync every module's compose-fork.txt against the pinned upstream ref.
scripts/compose-fork/sync.sh

Upstream refs live in scripts/compose-fork/compose.properties, one NAME=value per repo, each pinned to a durable TAG. Five repos are vendored now: COMPOSE_CORE_REF (JetBrains/compose-multiplatform-core), COMPOSE_REF (the compose-multiplatform umbrella), KOIN_REF (InsertKoinIO/koin), COIL_REF (coil-kt/coil) and PULSE_REF (bitsycore/pulse-mvi). Each gets its own sibling sparse clone (../cmp-ref, ../cmp-ref-koin, …). Bump the ref → re-sync → let the build tell you what broke. Per-module sync, manifest re-formatting, and the drift / vendor-clean guardrails are in TOOLING.md.

Key files by area - start here when you need to find something

Renderer + main loop

  • compose/desktop/native/desktop-native-window/src/nativeMain/…/ - the window module, one concern per file:
    • ComposeWindow.kt - the PUBLIC surface (ApplicationScope, Window(), nativeComposeApp, nativeComposeWindow) + the main loop.
    • WindowInstance.kt - one SDL window: backend + render backend + root host + its own recomposer/composition, event handling, frame pump, teardown. This is where composition-local seeding lives.
    • AppRuntime.kt - the live-window registry + create/destroy lifecycle.
    • WindowAttributes.kt - the Compose Desktop Window() attribute bundle.
    • FrameTiming.kt - deterministic/virtual frame clocks and the probe-quiescence hooks (disableInfiniteAnimations, useVirtualFrameTime, windowHasInvalidations).
  • compose/ui/ui/src/nativeMain/…/RenderBackend.kt - the interface.
  • compose/ui/ui/src/nativeMain/…/GpuMode.kt - sealed driver picker (Auto / Software / Skia.OpenGL / Skia.Metal).
  • compose/ui/ui/src/skikoRendererMain/…/renderer/skia/SkiaRenderBackend.kt - the Skia render backend (shared by every native target).
  • compose/ui/ui/src/skikoRendererMingwMain/… - mingwX64-only Skia actuals bound against the skiko fork's flat extern-C surface.

Layout / composition wiring

  • compose/ui/ui/src/commonMain/…/node/ComposeRootHost.kt - root LayoutNode host, hit-test, event dispatch, snapshot observer.
  • compose/ui/ui/src/commonMain/…/node/impl/ComposeOwner.kt - the project Owner implementation + ProjectOwnedLayer (graphicsLayer / clip / alpha bridge).
  • compose/ui/ui/src/commonMain/…/node/NodeApplier.kt.

Text (all in :ui-text now)

  • compose/ui/ui-text/src/nativeMain/…/ui/text/SkiaParagraph.native.kt - the Paragraph actual (skiko-free; the 30 interface methods over plain data), driving the skiko engine through the NativeParagraphOps seam.
  • compose/ui/ui-text/src/skikoRendererMain/…/renderer/skia/SkiaParagraphEngine.kt
    • SkiaParagraphOps (builds/queries the real skiko skparagraph) + SkiaFonts (data.kres bytes → skiko Typeface; the port's family/variable-axis model).
  • compose/ui/ui-text/src/nativeMain/…/ui/text/ParagraphFactories.native.kt - actuals for the Paragraph(…) / ParagraphIntrinsics(…) factory family.

Icons

  • compose/foundation/foundation/src/nativeMain/…/icons/IconFontIcon.kt - codepoint-based Icon composable + MaterialIconAxes / MaterialIconAxisDefaults.
  • utils/material-symbols/src/…/MaterialSymbols{Outlined,Rounded,Sharp}.kt.

Resources

  • compose/ui/ui/src/commonMain/…/res/Res.kt - the project's androidx.compose.ui.res reimpl (Painter, ImageLoader).
  • compose/ui/ui/src/nativeMain/…/ResourceIO.kt - opens data.kres once via SDL_GetBasePath() + parses central directory; each entry served by an fseek + fread.

Apps

  • demo/src/nativeMain/kotlin/Main.kt - sidebar demo (--gpu, --screen, --screenshot).
  • apidemo/src/nativeMain/kotlin/Main.kt - API manager entry. apidemo/src/nativeMain/kotlin/UiCompat.kt - project-local Dialog / DropdownMenu / DropdownMenuItem / TooltipBox (m3 doesn't ship drop-in equivalents for our anchor / scrim patterns).

Tooling

The full tooling reference is 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:

  • 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 it before commit.

Open renderer / fidelity / release work is tracked in PLAN.md.

Conventions

Kotlin standard style for formatting (braces, wrapping, indentation).

Naming, as the code actually is - this paragraph used to claim "no prefixes", which had not been true for a long time:

  • m for private/protected members - mWidth, mSurface, mCache. NOT f: the f prefix was retired across the project, so do not reintroduce it.
  • v for local variables - vWindow, vResult.
  • in for function parameters - inTitle, inWidth. Note Compose @Composable parameters are the exception: they stay plain, because the name is part of the call-site API (title = , onCloseRequest = ).
  • k for private file-level constants - kMaxFrames, kIsWindows.
  • Public API surface is plain camelCase - prefixes are for implementation detail only, and nothing prefixed should ever reach the klib dump.

VENDORED code under src/vendor/ follows UPSTREAM's naming, untouched - it is copied byte-for-byte, so never re-prefix anything in it.

Section headers inside a file, when useful:

// ==================
// MARK: Name (file-level / between classes)
// ==================

In-function smaller scope:

// ============
//  Name

Function-level comments only where the name isn't self-documenting - avoid line-by-line commentary. Prefer KDoc (/** … */) over /* … */ for docs that should surface in tooling.

Common pitfalls

  • State changes don't repaint the UI - check Snapshot.sendApplyNotifications() is being called each frame in the main loop (ComposeWindow.kt). Without it, mutableStateOf writes never reach the recomposer.
  • Physical-pixel modifiers double-scale on Retina - under Option-B density, layout runs in physical pixels but Modifier.width(20.dp) still goes through density.toPx(). If you have a value already in physical pixels (from onSizeChanged), convert it back to Dp via with(LocalDensity.current) { pxInt.toDp() } before passing to Modifier.width(…), or use pixel-based lambdas (Modifier.offset { IntOffset(x, y) }).
  • Skia's saveLayer(bounds, paint) - GPU backends allocate the offscreen to bounds. If content inside translates beyond those bounds, it gets clipped. SkiaCanvas.saveLayer passes a huge fixed bounds to sidestep this.
  • Modifier.alpha clips to bounds - upstream contract: Modifier.alpha(x) desugars to graphicsLayer(alpha = x, clip = true). For a drag ghost that also translates, put alpha and translationX on the SAME graphicsLayer(...) so clip stays false.
  • Substituted Maven modules hide their transitives from common metadata - when the bridge swaps an official coord for a project module on native configs, KGP's granular-metadata visibility check drops that Maven module's TRANSITIVES from the commonMain classpath (symptom: Unresolved reference 'Color' / Cannot access class ... in compileCommonMainKotlinMetadata, while per-target compilation is fine). Declare EVERY artifact the common code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its own bridge rule. Note only the WINDOWS publish job compiles common metadata (it owns the root KotlinMultiplatform publications - the only host that declares every target, so only its .module files carry the full variant table; macOS-published roots left v0.1.15 without mingwX64 variants) - test with gradlew :<module>:compileCommonMainKotlinMetadata before tagging.

Useful Gradle tricks

  • --args="--gpu=skia.opengl --screen=Buttons" - pass CLI to the demo.
  • --info - see cinterop classpath + include paths actually used.
  • After a module rename or IC-cache mismatch: nuke demo/build/kotlin-native-ic-cache (or apidemo/build/…). Kotlin/Native pins module IDs into its klib metadata; a stale cache surfaces as Unknown dependent library com.bitsycore.compose.sdl:core (or whatever the old module name was).

Known Compatible - official Maven KMP artifacts used AS-IS

Before reimplementing or vendoring ANYTHING androidx, check Maven: a lot of the architecture stack publishes real Kotlin/Native desktop klibs (mingwX64 + linuxX64/arm64 + macosArm64) and runs on this port unmodified. Sometimes the GOOGLE coordinates (androidx.*) have the K/N variant, sometimes the JETBRAINS ones (org.jetbrains.*) - check both before concluding an artifact "doesn't exist" for our targets.

Verified in-tree (api-exposed by :ui unless noted):

  • org.jetbrains.compose.runtime:runtime / runtime-saveable 1.11.1 - THE Compose runtime (composition, snapshots, recomposer). Never vendored.
  • androidx.compose.runtime:runtime-retain 1.11.1 (google coordinates).
  • androidx.lifecycle:* 2.11.0 (google): lifecycle-runtime-compose, lifecycle-viewmodel, lifecycle-viewmodel-compose, lifecycle-viewmodel-savedstate, lifecycle-viewmodel-navigation3 - the whole ViewModel + SavedStateHandle + nav3-decorator stack needed ZERO reimplementation; only the window-side owners had to be provided (see caveats).
  • androidx.savedstate:savedstate / savedstate-compose 1.5.0 (google).
  • androidx.navigation3:navigation3-runtime 1.1.4 - backstack / NavEntry / decorators. Only navigation3-UI (NavDisplay) lacks a K/N desktop artifact → vendored as :navigation3-ui.
  • androidx.navigationevent:navigationevent-compose 1.1.2 - predictive-back event plumbing (BackHandler, NavDisplay gestures).
  • androidx.collection:collection - plain Maven dep, not a module.
  • androidx.window:window-core 1.5.0 - WindowSizeClass / the adaptive window model. Publishes mingwX64 + linux + macos under BOTH coordinate sets; the port takes the google one like the rest of the androidx stack. This is what makes the material3-adaptive vendoring a pure re-target: the dependency everybody assumed was the blocker was never missing.
  • io.insert-koin:koin-core 4.2.2 - the DI container itself publishes mingwX64 + linux + macos. Only Koin's viewmodel / compose layers stop at apple+android and are vendored (koin/).
  • androidx.navigationevent:navigationevent-compose 1.1.2 AND the jetbrains org.jetbrains.androidx.navigationevent:navigationevent-compose 1.1.0 - BOTH publish mingwX64 + linux + macos. Nothing to vendor here.
  • NOT compatible (vendored instead): components-resources (no mingw/linux klibs → :components:resources:components-resources), navigation3-ui (same → :navigation3:navigation3-ui), Koin's viewmodel/compose modules (→ koin/), the whole Coil 3 stack (no mingwX64 anywhere; no desktop native at all for its compose layer → coil/), and Pulse MVI (no desktop native → pulse/).
  • Always check BOTH coordinate sets before concluding an artifact has no desktop-native klibs. androidx.* and org.jetbrains.androidx.* are different artifacts with the same classes; sometimes only one carries the K/N variant, and where both do, pick ONE - putting both on the path duplicates every class. This port standardises on the GOOGLE androidx.lifecycle:* coordinates, so vendored upstream code that referenced the jetbrains mirrors is repointed at the google ones in the module's build file.
  • Infra: kotlinx-coroutines-core, atomicfu, okio, kotlinx-serialization.

Caveats that make these work here (all already wired - listed so nobody "fixes" them away):

  • The google LocalViewModelStoreOwner / LocalSavedStateRegistryOwner / LocalLifecycleOwner are PLAIN composition locals - the JB HostDefault mechanism (compositionLocalWithHostDefaultOf) does not exist in google artifacts. WindowArchitectureOwner (WindowInstance.kt) provides all three per window, mirrors upstream desktop's DefaultArchitectureComponentsOwner, calls enableSavedStateHandles() at construction, and follows SDL focus / visibility (focused → RESUMED, unfocused → STARTED, minimised → CREATED).
  • The window composes its FIRST composition at CREATED and resumes after - enableSavedStateHandles() callers running in composition require lifecycle ≤ CREATED. Related contract: rememberViewModelStoreOwner() with its default savedStateRegistryOwner THROWS at a RESUMED call site (same on Android) - scope shared VMs to the window owner (viewModel { } outside the entries, the activityViewModels() analog) instead.
  • nav3 entry decorators: saveable BEFORE viewmodel - listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()).
  • Dispatchers.Main.immediate must run inline on the main thread (Sdl3MainDispatcher) - androidx.lifecycle's main-thread enforcement round-trips through it; a queue-only Main deadlocks composition.
  • Regression probes: demo --nav3test (nav3 + ViewModels + lifecycle, composed late at RESUMED like the real sidebar flow), --backtest (navigationevent), --multiwintest (per-window owners).

License

MIT - see LICENSE.md.