Files
Bitsy c4564b3a10 feat(demo): make the Adaptive screen fold on a live resize
The first version of this screen proved the vendored adaptive modules
render, but it did not really demonstrate adaptation: it left
rememberListDetailPaneScaffoldNavigator on its default directive, which
is the WINDOW size class, and pinned the scaffold to a fixed 420dp.

The window is the wrong input here. The screen does not get the window -
the demo shell spends 190dp on the sidebar plus 48dp of padding - so
defaulting folds ~240dp too late and leaves two panes side by side in a
region already too cramped for them. The directive is now computed from
the width the screen actually gets (BoxWithConstraints), which is what
any pane-hosting app that is not full-window should do; the pane height
tracks the window height instead of being a constant. The header prints
the window size class and the pane-area size class side by side so the
difference is visible while dragging.

Also adds selection highlighting and a Back button that only appears
while folded, so the single-pane mode is actually navigable.

New `--adaptivetest` probe: drives one window wide -> narrow -> wide with
setSize and asserts the scaffold folds and unfolds. This is what proves
the resize chain end to end - SDL resize, the owner's snapshot-backed
containerSize, recomposition, BoxWithConstraints re-measure, a new
PaneScaffoldDirective on the navigator, a new scaffold value. The
--screen=Adaptive --width=N screenshots could only ever prove the fold
was right for the size the window OPENED at, since each run is a fresh
process. The probe reads a small internal seam on the screen
(gAdaptiveTwoPane); it is documented as a probe hook and is not UI.

Verified on mingwX64: folds at 800dp and stays two-pane at 1100/1400dp,
--adaptivetest passes repeatedly, and the JVM parity leg plus commonMain
metadata still compile.
2026-09-22 16:46:57 +02:00

14 KiB

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.

Native libraries

SDL3 is built from source as a static library and linked straight into the executable - the windowing, input, and platform-integration layer. One script does it on every OS. Output lands in the gitignored libs/; the version is pinned in scripts/build-sdl/build-sdl.properties.

python3 scripts/build-sdl/build-all.py            # sdl3
python3 scripts/build-sdl/build-all.py sdl3       # rebuild the 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 version.

The static lib is built slim: the port uses SDL only for video/window, events, clipboard, file dialogs, GL/Metal contexts, the CPU-raster SDL_Render blit, filesystem, cursor, locale, theme, and text input/IME. build-all.py disables every unused subsystem - audio, joystick, haptic, hidapi, sensor, power, camera, GPU, offscreen, virtual-joystick (all zero-reference), plus tests/examples and the Windows D3D12 driver. If a consumer app ever needs one of these (e.g. SDL audio), re-enable its -DSDL_* flag in build-all.py and rebuild - a build-time change, no code edit.

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.

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.

Guardrails that keep the vendor tree honest:

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
python3 scripts/compose-fork/audit-exclusions.py       # every `!` exclusion is classified

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).

audit-exclusions covers the other half: a manifest ! line takes a file OUT of the verbatim sync, and the local replacement is either a derived copy (// VENDOR-BASE:, drift-tracked) or a fresh reimplementation (// VENDOR-REIMPL:, deliberately not tracked - only a SIGNATURE change matters). It classifies each one, flags any local counterpart carrying NEITHER marker (invisible to the drift check, silently rotting) and any copy that has become identical to upstream (drop it and re-vendor), and exits non-zero on an unclassified file. Run all three on every ref bump.

Coverage against upstream

How much of upstream's public API the port covers is measured, not guessed.

./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 the Skia renderer and exits non-zero on any failure:

scripts/verify-mac.sh

It runs: 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.

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.

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.

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.

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.

Text metrics diagnostic

Set CDN_TEXT_METRICS=1 and run any app to dump, per built paragraph, the font scaler's ascent/descent/leading, the resolved familyName + fontPx, and the resulting paragraph line box (paraHeight, line0[ascent/descent/baseline/ height]). Its job is the mac-vs-Windows vertical-spacing question (PLAN.md ยง1b): the SAME NotoSans.ttf bytes go through a different Skia FontMgr scaler per host (CoreText / fontconfig-FreeType / DirectWrite), which can pick different metric tables. Run it on native AND on the JVM parity app on the SAME host and diff the lines - Windows-native must match skiko-JVM-Windows.

CDN_TEXT_METRICS=1 demo.kexe --screen=Buttons --screenshot=/tmp/b.bmp

Demo probes

:demo ships headless regression probes driven by CLI flags, used by the verify runbook and in isolation:

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 --adaptivetest    # material3-adaptive folds/unfolds on a LIVE window resize
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).

