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

259 lines
14 KiB
Markdown

# 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 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`.
```bash
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`.
```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
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.
```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 the Skia renderer and exits non-zero on any
failure:
```bash
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.
```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.
### 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.
```bash
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:
```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 --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).
```bat
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](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.
4. Verify a consumer build against the new version resolves through the bridge.