gradlew.bat :demo:runDebugExecutableMingwX64
gradlew.bat :apidemo:runDebugExecutableMingwX64

mingwX64 renders through Skia via the bitsycore skiko fork, consumed from the PUBLIC https://maven.bitsycore.com/releases (no credentials) as com.bitsycore.skiko:skiko:0.150.1-mingw.2 (macOS/Linux use official Skiko). The runtime skiko-windows-x64.dll is auto-provisioned next to the executable by the bridge plugin - no manual copy. The fork itself is published by a separate GitHub Actions workflow in the fork repo, out of band from this repo's release flow.

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.

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. Groups mirror upstream per area (com.bitsycore.compose.<area>:<module>, e.g. com.bitsycore.compose.ui:ui); project-only modules use com.bitsycore.compose.sdl and :desktop-native-window is com.bitsycore.compose - see groupFor() in the root build. 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.
Vendored ecosystem libs KOIN_REF, COIL_REF, PULSE_REF in the same file, plus koin / coil in gradle/libs.versions.toml. Same rule: durable tags. Each repo gets its own sibling sparse clone (../cmp-ref-koin, ../cmp-ref-coil, ../cmp-ref-pulse-mvi). Re-sync, then apiDump - these modules are BCV-validated like the rest.
JVM parity forcing compose / composeMaterial3 / composeRuntime in gradle/libs.versions.toml (read by demo, apidemo, material-symbols and the root build's forcing map). Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published; at v1.12.1 they are in lockstep.
Skiko skiko (official) and skikoMingw (the fork) in gradle/libs.versions.toml; keep the fork's -mingw.N base equal to skiko. macOS/Linux use official Skiko (org.jetbrains.skiko); mingwX64 uses the bitsycore fork (com.bitsycore.skiko:skiko:0.150.1-mingw.2 from the public maven.bitsycore.com), published out of band by the fork repo's own workflow. Must expose the org.jetbrains.skiko.node RenderNode / GraphicsContext API the vendored compose-core uses (the fork keeps upstream's org.jetbrains.skiko.* package names; only the Maven coord is rebranded). Verify with a throwaway skikoRendererMain compile if unsure.
SDL3 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 the relevant *_REF in compose.properties - COMPOSE_CORE_REF / COMPOSE_REF for Compose, KOIN_REF / COIL_REF / PULSE_REF for the ecosystem libs (and the matching entry 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. Then python3 scripts/compose-fork/audit-exclusions.py - a newly-identical copy should go back to verbatim vendoring, and an unclassified one needs a marker. Finish with ./gradlew apiDump and review the klib API diff.
  4. Build and fix any fallout.
  5. scripts/verify-mac.sh (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 :desktop-native-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. Refresh the klib API baselines: on a Windows host (JDK 21, matching CI) run ./gradlew apiDump and commit any diff. Not a hard gate yet - the API is pre-stable and apiCheck isn't wired - but regenerating each release keeps the baselines honest so the eventual gate is a no-op. macosArm64 is inferred from the linux/mingw ABIs (accurate for the target-independent Compose surface); :sdl-core (cinterop) and :material-symbols (generated icon maps) are excluded via apiValidation.ignoredProjects. Best host: Windows - it builds the mingw slice (the fork's unique surface) for real and only infers macos; the macos ABI is target-independent for the tracked pure-Kotlin modules.
  4. 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 their per-area com.bitsycore.compose.* coords. 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.
  1. Verify a consumer build against the new version resolves through the bridge.