Compare commits

..
1 Commits
Author SHA1 Message Date
Bitsy 164d2b5e66 style: replace em dashes with plain punctuation 2026-08-09 21:45:57 +02:00
288 changed files with 1421 additions and 1421 deletions
+17 -17
View File
@@ -23,7 +23,7 @@ jobs:
# Each host runs only the publish tasks Gradle actually generated for it.
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
# (see its matrix entry) + the jvm jar + the bridge plugin.
# `target` is the Gradle K/N target token (Capitalised) — one per host,
# `target` is the Gradle K/N target token (Capitalised) - one per host,
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
# skipped for the app build because SDL3 static libs on the ubuntu runner
# are host-arch only; cross-compile would fail at link time.
@@ -49,7 +49,7 @@ jobs:
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
# the ONLY host that declares every target (vHostSupportsMingw is
# host-gated), so only its generated .module files carry the full
# variant table — the macOS-published roots of v0.1.15 had NO
# variant table - the macOS-published roots of v0.1.15 had NO
# mingwX64 variants and Windows consumers could not resolve them.
# The host-independent jvm jar (:material-symbols) and the bridge
# plugin ride along on the same job.
@@ -102,13 +102,13 @@ jobs:
# windows-2022 runners pre-install Chocolatey's mingw-w64 at C:\mingw64.
# SDL3 >= 3.4 needs a C++ compiler for its GameInput backend (K/N's
# bundled mingw is C-only) — this pre-install covers that. The setjmp
# bundled mingw is C-only) - this pre-install covers that. The setjmp
# intrinsic K/N's LLD can't resolve is aliased to msvcrt's _setjmpex at
# link time via --defsym in sdl3.def.
# Build the static SDL3 lib (scripts/build-sdl/build-sdl.properties). Every
# target renders through Skia now, so all platforms need only base SDL3 for
# the main loop (windowing / input / audio / filesystem) — the from-scratch
# the main loop (windowing / input / audio / filesystem) - the from-scratch
# SDL renderer and its SDL3_ttf / SDL3_image / FreeType deps were removed.
# Cached against the manifest + scripts so version bumps invalidate cleanly;
# bump the `v3` key when the toolchain (runner OS, compiler flavour, K/N
@@ -125,7 +125,7 @@ jobs:
shell: bash
run: python3 scripts/build-sdl/build-all.py
# src/vendor/ trees are gitignored — a fresh checkout has zero vendored
# src/vendor/ trees are gitignored - a fresh checkout has zero vendored
# androidx.compose.* files. Sync copies them in from the upstream refs
# pinned in scripts/compose-fork/compose.properties. Needs python3 + git;
# both are pre-installed on every runner.
@@ -138,7 +138,7 @@ jobs:
# SEQUENTIALLY from the first one. So when an upload dies mid-publication
# (the v0.1.13 macOS run lost DNS mid-publish), a blind retry hits 409
# Conflict on the first already-uploaded file and aborts BEFORE reaching
# the missing tail files — the version is poisoned and can never
# the missing tail files - the version is poisoned and can never
# complete. A 409 is therefore only benign when the publication had
# already completed; you can't tell from the 409 alone. The fix: between
# attempts, DELETE this host's package versions for the current release
@@ -160,7 +160,7 @@ jobs:
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
esac
# Published group per module — mirrors the per-area com.bitsycore groups
# Published group per module - mirrors the per-area com.bitsycore groups
# in root build.gradle.kts. Project-only modules (sdl-core, material-symbols)
# fall through to com.bitsycore.compose.sdl; the GitHub Packages Maven
# package name is "<group>.<artifact>".
@@ -202,7 +202,7 @@ jobs:
-H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions/$vid")
if [ "$code" != "204" ]; then
# Deleting the LAST version of a package is refused (400) —
# Deleting the LAST version of a package is refused (400) -
# a package new in this release: drop the whole package.
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: Bearer $GITHUB_TOKEN" \
@@ -219,7 +219,7 @@ jobs:
fi
echo "::endgroup::"
if [ $attempt -lt 3 ]; then
echo "::warning::Publish attempt $attempt failed — cleaning this host's $VER packages and retrying"
echo "::warning::Publish attempt $attempt failed - cleaning this host's $VER packages and retrying"
cleanup_host_versions || true
sleep 30
fi
@@ -247,19 +247,19 @@ jobs:
fi
echo "::endgroup::"
if [ $attempt -lt 3 ]; then
echo "::warning::Bitsycore publish attempt $attempt failed — retrying"
echo "::warning::Bitsycore publish attempt $attempt failed - retrying"
sleep 30
fi
done
echo "::error::Bitsycore publish failed after 3 attempts"
exit 1
# Release binaries for the host target — the binary plus the data.kres
# Release binaries for the host target - the binary plus the data.kres
# (bundled composeResources + fonts + subset icon-font). Each app's
# linkReleaseExecutable<Target> auto-runs its data.kres zip task —
# linkReleaseExecutable<Target> auto-runs its data.kres zip task -
# :demo's own copy<Variant>ComposeResources<Target>, :apidemo's
# bridge-plugin package<Variant>ComposeResources<Target> — so data.kres
# is produced next to the .kexe / .exe. On Linux only linuxX64 is built —
# bridge-plugin package<Variant>ComposeResources<Target> - so data.kres
# is produced next to the .kexe / .exe. On Linux only linuxX64 is built -
# linuxArm64 is skipped since the SDL3 static libs on the ubuntu runner
# are host-arch (x64).
- name: Build demo + apidemo release binaries
@@ -274,7 +274,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
# Lower-case first letter — gradle target tokens are Capitalised but
# Lower-case first letter - gradle target tokens are Capitalised but
# the build/bin dir uses the plain target name (macosArm64 etc).
target_dir=$(echo "${{ matrix.target }}" | awk '{print tolower(substr($0,1,1)) substr($0,2)}')
version="${GITHUB_REF_NAME#v}"
@@ -283,8 +283,8 @@ jobs:
src="$app/build/bin/$target_dir/releaseExecutable"
zip_path="$(pwd)/dist/$app-$version-$target_dir.zip"
files=("$src/$app.${{ matrix.exe_ext }}" "$src/data.kres")
# Windows Skia leg ships two runtime sidecars next to the exe — the
# skiko fork DLL and Skia's ICU data (icudtl.dat) — provisioned by the
# Windows Skia leg ships two runtime sidecars next to the exe - the
# skiko fork DLL and Skia's ICU data (icudtl.dat) - provisioned by the
# bridge plugin after linkReleaseExecutableMingwX64. The exe cannot run
# without them, so include them (and fail loudly if a provision regressed).
if [ "${{ matrix.exe_ext }}" = "exe" ]; then
+1 -1
View File
@@ -53,7 +53,7 @@ bin/
### Vendored Compose sources ###
# Byte-for-byte verbatim copies from JetBrains/compose-multiplatform-core,
# regenerated on demand by scripts/compose-fork/sync.sh. Not committed — only the
# regenerated on demand by scripts/compose-fork/sync.sh. Not committed - only the
# sync tooling (script + <module>/compose-fork.txt manifests + pinned ref + README)
# is tracked.
**/src/vendor/
+129 -129
View File
@@ -1,42 +1,42 @@
# 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.
context - read it first, then look at the files it points to.
## Documentation map
- [README.md](README.md) — public overview + quickstart (bridge plugin,
- [README.md](README.md) - public overview + quickstart (bridge plugin,
`nativeComposeWindow`, sample apps).
- Renderer — one Skia `RenderBackend` (Metal on macOS / OpenGL on Linux+Windows /
- 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()`
(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](PLAN.md).
- [TOOLING.md](TOOLING.md) — build/vendor/verify scripts and workflows
- [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.
- [PLAN.md](PLAN.md) §2 — audited list of no-ops, stubs, and hardcodes left in the
- [PLAN.md](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
- 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
**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
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
- **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 GitHub Packages as
`com.bitsycore.skiko:skiko:0.150.1-mingw.1` (override with
@@ -47,7 +47,7 @@ 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
`org.jetbrains.compose.runtime` klibs from Maven** - this project only
re-implements the layers on top (`androidx.compose.ui.*`, `.foundation.*`,
`.animation.*`, `.material3.*`).
@@ -55,7 +55,7 @@ re-implements the layers on top (`androidx.compose.ui.*`, `.foundation.*`,
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
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/window/`. `:ui` depends on `:sdl-core` and its renderer
+ SDL↔Compose bridges pick the cinterop from it.
@@ -66,56 +66,56 @@ One Gradle module per upstream artifact; the directory mirrors the upstream
```
compose/
├── ui/
│ ├── ui/ → :ui — androidx.compose.ui.* CORE (Modifier, LayoutNode,
│ │ composition, semantics, input, focus) + com.compose.sdl.* —
│ ├── 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/ → :ui-graphics — androidx.compose.ui.graphics.* + the Skia actuals
│ │ the sdl3 cinterop were split OUT - upstream layout.)
│ ├── ui-graphics/ → :ui-graphics - androidx.compose.ui.graphics.* + the Skia actuals
│ │ (SkiaBackedCanvas/Path/Paint, GraphicsLayer, SkiaImageCache,
│ │ painter/image + resource seams). → skiko; SDL-free.
│ ├── ui-text/ → :ui-text — androidx.compose.ui.text.* + the skiko text engine
│ ├── ui-text/ → :ui-text - androidx.compose.ui.text.* + the skiko text engine
│ │ (SkiaParagraph → NativeParagraphOps → SkiaFonts, IconFont,
│ │ NamedFont). → :ui-graphics, skiko; SDL-free.
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
│ ├── ui-backhandler/ → :ui-backhandler — androidx.compose.ui.backhandler.*
│ └── ui-tooling-preview/ → :ui-tooling-preview — androidx.compose.ui.tooling.preview.*
│ ├── ui-util/ → :ui-util - androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
│ ├── ui-geometry/ → :ui-geometry - androidx.compose.ui.geometry.*
│ ├── ui-unit/ → :ui-unit - androidx.compose.ui.unit.*
│ ├── ui-backhandler/ → :ui-backhandler - androidx.compose.ui.backhandler.*
│ └── ui-tooling-preview/ → :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
│ mingwX64/linux klibs). IDE-only metadata - previews
│ render through the apps' jvm parity targets.
├── animation/
│ ├── animation-core/ → :animation-core — androidx.compose.animation.core.*
│ ├── animation/ → :animation — androidx.compose.animation.* (non-core)
│ └── animation-graphics/ → :animation-graphics — androidx.compose.animation.graphics.*
│ ├── animation-core/ → :animation-core - androidx.compose.animation.core.*
│ ├── animation/ → :animation - androidx.compose.animation.* (non-core)
│ └── animation-graphics/ → :animation-graphics - androidx.compose.animation.graphics.*
├── foundation/
│ ├── foundation/ → :foundation — androidx.compose.foundation.*
│ └── foundation-layout/ → :foundation-layout — androidx.compose.foundation.layout.*
│ ├── foundation/ → :foundation - androidx.compose.foundation.*
│ └── foundation-layout/ → :foundation-layout - androidx.compose.foundation.layout.*
├── material3/
│ └── material3/ → :material3 — androidx.compose.material3.*
│ └── material3/ → :material3 - androidx.compose.material3.*
├── material/
│ └── material-ripple/ → :material-ripple — androidx.compose.material.ripple.*
└── desktop/native/window/ → :desktop-native-window — nativeComposeApp { Window(...) {} }
│ └── material-ripple/ → :material-ripple - androidx.compose.material.ripple.*
└── desktop/native/window/ → :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-core — the NAKED sdl3 cinterop + platform primitives
└── sdl-core/ → :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/ → :material-symbols — codepoints + all three style objects
└── material-symbols/ → :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
(Typeface.makeClone per axes - upstream's FontCache
drops variationSettings from its key). Its commonMain
declares official Maven compose coords — the root
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
@@ -127,7 +127,7 @@ utils/
jvmProcessResources stages the same fonts (jvm).
components/
└── resources/library/ → :components-resources — the OFFICIAL Compose resources
└── resources/library/ → :components-resources - the OFFICIAL Compose resources
runtime (org.jetbrains.compose.components:
components-resources), VENDORED from the
compose-multiplatform UMBRELLA repo (the first
@@ -137,11 +137,11 @@ components/
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
Apps' JVM targets keep the Maven artifact - the
generated Res accessors work against BOTH.
navigation3/
└── navigation3-ui/ → :navigation3-ui — androidx.navigation3.ui.* + scene machinery,
└── navigation3-ui/ → :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
@@ -152,21 +152,21 @@ navigation3/
(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.
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
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
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 —
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
@@ -179,23 +179,23 @@ 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
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 —
(dogfooding: data.kres packaging - and apidemo's app icon -
run through the plugin).
Three halves: (1) substitution — third-party apps declare
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
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
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
@@ -206,10 +206,10 @@ libs/ → gitignored per-host static SDL3 output of
scripts/build-sdl/build-all.py on Windows
```
Module PATHS stay short (`:ui`, `:foundation`, `:desktop-native-window`, …) —
Module PATHS stay short (`:ui`, `:foundation`, `:desktop-native-window`, …) -
`settings.gradle.kts` redirects `projectDir` for each so build files across
the repo stay terse. `androidx.collection` is a plain Maven dependency
(`androidx.collection:collection`), not a module — same as other simple
(`androidx.collection:collection`), not a module - same as other simple
androidx KMP libs.
## Dependency graph
@@ -224,7 +224,7 @@ androidx KMP libs.
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)`;
`: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`;
@@ -241,28 +241,28 @@ 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
## 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
`<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`
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
`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
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
@@ -271,17 +271,17 @@ 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
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.
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
`src/skikoRendererMain/`)** - code we author (project actuals, glue between
Compose and SDL3, project-specific extensions).
### The 5 rules for adding upstream Compose surface
@@ -299,7 +299,7 @@ Two categories of code live in each module:
`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,
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
@@ -315,15 +315,15 @@ Two categories of code live in each module:
`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
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
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;
@@ -333,14 +333,14 @@ Two categories of code live in each module:
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
`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
reimpl in project code - same signature (same package, same params) so
call sites don't care.
## Source-set hierarchy (:ui only)
@@ -353,11 +353,11 @@ 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)
│ ├── 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
└── 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.
@@ -375,22 +375,22 @@ 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 +
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`).
dependency, so `:ui → :sdl-core` is cycle-free - the compose way (like `ui → skiko`).
## Density flow (Option B — layout in physical pixels)
## 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
- `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
- `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
@@ -399,7 +399,7 @@ We use the **physical-pixel layout flow** on HiDPI. Concretely:
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.
(`Modifier.offset { IntOffset(x, y) }`) - they take pixels directly.
Passing raw `px.dp` will double-scale on Retina.
## Building
@@ -413,14 +413,14 @@ Passing raw `px.dp` will double-scale on Retina.
./gradlew :demo:runDebugExecutableLinuxX64
./gradlew :apidemo:runDebugExecutableLinuxX64
# Windows (from Windows — mingw cross-build from macOS/Linux fails at cinterop).
# Windows (from Windows - mingw cross-build from macOS/Linux fails at cinterop).
# Links the bitsycore skiko FORK from GitHub Packages (com.bitsycore.skiko:skiko:
# 0.150.1-mingw.1, 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
# 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
@@ -429,41 +429,41 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
### 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
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](TOOLING.md#native-libraries).
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
(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
## 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
- `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
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
- `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
(`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):
`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.
`apt install harfbuzz-utils`) - falls back to the full font if absent.
## Vendor sync workflow
@@ -472,61 +472,61 @@ executable, loaded via `SDL_GetBasePath()`). Contents:
scripts/compose-fork/sync.sh
```
Upstream ref: `scripts/compose-fork/compose.properties` — set to a durable tag
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. 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
## Key files by area - start here when you need to find something
### Renderer + main loop
- `compose/desktop/native/window/src/nativeMain/…/ComposeWindow.kt` — main loop,
- `compose/desktop/native/window/src/nativeMain/…/ComposeWindow.kt` - main loop,
recomposer lifecycle, SDL event dispatch, composition-local seeding.
- `compose/ui/ui/src/nativeMain/…/RenderBackend.kt` — the interface.
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` — sealed driver picker
- `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` —
- `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
- `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
- `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
- `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
- `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`
- `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` —
- `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` —
- `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
- `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
- `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`,
- `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
- `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).
@@ -547,9 +547,9 @@ Open renderer / fidelity / release work is tracked in [PLAN.md](PLAN.md).
## Conventions
Kotlin standard style — plain `camelCase` for parameters, local variables,
Kotlin standard style - plain `camelCase` for parameters, local variables,
and fields. **No `f`/`in`/`v` prefixes**, no SPIRTECH scheme (see the global
CLAUDE.md — that scheme is C-only).
CLAUDE.md - that scheme is C-only).
Section headers inside a file, when useful:
@@ -566,31 +566,31 @@ In-function smaller scope:
// Name
```
Function-level comments only where the name isn't self-documenting — avoid
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
- **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
- **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
- **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` 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** —
- **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
@@ -598,74 +598,74 @@ that should surface in tooling.
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
(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) —
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.
- `--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
## 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
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 —
- `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`** —
`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
- `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
- `androidx.navigationevent:navigationevent-compose` 1.1.2 - predictive-back
event plumbing (BackHandler, NavDisplay gestures).
- `androidx.collection:collection` — plain Maven dep, not a module.
- `androidx.collection:collection` - plain Maven dep, not a module.
- NOT compatible (vendored instead): `components-resources` (no mingw/linux
klibs → `:components-resources`), `navigation3-ui` (same → `:navigation3-ui`).
- Infra: `kotlinx-coroutines-core`, `atomicfu`, `okio`,
`kotlinx-serialization`.
Caveats that make these work here (all already wired — listed so nobody
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
`LocalLifecycleOwner` are PLAIN composition locals - the JB HostDefault
mechanism (`compositionLocalWithHostDefaultOf`) does not exist in google
artifacts. `WindowArchitectureOwner` (ComposeWindow.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 —
- 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
on Android) - scope shared VMs to the window owner (`viewModel { }` outside
the entries, the `activityViewModels()` analog) instead.
- nav3 entry decorators: saveable BEFORE viewmodel —
- 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
(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`
@@ -673,4 +673,4 @@ Caveats that make these work here (all already wired — listed so nobody
## License
MIT — see [LICENSE.md](LICENSE.md).
MIT - see [LICENSE.md](LICENSE.md).
+148 -148
View File
@@ -1,7 +1,7 @@
# ROAD TO 1.0.0
**Objective:** get ComposeNativeSDL3 **as close to upstream Compose Multiplatform as
possible** — vendor every file we can instead of rewriting, stay performant, and look
possible** - vendor every file we can instead of rewriting, stay performant, and look
faithful (ideally pixel-identical across macOS / Linux / Windows). The port is already at
99% upstream API coverage with maximal vendoring and no steady-state perf gap on
macOS/Metal; the remaining 1.0.0 work is **cross-platform rendering fidelity** (Windows
@@ -9,87 +9,87 @@ skiko-fork divergences), **completing native-actual stubs**, and the release mec
(1.12.0-stable re-pin + WIN-SMOKE).
**Status / handoff:** the macOS/Linux-verifiable work is done and committed on `main` (see
**"Landed toward 1.0.0"** below — don't redo it). The remaining blockers need a **Windows host**
→ jump to **"▶ Continuing on Windows (mingwX64) — START HERE"**. This file is self-contained: it
**"Landed toward 1.0.0"** below - don't redo it). The remaining blockers need a **Windows host**
→ jump to **"▶ Continuing on Windows (mingwX64) - START HERE"**. This file is self-contained: it
has the build/run commands, the exact confirm steps, the macOS baseline data to diff against, and
per-task fixes. So on Windows you can say **"continue PLAN.md"** and have everything.
## Definition of done for 1.0.0
- [x] No text tofu on any platform — Windows tab fix **CONFIRMED on-device** (§1a, 2026-08-04:
- [x] No text tofu on any platform - Windows tab fix **CONFIRMED on-device** (§1a, 2026-08-04:
literal `\t`→space, no `.notdef`); CJK / color-emoji / control chars render clean too (§1c).
- [x] Fork-vs-official divergence surface (FontMgr, gamma, ICU) **audited on native Windows**
(2026-08-04): metrics == JVM (§1b), FontMgr fallback works (CJK + color emoji, §1c), ICU
classification works (§1c). Net — the fork's Skia matches upstream; the only deltas are its
classification works (§1c). Net - the fork's Skia matches upstream; the only deltas are its
thin extern-C bindings, handled in shared Kotlin (LineMetrics reconstruction). No fork-side
fix needed for 1.0.
- [~] Native-actual fidelity blockers closed: float pointer coords **DONE**, screen-reader
no-op **DONE**, text context menu **RECONCILED — already works** via the legacy path
no-op **DONE**, text context menu **RECONCILED - already works** via the legacy path
(§2). Date/time localization is P1 polish (formatter already works).
- [x] Vendor hygiene stays clean: zero drift (all 10 manual vendors match pin), zero
commonMain rule-1 violations, zero vendored files touched this session — verified via
commonMain rule-1 violations, zero vendored files touched this session - verified via
`check-vendor-drift.py`.
- [~] SDL static lib slimmed to the used subsystem surface (§3) — **DONE + verified on
- [~] SDL static lib slimmed to the used subsystem surface (§3) - **DONE + verified on
macOS/Metal**; other hosts pending WIN-SMOKE.
- [ ] Refs re-pinned to Compose **1.12.0 stable**, JVM parity versions bumped, WIN-SMOKE
fidelity pass green on a real Windows host.
- [x] `CLAUDE.md` documentation map consistent with tree — line-1 typo fixed; the stale
- [x] `CLAUDE.md` documentation map consistent with tree - line-1 typo fixed; the stale
historical docs `RENDERER.md` + `SKIKO-MINGW-FEASIBILITY.md` removed (current renderer
essentials inlined into CLAUDE.md, the rest is in git history); all dead doc links
(TODO.md / RENDERER.md / SKIKO) removed or repointed to PLAN.md.
## Landed toward 1.0.0 (this pass — all build + run verified on macOS/Metal)
## Landed toward 1.0.0 (this pass - all build + run verified on macOS/Metal)
Everything below is committed; details + file refs are in the sections that follow.
1. **Tab tofu fix** (§1a P0) — normalize `\t`→space before shaping so the Windows fork
1. **Tab tofu fix** (§1a P0) - normalize `\t`→space before shaping so the Windows fork
can't render `.notdef`. Platform-independent, length-preserving.
2. **HiDPI caret quantization fix** (§2 P0) — pointer/wheel coords carried as `Float`
2. **HiDPI caret quantization fix** (§2 P0) - pointer/wheel coords carried as `Float`
end-to-end instead of truncated to `Int`.
3. **Screen-reader no-op** (§2 P0) — `LocalPlatformScreenReader` defaults to inactive
3. **Screen-reader no-op** (§2 P0) - `LocalPlatformScreenReader` defaults to inactive
instead of throwing.
4. **SDL slimming** (§3) — 10 unused subsystems disabled in `build-all.py`; 6 now-dead
4. **SDL slimming** (§3) - 10 unused subsystems disabled in `build-all.py`; 6 now-dead
macOS frameworks dropped from `sdl3.def`. Rebuilt; demo + apidemo link, demo runs.
5. **Context menu reconciled** (§2 P0) — confirmed already working via the vendored legacy
path; the 3 "NOP" seams are vestigial (disabled new API) — misleading TODOs corrected.
6. **`CDN_TEXT_METRICS` diagnostic** (§1b) — env-gated line-metrics dump; macOS baseline
5. **Context menu reconciled** (§2 P0) - confirmed already working via the vendored legacy
path; the 3 "NOP" seams are vestigial (disabled new API) - misleading TODOs corrected.
6. **`CDN_TEXT_METRICS` diagnostic** (§1b) - env-gated line-metrics dump; macOS baseline
captured, hypothesis sharpened (fork likely drops lineHeight leading).
7. **Doc hygiene** (§5) — removed the stale historical `RENDERER.md` +
7. **Doc hygiene** (§5) - removed the stale historical `RENDERER.md` +
`SKIKO-MINGW-FEASIBILITY.md` (renderer essentials folded into `CLAUDE.md`; rest in git
history), fixed the `CLAUDE.md` typo + all dead doc links; extracted the fork
FreeType-scaler lead into §1c before deleting. Vendor drift verified clean.
**Windows pass (2026-08-04, this host — DPR 1).** On a real Windows host at last: (8) tab fix
**CONFIRMED on-device** (§1a); (9) vertical metrics **RESOLVED** — native `--metricsprobe` heights
**Windows pass (2026-08-04, this host - DPR 1).** On a real Windows host at last: (8) tab fix
**CONFIRMED on-device** (§1a); (9) vertical metrics **RESOLVED** - native `--metricsprobe` heights
== JVM `--metrics` exactly, feared "dropped leading" DISPROVEN (§1b); (10) fork `LineMetrics.
ascent/descent` mis-decode **FIXED in shared Kotlin** (`SkiaParagraphOps.lineMetrics()`, guarded
no-op on official skiko) — repairs Windows caret/selection; (11) fork FontMgr/ICU **AUDITED** — CJK
no-op on official skiko) - repairs Windows caret/selection; (11) fork FontMgr/ICU **AUDITED** - CJK
+ color-emoji + control-char fallback all work → the "empty FreeType stub" fear DISPROVEN (§1c);
(12) `FontRasterizationSettings.native.kt` verified to mirror upstream VERBATIM + given a
`VENDOR-BASE` provenance line; rasterization stays upstream-faithful per-OS (user decision). Net:
the fork's Skia matches upstream — the only deltas are its thin extern-C bindings, fixed in shared
the fork's Skia matches upstream - the only deltas are its thin extern-C bindings, fixed in shared
Kotlin, **zero fork rebuild**.
**Still not done — WIN-SMOKE interactive + release** (§5): interactive caret-height check (the one
**Still not done - WIN-SMOKE interactive + release** (§5): interactive caret-height check (the one
behavioral consequence of the LineMetrics fix), the `PrintWindow` probe, `compileCommonMainKotlin
Metadata`, apiDump + publish FROM Windows, and the Compose 1.12.0-stable re-pin. **Deliberately
deferred** (large/structural, rationale in-section): RTL, `PlatformFontLoader`, grapheme source-set
move, date CLDR, brush/gradient text — plus the big **vendor-the-upstream-skiko-text-engine
move, date CLDR, brush/gradient text - plus the big **vendor-the-upstream-skiko-text-engine
refactor (§6)**, sequenced after these fixes land.
---
## ▶ Continuing on Windows (mingwX64) — START HERE
## ▶ Continuing on Windows (mingwX64) - START HERE
**Read this before doing anything.** This file is the single source of truth for the road to
1.0.0. Everything in **"Landed toward 1.0.0"** above is **already implemented and committed on
`main`** — do NOT redo it, just verify it on this host. Every open `[ ]` / `[~]` below is
`main`** - do NOT redo it, just verify it on this host. Every open `[ ]` / `[~]` below is
remaining work. The macOS/Linux side is done and verified; the remaining blockers need a **real
Windows host** — the mingwX64 build cross-compiled from mac/Linux fails at cinterop, and the
Windows host** - the mingwX64 build cross-compiled from mac/Linux fails at cinterop, and the
bitsycore skiko **fork** can only be rebuilt on Windows.
**The Windows fidelity reference is skiko-on-JVM Compose Desktop on THIS Windows host**
(`gradlew :demo:run` — also DirectWrite/system fonts), NOT macOS. mac↔Windows metric deltas are
(`gradlew :demo:run` - also DirectWrite/system fonts), NOT macOS. mac↔Windows metric deltas are
expected in upstream Compose Desktop too, so "matches Windows-JVM" is the acceptance bar, not
"matches macOS".
@@ -106,24 +106,24 @@ gradlew :apidemo:run :: JVM apidemo reference
Fork coords `com.bitsycore.skiko:skiko:0.150.1-mingw.1` (override `-PskikoMingwVersion`).
- `:demo` CLI flags (native AND jvm): `--screen=<Name>`, `--screenshot=<path.bmp>` (capture at
quiescence then quit), `--gpu=auto|software|skia.opengl`. `CDN_TEXT_METRICS=1` env var dumps
text metrics (§1b). `CDN_PROFILE=1` frame profiler. `:apidemo` has NO screenshot CLI — drive it
text metrics (§1b). `CDN_PROFILE=1` frame profiler. `:apidemo` has NO screenshot CLI - drive it
by hand for the button-metrics visual.
- If an IC-cache error appears after any module churn: delete `demo\build\kotlin-native-ic-cache`.
### ✅ Windows session results (2026-08-04, this host — DPR 1.0)
### ✅ Windows session results (2026-08-04, this host - DPR 1.0)
Items 1–3 below are **DONE + verified on the shipped mingwX64 demo binary**; findings folded into
§1a/§1b/§1c. Only 4–5 (WIN-SMOKE interactive gate + apiDump/publish) remain. TL;DR:
- **§1a tab fix — CONFIRMED.** `--screen=BasicText` with `"start>a\tb<end\tcols\tand\tthere"`
rendered `start>a b<end cols and there` — every `\t` a SPACE, zero `.notdef`. (Structural proof
- **§1a tab fix - CONFIRMED.** `--screen=BasicText` with `"start>a\tb<end\tcols\tand\tthere"`
rendered `start>a b<end cols and there` - every `\t` a SPACE, zero `.notdef`. (Structural proof
too: the whole module has exactly 2 `.addText(` sites, both `shapedText`.)
- **§1b vertical metrics — RESOLVED, the feared bug is DISPROVEN.** Native `--metricsprobe` vs JVM
- **§1b vertical metrics - RESOLVED, the feared bug is DISPROVEN.** Native `--metricsprobe` vs JVM
`--metrics` (same host, NotoSans, density 1): **every paragraph height + non-M3 baseline matches
JVM EXACTLY** (size 11→24). Leading is NOT dropped. Two residual deltas found (details in §1b):
(a) the fork's `LineMetrics.ascent/descent` are mis-decoded — **fixed in Kotlin** this session;
(a) the fork's `LineMetrics.ascent/descent` are mis-decoded - **fixed in Kotlin** this session;
(b) M3 baseline drifts ≤0.77px (halfLeading, a shared-engine omission on ALL platforms, sub-pixel).
- **§1c FontMgr/ICU — the "empty FreeType stub" fear is DISPROVEN.** Native render of CJK
- **§1c FontMgr/ICU - the "empty FreeType stub" fear is DISPROVEN.** Native render of CJK
(你好世界 日本語 한국어) + **full-COLOR emoji** (😀🎉🚀❤) + control chars (nbsp/zwsp/en-dash) = zero
tofu. Color emoji + CJK fallback prove `FontMgr.default` is a real DirectWrite-backed system
manager, not an empty custom FreeType one. Remaining §1c work is doc-only (capture `args.gn`).
@@ -138,18 +138,18 @@ set CDN_TEXT_METRICS=1 && ...demo.exe --screen=Buttons :: raw per-pa
### Ordered Windows work (do top-to-bottom; full detail in the numbered sections below)
1. **[§1a] Confirm the tab fix — ✅ DONE** (see session results above). Rendered a literal-tab
1. **[§1a] Confirm the tab fix - ✅ DONE** (see session results above). Rendered a literal-tab
string on the fork; all `\t`→space, no tofu. The `SkiaParagraphEngine.shapedText` normalization
holds; no bypass path exists (2 `.addText(` sites, both `shapedText`).
2. **[§1b] Confirm/resolve vertical metrics — ✅ RESOLVED (feared bug DISPROVEN).** Used the
2. **[§1b] Confirm/resolve vertical metrics - ✅ RESOLVED (feared bug DISPROVEN).** Used the
`--metricsprobe` (native) vs `--metrics` (JVM) pair, NOT the screenshot diff (the JVM leg has no
`--screenshot=<file>`; it dumps the same `metrics:` table). Every paragraph HEIGHT matches JVM
exactly (leading applied identically) → correct-by-reference. Only a ≤0.77px M3-baseline drift
remains (halfLeading, all platforms). Separately fixed the fork's broken `LineMetrics.ascent/
descent` in Kotlin. Full data in §1b.
3. **[§1c] Audit the fork FontMgr / gamma / ICU — ✅ AUDITED (fallback WORKS).** CJK + color emoji +
3. **[§1c] Audit the fork FontMgr / gamma / ICU - ✅ AUDITED (fallback WORKS).** CJK + color emoji +
control chars all render with zero tofu on the fork → `FontMgr.default` is a real system
(DirectWrite) manager with working glyph fallback, NOT an empty FreeType stub. Remaining: capture
the fork's REAL `args.gn` (`SK_GAMMA_*`, ICU packaging, freetype-vs-dwrite) into the fork repo so
@@ -167,20 +167,20 @@ set CDN_TEXT_METRICS=1 && ...demo.exe --screen=Buttons :: raw per-pa
## 1. Cross-platform FIDELITY (headline)
All Windows divergences live **below Kotlin** — the mingw source set reuses
All Windows divergences live **below Kotlin** - the mingw source set reuses
`src/skikoRendererMain/kotlin` byte-for-byte (`compose/ui/ui/build.gradle.kts:102-113`),
the only mingw-unique file is `PlatformGpu.mingw.kt` (13 lines, GPU only). So every
divergence is in the **fork's Skia binary**, in **`FontMgr.default`'s per-OS impl**, or in
the **GPU backend**. The fork is external (`bitsycore/skiko`,
`com.bitsycore.skiko:skiko:0.150.1-mingw.1`) and its GN args are **not verifiable from this
tree** — recovering them is a prerequisite for several fixes below.
tree** - recovering them is a prerequisite for several fixes below.
**Fidelity reference for Windows is skiko-on-JVM Compose Desktop on Windows** (also
DirectWrite), NOT macOS/Linux. macOS↔Windows metric deltas partly exist in upstream
Compose Desktop too (per-platform font backends). Reframe V-items as "match JVM-Windows"
acceptance tests, not "bugs vs macOS".
### 1a. Windows tab-character tofu — HIGH CONFIDENCE (root cause traced)
### 1a. Windows tab-character tofu - HIGH CONFIDENCE (root cause traced)
Kotlin sets `ParagraphStyle.replaceTabCharacters = true`
(`compose/ui/ui-text/src/skikoRendererMain/kotlin/.../renderer/skia/SkiaParagraphEngine.kt:234-236`,
@@ -191,12 +191,12 @@ On Windows the same call runs against the fork's flat extern-C DLL, which eviden
box. Font fallback is a **red herring**: no font has a tab glyph, only tab→space
replacement cures it.
- [x] **P0** Confirm — ✅ DONE (Windows 2026-08-04): rendered a literal-tab string on the fork
- [x] **P0** Confirm - ✅ DONE (Windows 2026-08-04): rendered a literal-tab string on the fork
(`--screen=BasicText`); every `\t` shows a SPACE, no `.notdef`. The fork renders everything
else clean too, so tab→space was the only gap. Structural backstop: the module has exactly 2
`.addText(` sites, both `shapedText` — no raw tab can reach skiko.
`.addText(` sites, both `shapedText` - no raw tab can reach skiko.
- [x] **P0** Fix (ship now, author project code): in
`.../SkiaParagraphEngine.kt`, feed skiko a normalized copy — added
`.../SkiaParagraphEngine.kt`, feed skiko a normalized copy - added
`private val shapedText = if (text.indexOf('\t') >= 0) text.replace('\t', ' ') else text`,
used at `addText` (no-span path) and via `shapedText.substring(segStart, segEnd)`
(span path); `text` kept for ALL length/index queries (`getRectsForRange`,
@@ -204,21 +204,21 @@ replacement cures it.
every offset consumer stays correct; this is exactly what Skia's flag does internally →
upstream-faithful; makes behavior platform-independent (no fork rebuild needed).
Kept `replaceTabCharacters = true` (harmless/redundant elsewhere).
**DONE** — compiles clean on macosArm64. Windows tofu render still needs on-device
**DONE** - compiles clean on macosArm64. Windows tofu render still needs on-device
confirmation (WIN-SMOKE §5).
- [ ] **P2** Long-term true fix (fork, out of tree): wire
`ParagraphStyle::setReplaceTabCharacters` through the fork's extern-C surface,
rebuild/republish `skiko-windows-x64.dll`, bump `-PskikoMingwVersion`. Do this *in
addition to* the Kotlin fix, not instead — it keeps the port thin. Can't be verified
addition to* the Kotlin fix, not instead - it keeps the port thin. Can't be verified
in-tree.
### 1b. macOS-vs-Windows vertical text metrics — ✅ RESOLVED (Windows 2026-08-04): heights match JVM, feared bug DISPROVEN
### 1b. macOS-vs-Windows vertical text metrics - ✅ RESOLVED (Windows 2026-08-04): heights match JVM, feared bug DISPROVEN
**WINDOWS RESULT (2026-08-04).** All targets render through the SAME Skia, so metrics are expected
to be similar — and they are. Native `--metricsprobe` (fork) vs JVM `--metrics` (upstream skiko) on
to be similar - and they are. Native `--metricsprobe` (fork) vs JVM `--metrics` (upstream skiko) on
this host (NotoSans, density 1) print IDENTICAL paragraph heights and non-M3 baselines for every
size 11→24 (`cell`/`one`/`three`/`oneM3`/`threeM3`, `base1`/`base3` all match). The fork does NOT
drop lineHeight leading — the hypothesis below is disproven. Two small deltas remained, both
drop lineHeight leading - the hypothesis below is disproven. Two small deltas remained, both
handled in SHARED Kotlin (no fork rebuild, keeping fork-reliance minimal):
1. **Fork `LineMetrics.ascent/descent` mis-decode → FIXED in Kotlin (this session).**
@@ -229,7 +229,7 @@ handled in SHARED Kotlin (no fork rebuild, keeping fork-reliance minimal):
now rebuilds ascent/descent from the reliable baseline+height when they're internally
inconsistent (guard `|ascent-(baseline-lineTop)|>0.5` = no-op on official skiko). Repairs caret
height / getLineTop/Bottom / vertical hit-test on Windows. **Behavioral caret check → WIN-SMOKE.**
2. **M3 baseline drift ≤0.77px (halfLeading) — SHARED-engine omission, all platforms.** `base1M3`
2. **M3 baseline drift ≤0.77px (halfLeading) - SHARED-engine omission, all platforms.** `base1M3`
differs native-vs-JVM by ≤0.77px in the 11→24 sweep (size 14 → 0.28px, sub-pixel), 2.19px in
the contrived `boundary 24/25` case. Root cause is NOT the fork: `SkiaParagraphEngine` maps
`LineHeightStyle.trim`→HeightMode but never wires `halfLeading`/`LineHeightStyle.Alignment`, so
@@ -237,7 +237,7 @@ handled in SHARED Kotlin (no fork rebuild, keeping fork-reliance minimal):
shared Kotlin (`textStyle.halfLeading` from the alignment); deferred pending a cross-platform
parity re-check (can't run mac parity from a Windows host).
Native-fork metricsprobe vs JVM `--metrics` (this host) — identical except `base?M3`:
Native-fork metricsprobe vs JVM `--metrics` (this host) - identical except `base?M3`:
```
size=14 lh=20 cell=19 one=19 three=59 base1=14.898 base3=54.898 oneM3=20 threeM3=60 base1M3: 15.682 fork / 15.398 jvm
size=24 lh=30 cell=33 one=33 three=93 base1=25.968 base3=85.968 oneM3=30 threeM3=90 base1M3: 23.546 fork / 24.312 jvm
@@ -253,7 +253,7 @@ of the font itself (`SkiaParagraphEngine.kt:244-249`, matches upstream
reported ascent/descent.
Root cause: **`FontMgr.default` resolves to a different Skia font scaler per build** for
the *same* `font/NotoSans.ttf` bytes (`SkiaFonts.kt:38,44,50`) — CoreText on macOS,
the *same* `font/NotoSans.ttf` bytes (`SkiaFonts.kt:38,44,50`) - CoreText on macOS,
fontconfig/FreeType on Linux, DirectWrite (or FreeType, unverified) in the fork on Windows.
Different backends select different metric tables (hhea `ascender/descender` vs OS/2
`sTypoAscender/Descender` vs `usWinAscent/Descent`) and honor the OS/2 `USE_TYPO_METRICS`
@@ -267,7 +267,7 @@ ignores it) and V3 (subpixel/edging) are secondary and per-scaler.
Windows-JVM*. If it matches JVM-Windows it's correct-by-reference (not a bug), and the
macOS↔Windows delta is expected upstream behavior.
- [x] **P0** Confirm FIRST — ✅ RESOLVED (Windows 2026-08-04; heights == JVM, see WINDOWS RESULT
- [x] **P0** Confirm FIRST - ✅ RESOLVED (Windows 2026-08-04; heights == JVM, see WINDOWS RESULT
above). Diagnostic **LANDED**: `SkiaParagraphEngine.kt` dumps
`defaultFont.metrics.{ascent,descent,leading}`, `paragraph.height`,
`lineMetrics[0].{ascent,descent,baseline,height}`, `familyName` + `fontPx` per built
@@ -283,9 +283,9 @@ macOS↔Windows delta is expected upstream behavior.
labels ("Filled Button" / "OutlinedButton" / "TextButton"): font metrics
`ascent=-32.07 descent=8.79 leading=0` (font box = **40.86px**), but the line box is
`line0[ascent=37.67 descent=10.33 height=48.0]`, `paraHeight=48`. So macOS DOES apply
the lineHeight leading (48 > 40.86) and distributes it **asymmetrically — ~5.6px above,
the lineHeight leading (48 > 40.86) and distributes it **asymmetrically - ~5.6px above,
~1.5px below** → exactly the "extra space on top of the text" the report describes.
**SHARPENED HYPOTHESIS:** this is NOT a mac bug — it's correct lineHeight distribution.
**SHARPENED HYPOTHESIS:** this is NOT a mac bug - it's correct lineHeight distribution.
The likely Windows story is the MIRROR of §1a: the fork's flat extern-C surface doesn't
wire the `ParagraphStyle` height/strut path, so Windows renders **tight to the font box
(~41px, no leading)** = the "fitted to text" look. **Sharpened WIN-SMOKE check:** for the
@@ -294,7 +294,7 @@ macOS↔Windows delta is expected upstream behavior.
`replaceTabCharacters`); a Kotlin fallback is a manual `StrutStyle`. If Windows-native
== Windows-JVM, it's correct-by-reference.
**macOS baseline raw lines** (`CDN_TEXT_METRICS=1 demo --screen=Buttons`, DPR 2) — diff
**macOS baseline raw lines** (`CDN_TEXT_METRICS=1 demo --screen=Buttons`, DPR 2) - diff
Windows-native + Windows-JVM against these; the fontPx=30 rows are the cleanest signal:
```
text='Filled Button' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0]
@@ -305,7 +305,7 @@ macOS↔Windows delta is expected upstream behavior.
(family='Noto Sans' on all.) Expectation if correct: `fontPx`, `paraHeight`, `line0.height`
match JVM-Windows exactly; the raw `ascent`/`descent` may differ from these macOS numbers
because the Windows scaler is FreeType (§1c), but the LINE BOX must still match JVM-Windows.
- [x] **P1** ~~If Windows-native != Windows-JVM: fix in the fork~~ — **N/A (2026-08-04):** heights
- [x] **P1** ~~If Windows-native != Windows-JVM: fix in the fork~~ - **N/A (2026-08-04):** heights
MATCH JVM-Windows exactly, so no fork scaler/FontMgr change is needed for vertical metrics.
The `makeFromData` typefaces already get the same metric-table selection as skiko-JVM-Windows.
The one residual (M3 halfLeading) is a shared-Kotlin fix, not a fork one.
@@ -313,20 +313,20 @@ macOS↔Windows delta is expected upstream behavior.
wanted over matching each host's stock Compose Desktop: build a single
FreeType/`SkFontMgr_Custom_Empty`-backed `SkFontMgr` used on ALL native targets for
the bundled fonts, so NotoSans yields identical ascent/descent everywhere.
Intentional departure from per-platform upstream behavior — decide deliberately.
Intentional departure from per-platform upstream behavior - decide deliberately.
- [ ] **DO NOT** "fix" by injecting a default `LineHeightStyle` or clamping ascent in
`SkiaParagraphEngine.kt` — that desyncs from upstream `DISABLE_ALL` and masks, not
`SkiaParagraphEngine.kt` - that desyncs from upstream `DISABLE_ALL` and masks, not
resolves, the metric divergence.
### 1c. Other fork-vs-official divergences
- [x] **P1** ~~`FontMgr.default` may be an empty/stub manager on the fork~~ — **DISPROVEN
- [x] **P1** ~~`FontMgr.default` may be an empty/stub manager on the fork~~ - **DISPROVEN
(Windows 2026-08-04).** Native render of CJK (你好世界 日本語 한국어) + **full-COLOR emoji**
(😀🎉🚀❤) + control chars = zero tofu. Color emoji + CJK fallback are impossible with an empty
custom FreeType manager, so the fork's `FontMgr.default` IS a real DirectWrite-backed system
manager with working glyph enumeration/fallback. No fork change needed. `SkiaFonts.kt` wiring
stays as-is.
- [x] **P1** `FontMgrWithFallback` — ANALYZED + fallback now VERIFIED on Windows. The port
- [x] **P1** `FontMgrWithFallback` - ANALYZED + fallback now VERIFIED on Windows. The port
does `setDefaultFontManager(fontMgr)` [system] + `setAssetFontManager(provider)`
[bundled] (`SkiaFonts.kt:49-52`). This is **functionally equivalent** for missing-glyph
fallback on macOS/Linux/Windows-JVM: skiko's shaper tries the asset provider (bundled
@@ -335,15 +335,15 @@ macOS↔Windows delta is expected upstream behavior.
`setDefaultFontManager(FontMgrWithFallback(provider))` risks a macOS fallback
regression with no observable gain, and I can't visually verify CJK/emoji fallback on
this host. **The real gap was thought to be Windows-fork-only** (fork `FontMgr.default`
empty stub → no system fallback) — but that's **DISPROVEN (2026-08-04):** CJK + color-emoji
empty stub → no system fallback) - but that's **DISPROVEN (2026-08-04):** CJK + color-emoji
fallback works on native Windows too (item above). So the setDefault/setAsset wiring is fine
on ALL targets. **Decision:** leave the Kotlin as-is; nothing to fix in the fork here. Re-open
only if a CJK/emoji fallback bug is actually observed.
- [x] **P2** ~~ICU/unicode data packaged differently in the fork~~ — **VERIFIED OK (2026-08-04).**
- [x] **P2** ~~ICU/unicode data packaged differently in the fork~~ - **VERIFIED OK (2026-08-04).**
Rendered U+00A0 (nbsp → visible space), U+200B (zwsp → zero-width/collapsed), en-dash, and
U+0009 (tab, pre-replacement) on native Windows: all classified/handled correctly, no tofu.
The force-exported `uloc_*_skiko` symbols do their job; skunicode classification works. (This
is also why HarfBuzz shaping + skparagraph bidi work at all — ICU data is present via
is also why HarfBuzz shaping + skparagraph bidi work at all - ICU data is present via
icudtl.dat.)
- [~] **P2** Text gamma/AA edges: a Skia **build-time constant**
(`SK_GAMMA_EXPONENT`/`SK_GAMMA_CONTRAST`/`SK_GAMMA_APPLY_TO_A8`), NOT a GL-vs-Metal
@@ -355,25 +355,25 @@ macOS↔Windows delta is expected upstream behavior.
upstream Compose Desktop's per-OS defaults VERBATIM (Win/mac=Normal hinting, Linux=Slight).
**User decision:** keep the upstream-faithful per-OS behavior (match each platform's stock
CMP), NOT a forced uniform cross-platform look. So there is no SDL_TTF-era gamma hack left to
strip — Skia is configured exactly as upstream does it.
- [x] **GL-vs-Metal AA/gamma/color-space — RULED OUT as a primary cause.** Both bridges
strip - Skia is configured exactly as upstream does it.
- [x] **GL-vs-Metal AA/gamma/color-space - RULED OUT as a primary cause.** Both bridges
create the surface `colorSpace = null` (`SkiaGLBridge.kt:68-74`,
`SkiaMetalBridge.kt:133-139`) — identical un-color-managed legacy blending. Only
`SkiaMetalBridge.kt:133-139`) - identical un-color-managed legacy blending. Only
deltas are cosmetic-correct: `RGBA_8888`/`BOTTOM_LEFT` (GL) vs `BGRA_8888`/`TOP_LEFT`
(Metal) — channel order + Y-flip, matched to buffers. Windows uses OpenGL
(Metal) - channel order + Y-flip, matched to buffers. Windows uses OpenGL
(`PlatformGpu.kt:20`), same backend as Linux, so GL-vs-Metal can't explain a
Windows-vs-Linux gap at all. Deprioritized — no action.
Windows-vs-Linux gap at all. Deprioritized - no action.
- [~] **P1** The fork's Skia build config (GN args). **Lead extracted from git history**
(the old `SKIKO-MINGW-FEASIBILITY.md`, deleted as stale — its build recipe lives in
(the old `SKIKO-MINGW-FEASIBILITY.md`, deleted as stale - its build recipe lives in
`git show bdb5c64d^:SKIKO-MINGW-FEASIBILITY.md`): the fork's Route-1a recipe builds Skia
with **`skia_use_freetype`**, i.e. the Windows text scaler is **FreeType**, not
DirectWrite and not macOS CoreText — a concrete reason the SAME NotoSans bytes yield
DirectWrite and not macOS CoreText - a concrete reason the SAME NotoSans bytes yield
different ascent/descent on Windows (different metric-table selection) AND why fork
`FontMgr.default` may not enumerate Windows system fonts for glyph fallback (FreeType
has no system fontmgr without fontconfig). **CAVEAT:** that's the *recommended recipe*,
not a verified dump of the shipped `0.150.1-mingw.1` GN args. **REMAINING:** whoever
rebuilds the fork should capture the real `args.gn` (`SK_GAMMA_*`, ICU packaging,
freetype-vs-dwrite) alongside the fork sources so drift is auditable — NOT as a doc in
freetype-vs-dwrite) alongside the fork sources so drift is auditable - NOT as a doc in
this repo (kept lean); a comment in the fork repo or `build-sdl.properties`-style pin.
---
@@ -385,12 +385,12 @@ match pin `v1.12.0-beta03+dev4483`; zero commonMain rule-1 violations (the 3 aut
`androidx.compose.*` files in commonMain are all provenance-tracked Rule-3 vendors). The
macosMain DIAGNOSTIC GAP families (`ui/ui` 12, `foundation` 23: `CoreTextField.macos.kt`,
`SelectionManager.macos.kt`, `PlatformClipboard.macos.kt`, …) are **correctly excluded**
(AppKit/NSView; the port uses SDL) — keep them in gaps. **Do not spend 1.0.0 effort
(AppKit/NSView; the port uses SDL) - keep them in gaps. **Do not spend 1.0.0 effort
re-vendoring.** The debt is **completing native-actual stubs**.
### P0 — blocks fidelity/correctness
### P0 - blocks fidelity/correctness
- [~] **P0** Text context menu (right-click copy/paste/select-all) — **RECONCILED: already
- [~] **P0** Text context menu (right-click copy/paste/select-all) - **RECONCILED: already
works, no reimplementation.** Static trace confirmed the menu is wired end-to-end
through the vendored LEGACY path: text-level `ContextMenuArea` (`ContextMenu.native.kt`)
→ `CommonContextMenuArea` (`vendor/common/.../text/CommonContextMenuArea.kt`) → (native
@@ -401,23 +401,23 @@ re-vendoring.** The debt is **completing native-actual stubs**.
`SelectionManager.contextMenuBuilder` emit Cut/Copy/Paste/SelectAll with real actions.
SDL right-click → `PointerButton.Secondary` (`SDL3EventMapper.kt`) →
`isSecondaryPressed` (`PointerEventBridge`). The three "NOP" seams belong to the
DISABLED *new* context-menu API and are unreachable — **DONE:** their misleading
DISABLED *new* context-menu API and are unreachable - **DONE:** their misleading
`TODO(CMP-7819)` comments corrected to say so, in all three files.
**REMAINING:** an interactive right-click smoke check (no headless driver here — fold
**REMAINING:** an interactive right-click smoke check (no headless driver here - fold
into manual/WIN-SMOKE). Known limitation, not a breakage: Paste enablement is
plain-text-only (`ClipboardPasteState.hasClip = hasText`, see P2 below).
- [ ] **P1** DatePicker/TimePicker localization — VERIFIED current state:
- [ ] **P1** DatePicker/TimePicker localization - VERIFIED current state:
`material3/.../internal/PlatformDateFormat.native.kt` is a real kotlinx-datetime
formatter that DOES honor CLDR patterns/skeletons (renders "Jul 29, 2026" /
"July 2026" correctly — the agent's "always yyyy-MM-dd" finding was stale). Genuine
"July 2026" correctly - the agent's "always yyyy-MM-dd" finding was stale). Genuine
remaining gaps, all narrower than a correctness blocker: field NAMES English-only
(`:29 weekdayNames`, `:147-154 MONTH_NAMES/ABBR` — no CLDR data bundled), `:27
(`:29 weekdayNames`, `:147-154 MONTH_NAMES/ABBR` - no CLDR data bundled), `:27
firstDayOfWeek=1` fixed Sunday, `:67 is24HourFormat()=true` fixed, `:46 parse()`
ISO-8601-only. Upstream `darwinMain` uses `NSDateFormatter` (not portable). **Fix
(author):** bundle a CLDR subset or K/N i18n lib for localized names + locale-aware
first-day/24h; also unblocks `CalendarLocale.native.kt:20` (fixed `"en"`). Ships
readable English dates today — polish, not a P0 gate.
- [x] **P0** Float pointer coordinates — `SDL3EventMapper.kt` `.toInt()`-truncated SDL's
readable English dates today - polish, not a P0 gate.
- [x] **P0** Float pointer coordinates - `SDL3EventMapper.kt` `.toInt()`-truncated SDL's
Float `mb.x`/`mb.y`/`mm.x`/`mm.y` and the wheel `mouse_x/y` before the DPR multiply in
`ComposeWindow.kt`. On 2× displays a click at logical 100.9 → physical 200 not ~201,
quantizing the caret to 2px steps near glyph edges. **DONE:** widened
@@ -426,92 +426,92 @@ re-vendoring.** The debt is **completing native-actual stubs**.
`.toFloat()` at the two `ComposeWindow` read sites. Consumers fully contained (verified
by grep). Builds + runs clean on macosArm64.
### P1 — quality / parity
### P1 - quality / parity
- [ ] **P1** RTL text unsupported — `ui-text/.../SkiaParagraph.native.kt:112`
- [ ] **P1** RTL text unsupported - `ui-text/.../SkiaParagraph.native.kt:112`
`textDirection = ResolvedTextDirection.Ltr` fixed field; `getParagraphDirection()`
(`:202`) always Ltr. Bidi run direction (`:204`) does read box direction (partial
machinery). Root cause: `text/intl/Locale.native.kt isRtl()` unimplemented. Fix
(author) tied to P1.2 engine.
- [ ] **P1** `PlatformFontLoader` NOP — `ui-text/.../font/FontFamilyResolver.native.kt`
- [ ] **P1** `PlatformFontLoader` NOP - `ui-text/.../font/FontFamilyResolver.native.kt`
`SdlPlatformFontLoader.loadBlocking`/`awaitLoad` NOP;
`PlatformFontFamilyTypefaceAdapter.resolve` returns `Immutable(Unit)`. Raw androidx
`Font(bytes)`/`ResourceFont` don't load (compose-resources `Font()` works). Upstream
`SkiaFontLoader.skiko.kt` is the vendorable reference. This is the long-deferred P3.2
font resolver — **high-risk L**, parity harness wouldn't exercise it. Scope
deliberately for 1.0.0 (accept-and-document vs attempt). **NOW ALSO GATES §6** — the
font resolver - **high-risk L**, parity harness wouldn't exercise it. Scope
deliberately for 1.0.0 (accept-and-document vs attempt). **NOW ALSO GATES §6** - the
2026-08-04 spike found the verbatim engine-vendor routes font resolution through this NOP, so
implementing this loader (so `SkiaFonts`' icon/variable-axis model rides upstream's seam) and
the §6 engine-vendor are ONE effort. Do them together, with macOS/Linux verification.
- [ ] **P1** `CharHelpers.skiko.kt` grapheme-break logic — the ONE cheap selective-vendor
- [ ] **P1** `CharHelpers.skiko.kt` grapheme-break logic - the ONE cheap selective-vendor
win from the hand-rolled text engine. Fixes `findPrecedingBreak`/`findFollowingBreak`
splitting emoji/combining marks in `StringHelpers.native.kt:31` +
`CharHelpers.native.kt:14`. (Giving `:foundation` a `skikoRenderer` source set would
unblock this + `DragAndDropSource.skiko` — L-effort structural move, per §13a.)
- [~] **P1** `Serif`/`Cursive` generic families — RE-SCOPED after reading `SkiaFonts.kt`.
unblock this + `DragAndDropSource.skiko` - L-effort structural move, per §13a.)
- [~] **P1** `Serif`/`Cursive` generic families - RE-SCOPED after reading `SkiaFonts.kt`.
They do NOT silently collapse to sans: `baseTypeface()` routes `generic:serif` /
`generic:cursive` through `resolveGeneric()` → `fontMgr.matchFamilyStyle()` over a
per-OS candidate list (`genericFamilyAliases`: Times/Noto Serif/… on each host), only
falling back to the bundled default if NONE is installed. So serif/cursive already
render as a real serif/cursive wherever the OS ships one (all three target OSes do).
The only true gap is **cross-platform pixel-identity** / a host with no serif installed,
which needs a BUNDLED Noto Serif — an app-level opt-in (buildSrc `downloadNotoFonts` +
which needs a BUNDLED Noto Serif - an app-level opt-in (buildSrc `downloadNotoFonts` +
data.kres size cost), not a library correctness bug. **Decision:** leave library
resolution as-is; offer bundled-serif as an opt-in later if an app needs it.
- [x] **P1** Document the hand-rolled text engine as an **accepted architectural
deviation** — **DONE:** rationale header added to the top of
deviation** - **DONE:** rationale header added to the top of
`ui-text/compose-fork.txt` (the exact place someone would go to "fill" the gaps),
explaining the reduced local engine, the flat-source-set reason, and that RTL /
stroke-DrawStyle / grapheme reductions are tracked here (§2), not fixed by vendoring.
Also reflected in `SkiaParagraphEngine.kt`'s header and CLAUDE.md's renderer summary.
### P1/P2 — reduced-coverage + accessibility
### P1/P2 - reduced-coverage + accessibility
- [ ] **P1** Text paint path handles only `SolidColor`
(`SkiaParagraph.native.kt:253-256`) — no brush/gradient text fill, `drawStyle`
(`SkiaParagraph.native.kt:253-256`) - no brush/gradient text fill, `drawStyle`
(stroke), or non-`SrcOver` blendMode. Part of the reduced style coverage; extend on
the local engine.
- [ ] **P1** `SkiaParagraphEngine.kt:150-164` span segmentation drops partial `SpanStyle`
overlaps (keeps only fully-covering `start<=segStart && end>=segEnd`). Documented
divergence (`b63-upstream-text-mingw.md`) — widen to true interval segmentation.
- [~] **P1** Accessibility absent — `ComposeOwner.kt:~340` builds a `semanticsOwner` never
divergence (`b63-upstream-text-mingw.md`) - widen to true interval segmentation.
- [~] **P1** Accessibility absent - `ComposeOwner.kt:~340` builds a `semanticsOwner` never
traversed to any OS a11y API; `SemanticsRegion.native.kt intersect()/difference()`
hardcode `false`. Not vendorable (NSAccessibility/UIA/AT-SPI). **Decision for 1.0.0:
out of scope for desktop.** The full a11y pipeline stays P2/out.
- [x] **P0** No-op `PlatformScreenReader` — `CompositionLocals.native.kt`
- [x] **P0** No-op `PlatformScreenReader` - `CompositionLocals.native.kt`
`LocalPlatformScreenReader` default no longer throws (`error(...)`); it returns an
`InactivePlatformScreenReader` (`isActive = false`) so a11y-gated vendored code
degrades to "no reader present" instead of crashing. **DONE** — builds + runs clean.
degrades to "no reader present" instead of crashing. **DONE** - builds + runs clean.
- [ ] **P2** `SkiaFonts.kt` fidelity: `resolveCache` unbounded (font-cache half of P3.4
deferred); variation key passes `null` density (`:65,78`) so density-dependent `opsz`
in sp resolves wrong. `NamedFont.equals/hashCode` axis identity reportedly fixed —
in sp resolves wrong. `NamedFont.equals/hashCode` axis identity reportedly fixed -
re-verify.
### P2 — cosmetic / edge
### P2 - cosmetic / edge
- [ ] **P2** `BlendMode.Multiply` renders wrong — opaque cyan×yellow reads back blue;
- [ ] **P2** `BlendMode.Multiply` renders wrong - opaque cyan×yellow reads back blue;
likely Metal premultiply in graphics-layer flatten, not the `toSkia()` map. Isolate on
the Skia draw path.
- [ ] **P2** Drag-OUT of window NOP — `ui/.../draganddrop/Sdl3DragAndDropOwner.kt:39`
- [ ] **P2** Drag-OUT of window NOP - `ui/.../draganddrop/Sdl3DragAndDropOwner.kt:39`
`requestDragAndDropTransfer`; `DragAndDropSource.native.kt:36` no drag-shadow. SDL3
has no portable start-drag → needs NSDraggingSession/DoDragDrop/XDND per-OS.
Drop-INTO works. Accepted 1.0 gap (SDL platform limit) — document.
Drop-INTO works. Accepted 1.0 gap (SDL platform limit) - document.
- [ ] **P2** `NativeStringDelegate.native.kt:17` `toUpper/toLowerCase` ignore `locale`
(Turkish i). Upstream `darwin` unvendorable (NSString).
- [ ] **P2** Prefetch scheduler NOP — `platform/PrefetchLocals.native.kt:18`, lazy lists
- [ ] **P2** Prefetch scheduler NOP - `platform/PrefetchLocals.native.kt:18`, lazy lists
skip ahead-of-time composition (scroll-in jank).
- [ ] **P2** `ClipboardPasteState.hasClip` aliased to `hasText`
(`TextFieldSelectionState.native.kt:43`) — image-only clipboard undetected.
(`TextFieldSelectionState.native.kt:43`) - image-only clipboard undetected.
- [ ] **P2** `ComposeOwner.kt` gaps: `autofill`/`autofillManager` null (`:324`),
`hapticFeedBack` NOP (`:279`), deprecated `clipboardManager` NOP (`:287`, real
`LocalClipboard` works), `textToolbar` stub (`:311`, touch-gated). No multi-monitor
enumeration (`SDL_GetDisplays` unused). Most match upstream desktop — verify, then
enumeration (`SDL_GetDisplays` unused). Most match upstream desktop - verify, then
leave or fill.
- [x] **P2** `installGlobals()` → `registerGenericFonts()`: VERIFIED not a hot-path cost —
- [x] **P2** `installGlobals()` → `registerGenericFonts()`: VERIFIED not a hot-path cost -
`registerGenericFonts()` already early-returns on a `fRegistered` flag
(`GenericFonts.kt:20-26`), so the per-event call is a single boolean check, not a
re-register. Original premise inaccurate; no change needed. (Minor residue: the
`ImeBridge.onSessionActiveChange` lambda is reassigned per event — negligible, left
`ImeBridge.onSessionActiveChange` lambda is reassigned per event - negligible, left
as-is since it repoints per active window.)
---
@@ -523,12 +523,12 @@ Static lib built from source per host (`scripts/build-sdl/build-all.py`, ref
only tests/examples + Windows D3D12/GPU are off (`build-all.py:311-319`). `SDL_Init` uses
**`SDL_INIT_VIDEO` only** (`SDL3Backend.kt:49`).
**USED (keep ON):** Video/window, Events, Clipboard, Dialog (file open/save — no cheaper
**USED (keep ON):** Video/window, Events, Clipboard, Dialog (file open/save - no cheaper
substitute; Linux uses portal/zenity, keep deps), OpenGL + Metal contexts, Render (2D CPU-
raster blit path: `SkiaSurfaceBridge` uploads to `SDL_Texture` + `SDL_RenderPresent`),
Filesystem, Cursor, Locale, System theme, Text input/IME, OpenURL, timing/hints.
**UNUSED (disable — zero references, no transitive need):** Audio, Joystick (→disables
**UNUSED (disable - zero references, no transitive need):** Audio, Joystick (→disables
Gamepad), Haptic, HIDAPI, Sensor, Power, Camera, GPU API, Offscreen video driver, virtual
joystick.
@@ -537,7 +537,7 @@ joystick.
`-DSDL_POWER=OFF`, `-DSDL_CAMERA=OFF`, `-DSDL_GPU=OFF`, `-DSDL_OFFSCREEN=OFF`,
`-DSDL_VIRTUAL_JOYSTICK=OFF`. Promoted `-DSDL_GPU=OFF` from the Windows-only branch to
the shared list; kept the per-host `-DSDL_RENDER_D3D12=OFF` (Windows). (Skipped
`-DSDL_DISABLE_INSTALL_DOCS` — not a real SDL3 option; the build's `cmake --install`
`-DSDL_DISABLE_INSTALL_DOCS` - not a real SDL3 option; the build's `cmake --install`
is relied upon.) All ten confirmed zero-reference by grep before disabling.
- [~] **P1** After the flag change, rebuild + run apps to confirm no regression.
**DONE on macOS/Metal:** `build-all.py` rebuilt libSDL3.a clean with the new flags;
@@ -548,17 +548,17 @@ joystick.
- [x] **P2** Prune now-unreferenced macOS frameworks from `sdl3.def`. **DONE:** verified
against the regenerated `libs/SDL3/lib/pkgconfig/sdl3.pc` `Libs:` line (source of
truth), which after slimming no longer references `CoreAudio`, `AudioToolbox`,
`AVFoundation`, `GameController`, `ForceFeedback`, or weak `CoreHaptics` — removed all
`AVFoundation`, `GameController`, `ForceFeedback`, or weak `CoreHaptics` - removed all
six from `linkerOpts.osx` (kept `CoreMedia`, which the `.pc` still lists). Demo relinks
clean. (Left Linux `linkerOpts` alone — those `-l` entries serve Skia/GL/X11, not the
clean. (Left Linux `linkerOpts` alone - those `-l` entries serve Skia/GL/X11, not the
disabled SDL subsystems.)
- [ ] **P2** (higher-risk, flag-don't-apply) Render-driver pruning to software-only. Only
the CPU-raster fallback uses `SDL_Render`; GL/Metal go direct. But
`SDL_CreateRenderer(window, null)` (`SDL3Backend.kt:117`) lets SDL pick the first
driver — with only software present it picks software (fine) but couples the fallback
driver - with only software present it picks software (fine) but couples the fallback
to that assumption. **Conservative: leave render drivers alone.**
**Do NOT** add `-DSDL_DISABLE_INSTALL` — the build relies on `cmake --install`
**Do NOT** add `-DSDL_DISABLE_INSTALL` - the build relies on `cmake --install`
(`build-all.py:342`) to stage `libSDL3.a` + headers. `SDL_DYNAMIC_API` auto-disables for
static builds (no flag needed). **Risk:** a future consumer app needing SDL audio/gamepad
requires a static-lib rebuild (build-time, not a code change).
@@ -569,16 +569,16 @@ requires a static-lib rebuild (build-time, not a code change).
Prior PLAN Phases 0–3 landed; §12 measurement showed **no steady-state perf gap on
macOS/Metal**. Remaining perf question (if any) is Windows-GL or heavy-interaction
specific. Retained-layer engine is byte-for-byte upstream — not the gap.
specific. Retained-layer engine is byte-for-byte upstream - not the gap.
- [ ] **P2** WON'T-FIX confirmed, keep as-is (don't relitigate): P0.4 per-frame full-surface
clear + P0.6 per-frame Metal drawable reacquire (`CAMetalDrawable` single-use per
frame, matches upstream `MetalRedrawer`).
- [ ] **P2** P2.2 upstream `GlobalSnapshotManager` invalidation-driven scheduling —
- [ ] **P2** P2.2 upstream `GlobalSnapshotManager` invalidation-driven scheduling -
deferred; coalescing half done by P1.3; full manager is skiko-windowing-coupled
(an explicit renderer non-goal — skiko-windowing-coupled). Leave deferred unless a
(an explicit renderer non-goal - skiko-windowing-coupled). Leave deferred unless a
Windows-GL perf gap surfaces.
- [ ] **P2** `SkiaImageCache` font-cache half of P3.4 — evicted typefaces stay referenced by
- [ ] **P2** `SkiaImageCache` font-cache half of P3.4 - evicted typefaces stay referenced by
live paragraphs + `TypefaceFontProvider` (no clean unregister) so eviction frees no
memory. Native-resource lifecycle backstop is the periodic `GC.collect()` nudge (top-
level `Paragraph` a live `Text` holds has no Compose dispose seam). Accept for 1.0 or
@@ -587,9 +587,9 @@ specific. Retained-layer engine is byte-for-byte upstream — not the gap.
shadows via `SkiaGraphicsContext.setLightingInfo` + relocate
`prepareTransformationMatrix`; deltas D2–D6). Buys vendoring cleanliness at
shadow-lighting/hit-test regression risk (current `NativeShadowCanvas` shadows render,
draw=0.08ms). Low ROI — deferred, needs full verify-mac + parity.
draw=0.08ms). Low ROI - deferred, needs full verify-mac + parity.
- [ ] **P2** Profile the shipped **Windows-GL** binary (`CDN_PROFILE=1`) under heavy
interaction before ship (present phase is vsync-capped by display refresh — profile on
interaction before ship (present phase is vsync-capped by display refresh - profile on
the target monitor). Only open perf unknown.
---
@@ -602,50 +602,50 @@ and the `ui-text.platform` font layer (= P3.2 font resolver). The 1076 "extra" d
umbrella-repo modules the tool can't compare + version skew, not invented surface.
- [ ] **P0** Re-pin refs to Compose **1.12.0 stable** when it ships (currently
`v1.12.0-beta03+dev4483` — no clean beta03 tag on Maven). Bump both
`v1.12.0-beta03+dev4483` - no clean beta03 tag on Maven). Bump both
`scripts/compose-fork/compose.properties` refs, re-sync (`scripts/compose-fork/sync.sh`),
let the build surface breakage.
- [ ] **P0** Bump `vComposeJvmVersion` in `:demo` / `:apidemo` / `:material-symbols` (JVM
parity leg currently forced to beta02 by documented skew — native leads).
- [ ] **P1** Run `check-vendor-drift.py` after the ref bump — re-stamp `VENDOR-BASE` on the
parity leg currently forced to beta02 by documented skew - native leads).
- [ ] **P1** Run `check-vendor-drift.py` after the ref bump - re-stamp `VENDOR-BASE` on the
10 manual vendors, hand-reconcile any that actually changed base..pin.
- [~] **P0** WIN-SMOKE fidelity pass (Windows host only, pre-ship gate): the Mac runbook
cannot cover the shipped mingwX64 binary. Assert: (1) NotoSans `FontMetrics` dump
native-vs-JVM-Windows (§1b acceptance) — **✅ DONE 2026-08-04 (`--metricsprobe`==`--metrics`)**;
(2) `\t`/control-char render clean (§1a) — **✅ DONE**; (3) the Windows-only `PrintWindow`
probe — pending (interactive); (4) the common-metadata publish job — compile gate now
native-vs-JVM-Windows (§1b acceptance) - **✅ DONE 2026-08-04 (`--metricsprobe`==`--metrics`)**;
(2) `\t`/control-char render clean (§1a) - **✅ DONE**; (3) the Windows-only `PrintWindow`
probe - pending (interactive); (4) the common-metadata publish job - compile gate now
**✅ GREEN (2026-08-04, see the fix below)**; the actual publish + downstream-consume smoke
still owed. Remaining: (3) `PrintWindow` probe + the interactive caret-height check for the
§1b LineMetrics fix.
- [ ] **P0** apiDump is **host-specific** — do NOT commit macOS dumps. Only the **Windows
- [ ] **P0** apiDump is **host-specific** - do NOT commit macOS dumps. Only the **Windows
publish job compiles common metadata** (owns the root KotlinMultiplatform publications
— the only host declaring every target, so only its `.module` files carry the full
- the only host declaring every target, so only its `.module` files carry the full
variant table; macOS-published roots left v0.1.15 without mingwX64 variants). Test
`gradlew :<module>:compileCommonMainKotlinMetadata` before tagging; publish from
Windows.
- [x] **P0** **FIXED (2026-08-04) — common-metadata compile is now GREEN** (`compileCommonMainKotlin
- [x] **P0** **FIXED (2026-08-04) - common-metadata compile is now GREEN** (`compileCommonMainKotlin
Metadata` across all modules → BUILD SUCCESSFUL). Was RED, blocking the Windows publish.
Running `:material3:compileCommonMainKotlinMetadata` (or the aggregate) had failed at
`:foundation:compileNativeMainKotlinMetadata` (pulled in via `:foundation:allMetadataJar`):
`Scrollbar.skiko.kt` + `v2/Scrollbar.skiko.kt` — `Declaration annotated with
`Scrollbar.skiko.kt` + `v2/Scrollbar.skiko.kt` - `Declaration annotated with
'@OptionalExpectation' can only be used in common module sources`
(`OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE`). **Root cause:** upstream keeps these
`.skiko.kt` files in `skikoMain`, which INCLUDES the JVM-desktop target, so `@JvmName`/
`@JvmStatic`/`@file:JvmName` resolve; the port vendors them into **native-only `nativeMain`**
(no JVM target under it), so those JVM annotations are orphaned and the *metadata* compile
rejects them (per-target compile tolerates them — that's why the APPS build fine, but the
rejects them (per-target compile tolerates them - that's why the APPS build fine, but the
publish's `allMetadataJar` recompiles nativeMain metadata and dies). `:ui` and below pass;
`:foundation` is the first break. **Blast radius:** 14 vendored native files use `@Jvm*`
(`grep -rE "@file:JvmName|@JvmName|@JvmStatic" .../src/vendor/native .../src/nativeMain`); 11
are in the GITIGNORED, sync-regenerated `src/vendor/native/` tree, so per-file edits DON'T
survive a re-sync. **Fix (build/sync-level, not per-file):** either (a) a `sync.sh`
post-step that injects `@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE")`
(or strips `@Jvm*`, which are no-ops with no JVM library target) into vendored native files —
(or strips `@Jvm*`, which are no-ops with no JVM library target) into vendored native files -
there's precedent: `components-resources/…/ResourceState.blocking.kt` already carries that
suppress; or (b) a KGP-level relaxation of the native metadata compilation. Needs a real
publish + downstream-consume verification after. Dedicated effort — likely THE reason the
publish + downstream-consume verification after. Dedicated effort - likely THE reason the
Windows metadata publish never went green.
**FIX APPLIED (2026-08-04):** the root turned out narrower — sync.py ALREADY injects the K2
**FIX APPLIED (2026-08-04):** the root turned out narrower - sync.py ALREADY injects the K2
`@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE", "LESS_VISIBLE_TYPE_ACCESS_IN_INLINE")`
into every `src/vendor/` .kt (`sync.py:313`), so the regenerated tree was fine. The only gap
was the two COMMITTED manual vendors in `src/nativeMain` that sync doesn't regenerate:
@@ -657,14 +657,14 @@ umbrella-repo modules the tool can't compare + version skew, not invented surfac
- [ ] **P1** Version bump to `1.0.0` across published coords once the above are green.
- [x] **P0** Doc-hygiene blocker: `CLAUDE.md` documentation map referenced `RENDERER.md`,
`SKIKO-MINGW-FEASIBILITY.md`, and `TODO.md`. **DONE:** removed `RENDERER.md` +
`SKIKO-MINGW-FEASIBILITY.md` (stale/historical — current renderer essentials inlined in
`SKIKO-MINGW-FEASIBILITY.md` (stale/historical - current renderer essentials inlined in
CLAUDE.md's doc map, the rest recoverable from git history); fixed the `CLAUDE.md`
line-1 `ean` typo; dropped the `TODO.md` link (never committed) → its content is PLAN.md
§2. `git grep` confirms no remaining links to any deleted doc.
---
## 6. Vendor the upstream skiko text engine (next major refactor — approved 2026-08-04)
## 6. Vendor the upstream skiko text engine (next major refactor - approved 2026-08-04)
**Goal:** honour "VENDOR ALL WE CAN". Delete the hand-rolled reduced text engine
(`SkiaParagraph.native.kt` + `NativeParagraphOps` seam + `SkiaParagraphEngine.kt` +
@@ -674,21 +674,21 @@ umbrella-repo modules the tool can't compare + version skew, not invented surfac
(`vendor/common/…/Paragraph.kt:56`) whose `actual` sits in the **skiko-FREE `nativeMain`**, so a
sealed implementer can't touch skiko → the hand seam. `nativeMain` is skiko-free because it's the
shared parent of the **official-skiko** (mac/linux, `libs.skiko`) and **fork-skiko** (mingw,
`com.bitsycore.skiko:skiko`) legs — two different artifacts, so the shared parent can carry neither.
`com.bitsycore.skiko:skiko`) legs - two different artifacts, so the shared parent can carry neither.
**Why it's feasible NOW (all-skiko + srcDir reuse):** every native leg already reaches
`src/skikoRendererMain/kotlin` (mingw via `kotlin.srcDir(...)` in `skikoRendererMingwSharedMain`,
`ui-text/build.gradle.kts:54`), and the fork exposes the SAME `org.jetbrains.skia.*` Kotlin API. So
the `Paragraph` **actual + engine can move DOWN into the skiko source set** (served to all targets)
instead of skiko-free `nativeMain` — which lets upstream's skiko files compile as-is.
instead of skiko-free `nativeMain` - which lets upstream's skiko files compile as-is.
**⚠️ SPIKE FINDING (2026-08-04) — the real gate is the FONT LOADER, not the source-set move.**
**⚠️ SPIKE FINDING (2026-08-04) - the real gate is the FONT LOADER, not the source-set move.**
A read-only feasibility map (no code changed) found two layers:
- **Layer 1 — source-set / sealed move: FEASIBLE.** The `actual sealed interface Paragraph`
- **Layer 1 - source-set / sealed move: FEASIBLE.** The `actual sealed interface Paragraph`
(`vendor/native/Paragraph.native.kt:36`, signature-only mirror) + its implementer + the 11 factory
actuals can move to `skikoRendererMain` (all leaves reach it; sealed same-source-set holds). Just
mechanical.
- **Layer 2 — font model: THIS is the blocker, and it's the SAME task as §2-P1 `PlatformFontLoader`.**
- **Layer 2 - font model: THIS is the blocker, and it's the SAME task as §2-P1 `PlatformFontLoader`.**
Upstream's engine (`SkiaParagraphIntrinsics.skiko.kt:38,61`) resolves typefaces through
`FontFamily.Resolver` → `PlatformFontLoader`. The port's `SdlPlatformFontLoader`
(`font/FontFamilyResolver.native.kt:30-49`) is a **NOP** (`loadBlocking`/`awaitLoad`→`Unit`,
@@ -699,15 +699,15 @@ A read-only feasibility map (no code changed) found two layers:
- **Consequence:** the verbatim engine-vendor REQUIRES first implementing upstream's font-loader path
(vendor `SkiaFontLoader.skiko.kt` as the reference) so the port's `SkiaFonts` icon/variable-axis
model rides upstream's `PlatformFontLoader`/`FontLoadResult` seam. This is the deferred high-risk P1
in §2 — it and this §6 are one effort. **Intermediate option:** vendor the engine files but keep
in §2 - it and this §6 are one effort. **Intermediate option:** vendor the engine files but keep
`SkiaFonts` via a Rule-3 edit at the one resolve call site (vendors most files verbatim, 1–2 stay
hand-reconciled). **Verification needs macOS + Linux hosts** (parity), so this is a dedicated
effort, not a Windows-only session — do it with mac access, on its own branch.
effort, not a Windows-only session - do it with mac access, on its own branch.
**Plan (spike on a branch, verify on THIS Windows host — build + render mingwX64):**
**Plan (spike on a branch, verify on THIS Windows host - build + render mingwX64):**
1. Move the `Paragraph`/`ParagraphIntrinsics` **actuals** into `skikoRendererMain` (reused by mingw
via the existing `srcDir`). Confirm the `expect sealed` actual is accepted there for every leaf
target (the b63 memo hit a sealed-in-`nativeMain` wall — test whether hosting the actual in the
target (the b63 memo hit a sealed-in-`nativeMain` wall - test whether hosting the actual in the
skiko set clears it; the sealed same-module rule is per-target-compilation, which should hold).
2. Vendor VERBATIM into `src/vendor/skikoRenderer/`: `SkiaParagraph.skiko.kt`,
`ParagraphBuilder.skiko.kt`, `SkiaParagraphIntrinsics.skiko.kt`, `SkiaTextPaint.skiko.kt`,
@@ -715,7 +715,7 @@ A read-only feasibility map (no code changed) found two layers:
`TextStyle.skiko.kt`; add them to `ui-text/compose-fork.txt`; delete the hand-rolled files.
3. Reconcile the ~7 hierarchy-crossing `expect/actual` pairs the inverted layout needed; keep the
`SkiaFonts` family/variable-axis model (it's the port's real value-add) behind upstream's seams.
4. **Fork-only Rule-3 edit:** upstream's code calls `paragraph.lineMetrics` — the fork's extern-C
4. **Fork-only Rule-3 edit:** upstream's code calls `paragraph.lineMetrics` - the fork's extern-C
binding mis-decodes ascent/descent (§1b finding). Either carry the guarded reconstruction (from
`SkiaParagraphOps.lineMetrics()`) into the vendored `SkiaParagraph.skiko.kt` as a Rule-3 edit, or
fix it in the fork DLL. **Bonus:** vendoring `ParagraphBuilder.skiko.kt` verbatim wires
@@ -730,7 +730,7 @@ Rule-3 vendors (like `FontRasterizationSettings.native.kt` now has a `VENDOR-BAS
## Accepted 1.0.0 gaps (documented, not fixed)
Drag-OUT of window (SDL platform limit; drop-IN works), full accessibility pipeline (out of
scope for desktop 1.0 — but ship the non-throwing `PlatformScreenReader` no-op), the
scope for desktop 1.0 - but ship the non-throwing `PlatformScreenReader` no-op), the
hand-rolled text engine as an architectural deviation (§2 P1), per-focus
`SDL_StartTextInput/StopTextInput`, `loadImageBitmap`/`loadSvgPainter` (JVM `InputStream`
signatures, N/A on K/N).
+5 -5
View File
@@ -30,9 +30,9 @@ executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
| Platform | Gradle target | Renderer |
|----------|---------------|----------|
| macOS arm64 | `macosArm64` | Skia (Metal) — official Skiko |
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) — official Skiko |
| Windows | `mingwX64` | Skia — bitsycore Skiko fork |
| macOS arm64 | `macosArm64` | Skia (Metal) - official Skiko |
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) - official Skiko |
| Windows | `mingwX64` | Skia - bitsycore Skiko fork |
## Quickstart
@@ -82,7 +82,7 @@ commonMain.dependencies {
The klibs publish to [maven.bitsycore.com](https://maven.bitsycore.com/releases)
(no auth) and GitHub Packages (authenticated fallback) under per-area coordinates that mirror
upstream — `com.bitsycore.compose.ui:ui`, `com.bitsycore.compose.foundation:foundation`,
upstream - `com.bitsycore.compose.ui:ui`, `com.bitsycore.compose.foundation:foundation`,
… (the `com.bitsycore` fork of each `org.jetbrains.compose.*`). 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
@@ -109,7 +109,7 @@ commonMain.dependencies {
**Two GitHub Packages repos are needed.** A consumer declares the port's repo
(`bitsycore/compose-desktop-native`) **and** the skiko fork's (`bitsycore/skiko`).
The Windows (mingwX64) target renders through the bitsycore skiko fork, and
GitHub Packages binds each package name to a **single** repository — so
GitHub Packages binds each package name to a **single** repository - so
`com.bitsycore.skiko` can't be mirrored into the port's repo, it stays in its
own. Scope them with `includeGroup`/`excludeGroup("com.bitsycore.skiko")`
(macOS/Linux pull the official skiko from Maven Central instead; if you build no
+9 -9
View File
@@ -9,7 +9,7 @@ For architecture, source-set layout, and vendoring rules, see
## 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
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`.
@@ -24,11 +24,11 @@ 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,
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.
rebuild - a build-time change, no code edit.
## Vendoring upstream Compose
@@ -128,7 +128,7 @@ 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.
diff the lines - Windows-native must match skiko-JVM-Windows.
```bash
CDN_TEXT_METRICS=1 demo.kexe --screen=Buttons --screenshot=/tmp/b.bmp
@@ -162,7 +162,7 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
mingwX64 renders through Skia via the **bitsycore skiko fork**, consumed from
GitHub Packages as `com.bitsycore.skiko:skiko:0.150.1-mingw.1` (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
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.
@@ -182,7 +182,7 @@ 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`. |
| 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. |
| 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`. | macOS/Linux use official Skiko (`org.jetbrains.skiko`); mingwX64 uses the bitsycore fork (`com.bitsycore.skiko:skiko:0.150.1-mingw.1` from GitHub Packages), 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. |
@@ -216,12 +216,12 @@ Run this on each upstream bump; it is the flow that keeps the sync tax low.
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
`./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
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`.
+2 -2
View File
@@ -127,13 +127,13 @@ compose.desktop.native {
}
// ==================
// MARK: Fonts (shared pipeline — buildSrc ComposeFontBundling.kt)
// MARK: Fonts (shared pipeline - buildSrc ComposeFontBundling.kt)
// ==================
registerComposeFontBundling {
bundleNotoSans = true
// The mono body font is loaded through the app's own seam (Fonts.kt), not via
// FontFamily.Monospace call sites — auto-detection can't see it, bundle explicitly.
// FontFamily.Monospace call sites - auto-detection can't see it, bundle explicitly.
bundleNotoSansMono = true
bundleMaterialSymbols = true
enableIconSubsetting = true
@@ -9,7 +9,7 @@ import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// macOS: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
// certificate and key files directly — no certificate-store dance needed.
// certificate and key files directly - no certificate-store dance needed.
/** Point libcurl straight at the certificate / key files. */
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
@@ -26,7 +26,7 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
actual fun sweepTempClientCerts() {}
/** Continue the server's chain with intermediates / roots pulled from the
macOS Keychain — same UX as the Windows path does via the trust store.
macOS Keychain - same UX as the Windows path does via the trust store.
Falls back to the name-only placeholder if Apple's APIs can't resolve
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
in any keychain). */
@@ -43,7 +43,7 @@ actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<Ch
// ============
/** Build the chain by handing the leaf to SecTrust + evaluating against
an SSL policy — Apple then walks Keychain, pulling intermediates and
an SSL policy - Apple then walks Keychain, pulling intermediates and
the root in. Returns the resolved chain with fromServer=true for the
first inServerCount entries (those came down on the wire) and false
for anything the OS added. */
@@ -53,7 +53,7 @@ private fun osChainApple(
inServerCount: Int,
inServerCerts: List<List<Pair<String, String>>>,
): List<ChainCert> {
// Parse every cert the server presented, not just the leaf — Apple's
// Parse every cert the server presented, not just the leaf - Apple's
// chain builder uses them as candidate intermediates when stitching to
// a root, instead of relying solely on AIA-fetch which is off by default.
val vServerCerts = inServerCerts.mapNotNull {
@@ -82,7 +82,7 @@ private fun osChainApple(
}
val vTrust = vTrustVar.value!!
// Evaluate even if it fails — we still want the partial chain back so
// Evaluate even if it fails - we still want the partial chain back so
// the UI can show what it could resolve. SecTrustEvaluateWithError
// stops at the highest cert it found.
val vErr = nativeHeap.alloc<CFErrorRefVar>()
@@ -137,7 +137,7 @@ private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
return vCert
}
/** CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
/** CFArray of SecCertificateRef - the input to SecTrustCreateWithCertificates. */
@OptIn(ExperimentalForeignApi::class)
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
@@ -147,7 +147,7 @@ private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
return vArr
}
/** Apple's "subject summary" — basically the CN, but falls back to the
/** Apple's "subject summary" - basically the CN, but falls back to the
organisation when no CN is present (matches what Keychain Access
displays in its cert list). */
@OptIn(ExperimentalForeignApi::class)
@@ -157,7 +157,7 @@ private fun secSubjectSummary(inCert: SecCertificateRef): String? {
return vNs?.toString()
}
/** PEM string for a SecCertificate — base64 of the DER bytes wrapped
/** PEM string for a SecCertificate - base64 of the DER bytes wrapped
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
private fun secCertPem(inCert: SecCertificateRef): String? {
+21 -21
View File
@@ -87,7 +87,7 @@ internal fun App() {
// Focus the saved active pack, but fall back to one that actually has tabs so
// the strip shows whenever any tab is open.
// The active pack/scope as a reference (a top-level pack, a sub-pack, or the
// loose root) — a reference (not an index) so sub-packs can be active.
// loose root) - a reference (not an index) so sub-packs can be active.
var vActivePackRef by remember {
mutableStateOf(
vBoot.activePack.coerceIn(0, (vPacks.size - 1).coerceAtLeast(0)).let { vIdx ->
@@ -138,7 +138,7 @@ internal fun App() {
// Variables a request sees: session, then each enclosing pack (inner overrides).
fun effective(inP: PackState): List<KeyVal> = vGlobalEnv.toList() + scopeChain(inP).flatMap { it.variables }
// …plus the request's own variables (innermost — they win over everything above).
// …plus the request's own variables (innermost - they win over everything above).
fun effectiveReqVars(inReq: ApiRequest, inP: PackState): List<KeyVal> = effective(inP) + inReq.variables
// Query params a request inherits: session, then each enclosing pack (inner wins by key).
@@ -192,7 +192,7 @@ internal fun App() {
// the tooltip. inChain for a request = scopeChain(owningPack) (its own pack
// included); for a pack's settings = scopeChain(pack.parent) (ancestors only).
// The full path of a scope for the source tooltip: "Methods" for a top-level
// pack, "Methods / Nested" for a sub-pack (root excluded — it's never inherited).
// pack, "Methods / Nested" for a sub-pack (root excluded - it's never inherited).
fun scopePath(inP: PackState): String {
val vParts = ArrayList<String>()
var vCur: PackState? = inP
@@ -243,7 +243,7 @@ internal fun App() {
val vGE = vGlobalEnv.toList()
val vRootPack = vRoot.toPack()
val vRootTabs = vRoot.openTabs.mapNotNull { vRs -> vRoot.requests.indexOf(vRs).takeIf { it >= 0 } }
// Open-tab state — persisted in app state only, not in the session file.
// Open-tab state - persisted in app state only, not in the session file.
val vOpen = vPacks.map { vP -> vP.openTabs.mapNotNull { vRs -> vP.requests.indexOf(vRs).takeIf { it >= 0 } } }
val vActiveReq = vActivePackRef?.let { it.requests.indexOf(it.active) } ?: -1
saveAppState(
@@ -266,7 +266,7 @@ internal fun App() {
)
)
// A session opened from / saved to a file auto-saves back to it on every
// change — once it has a file, it's always in sync (best-effort). The
// change - once it has a file, it's always in sync (best-effort). The
// Session has no open-tab fields, so the file never carries them.
vSessionPath?.let {
exportSession(
@@ -315,7 +315,7 @@ internal fun App() {
}
// Open a request in a specific pack's context (a linked copy shares the
// source's ReqState objects, so the pack must be passed — packOf would
// source's ReqState objects, so the pack must be passed - packOf would
// resolve to the source, not the linked copy).
fun open(inRs: ReqState, inPack: PackState) {
if (inRs !in inPack.openTabs) inPack.openTabs.add(inRs)
@@ -378,7 +378,7 @@ internal fun App() {
val vTab = vFlat.getOrNull(inFrom) ?: return
val vRs = vTab.req ?: return
val vP = vTab.pack ?: return
// Flat index of this pack's first request tab — works for the root and any
// Flat index of this pack's first request tab - works for the root and any
// (nested) pack without hand-computing offsets.
val vReqStart = vFlat.indexOfFirst { it.pack === vP && it.req != null }
if (vReqStart < 0) return
@@ -400,7 +400,7 @@ internal fun App() {
vP.requests.add(vRs); vP.openTabs.add(vRs); vP.active = vRs; vP.dirty = true; vReqMsg = null; vSideTab = 0
}
// A loose request at the session root (no pack — inherits session settings only).
// A loose request at the session root (no pack - inherits session settings only).
fun newLooseRequest() {
val vRs = ReqState(ApiRequest(name = "Request ${vRoot.requests.size + 1}"))
vRoot.requests.add(vRs); vRoot.openTabs.add(vRs); vRoot.active = vRs
@@ -548,7 +548,7 @@ internal fun App() {
}
// A linked copy mirrors inP's requests read-only but gets its own (copied)
// variables / headers / cert — for running the same calls against another env.
// variables / headers / cert - for running the same calls against another env.
fun createLinkedPack(inP: PackState) {
val vSource = inP.linkedSource ?: inP // link to the real source, never to another link
val vAt = (vPacks.indexOf(inP) + 1).coerceIn(0, vPacks.size)
@@ -582,7 +582,7 @@ internal fun App() {
// Move inRs out of inFrom into inTo at inIndex. inIndex counts inTo's rows
// *excluding* the dragged request (so for a same-pack reorder it is already an
// index into the post-removal list — no shift needed; resolveReqDrop produces
// index into the post-removal list - no shift needed; resolveReqDrop produces
// exactly this). A request that was open / active follows to the new pack so
// its tab survives the move.
fun moveRequest(inRs: ReqState, inFrom: PackState, inTo: PackState, inIndex: Int) {
@@ -746,7 +746,7 @@ internal fun App() {
}
// Commit the resolved request / pack drop, then clear the drag. A press that
// never passed the slop (engaged == false) is a click, not a drop — skip it.
// never passed the slop (engaged == false) is a click, not a drop - skip it.
fun reqDropEnd() {
val vRs = vTreeDrag.dragReq
val vFrom = vTreeDrag.dragReqOwner
@@ -933,7 +933,7 @@ internal fun App() {
// ============
// Sidebar (Pack panel)
Column(modifier = Modifier.fillMaxSize().background(vC.panel)) {
// Sticky header — pack switcher + section tabs stay pinned while the list scrolls.
// Sticky header - pack switcher + section tabs stay pinned while the list scrolls.
Column(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -1039,7 +1039,7 @@ internal fun App() {
}
if (vPacks.isEmpty() && vRoot.requests.isEmpty()) {
Text(
"Nothing here yet — use Add (+) for a request or pack, or Open above.",
"Nothing here yet - use Add (+) for a request or pack, or Open above.",
color = vC.dim,
fontSize = 12.sp
)
@@ -1140,7 +1140,7 @@ internal fun App() {
},
second = {
// ============
// Main — unified tab strip over the editor (request) or pack env.
// Main - unified tab strip over the editor (request) or pack env.
val vTabs = stripTabs()
val vReqActive = vP?.active
val vEnvShown = vEnvActive && vP != null && vP.envOpen
@@ -1161,12 +1161,12 @@ internal fun App() {
"Load default session"
) { loadDefaultSession() }
}
} else Text("Nothing open — click a request, a pack, or Session settings.", color = vC.dim)
} else Text("Nothing open - click a request, a pack, or Session settings.", color = vC.dim)
Spacer(Modifier.weight(1f))
}
} else {
Column(modifier = Modifier.fillMaxSize().background(vC.bg)) {
// Panel 1 — unified tab strip (session + pack-settings + request tabs).
// Panel 1 - unified tab strip (session + pack-settings + request tabs).
RequestTabStrip(
inTabs = vTabs,
inActiveKey = when {
@@ -1263,7 +1263,7 @@ internal fun App() {
inOnCert = { vP.cert = it; vP.dirty = true; persist() },
inCertHelp = "Used by every request in this pack unless the request sets its own. Overrides the session cert.",
inCertHeading = "Pack client certificate",
// What this pack inherits from above (session + ancestor packs — itself excluded).
// What this pack inherits from above (session + ancestor packs - itself excluded).
inInheritedVars = sourcedVars(scopeChain(vP.parent)),
inInheritedParams = sourcedParams(scopeChain(vP.parent)),
inInheritedHeaders = sourcedHeaders(scopeChain(vP.parent)),
@@ -1286,13 +1286,13 @@ internal fun App() {
size = 14.dp
)
Text(
"Linked copy — read-only. Runs with this pack's Var/Header/Cert; edit the request in “${vP.linkedSource?.name ?: "source"}”.",
"Linked copy - read-only. Runs with this pack's Var/Header/Cert; edit the request in “${vP.linkedSource?.name ?: "source"}”.",
color = vC.dim,
fontSize = 11.sp
)
}
}
// Panel 2 — unified method · url · send.
// Panel 2 - unified method · url · send.
UrlBar(
inReq = vReq,
inLoading = vReqActive.loading,
@@ -1438,7 +1438,7 @@ internal fun App() {
Text("Replace current session?", color = vC.text, fontSize = 16.sp)
}
Text(
"This session hasn't been saved to a file — continuing will discard it. Save it first if you want to keep it.",
"This session hasn't been saved to a file - continuing will discard it. Save it first if you want to keep it.",
color = vC.dim,
fontSize = 13.sp
)
@@ -1549,7 +1549,7 @@ internal fun App() {
Text("Remove pack", color = vC.text, fontSize = 16.sp)
}
Text(
"\"${vRmPack.name}\" will be removed from the session. Unsaved changes are lost — export it first to keep them.",
"\"${vRmPack.name}\" will be removed from the session. Unsaved changes are lost - export it first to keep them.",
color = vC.dim,
fontSize = 13.sp
)
@@ -40,9 +40,9 @@ internal fun imageFileName(inContentType: String?): String = when {
}
internal fun statusColor(inStatus: Int): Color = when (inStatus) {
in 200..299 -> Color(0xFF36B37E) // success — green
in 300..399 -> Color(0xFF4C9AFF) // redirect — blue
in 400..499 -> Color(0xFFFF991F) // client error — orange (warning)
in 500..599 -> Color(0xFFFF5630) // server error — red
else -> Color(0xFFFF991F) // unknown / pending — orange
in 200..299 -> Color(0xFF36B37E) // success - green
in 300..399 -> Color(0xFF4C9AFF) // redirect - blue
in 400..499 -> Color(0xFFFF991F) // client error - orange (warning)
in 500..599 -> Color(0xFFFF5630) // server error - red
else -> Color(0xFFFF991F) // unknown / pending - orange
}
+4 -4
View File
@@ -15,7 +15,7 @@ internal class HistoryEntry(
val request: ApiRequest
)
/** One request plus its live session state — response, in-flight job (for
/** One request plus its live session state - response, in-flight job (for
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
open-tab strip reference the same request without index juggling. */
internal class ReqState(inInitial: ApiRequest) {
@@ -82,7 +82,7 @@ internal class PackState(
inOpenTabs.forEach { vIdx -> requests.getOrNull(vIdx)?.let { add(it) } }
}
var active by mutableStateOf(requests.getOrNull(inActive)?.takeIf { it in openTabs } ?: openTabs.firstOrNull())
var expanded by mutableStateOf(true) // sidebar fold state (transient — not part of the pack file)
var expanded by mutableStateOf(true) // sidebar fold state (transient - not part of the pack file)
var envOpen by mutableStateOf(false) // whether this pack's env tab is open in the strip (transient)
fun toPack(): Pack = Pack(
@@ -112,7 +112,7 @@ internal const val kSessionTabKey = "session-settings"
internal class StripTab(val pack: PackState?, val req: ReqState?, val isSession: Boolean = false)
/** Identity of a strip tab. A linked pack shares the source's ReqState objects,
so the key must include the pack — otherwise the source's and linked's tabs
so the key must include the pack - otherwise the source's and linked's tabs
for the same request collide (double selection, duplicate key()). Data class →
structural equality, so compare keys with == (not ===). */
internal data class TabKey(val pack: PackState?, val req: ReqState?)
@@ -138,7 +138,7 @@ internal class TreeDrag {
var dy by mutableStateOf(0f) // draw-only follow offset for the grabbed element
var pressRel by mutableStateOf(0) // relY at capture (cursor = grabbed-element top + relY)
// A press doesn't become a drag until the pointer moves past kDragSlop — so a
// A press doesn't become a drag until the pointer moves past kDragSlop - so a
// plain click (which carries a pixel or two of jitter) still selects / opens.
var engaged by mutableStateOf(false)
+3 -3
View File
@@ -6,7 +6,7 @@ import kotlin.io.encoding.Base64
// MARK: Minimal X.509 / DER parser
// ==================
// Some TLS backends (notably Schannel on Windows) expose only Subject / Issuer /
// the PEM through CURLINFO_CERTINFO — not the parsed fields (validity, serial,
// the PEM through CURLINFO_CERTINFO - not the parsed fields (validity, serial,
// SAN, algorithms). Since every backend gives us the PEM, we parse it ourselves
// to fill in the detail. Just enough ASN.1 to walk a Certificate; anything odd
// is swallowed and the field is simply omitted.
@@ -94,7 +94,7 @@ private fun pemToDer(inPem: String): ByteArray? {
}
}
/** The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
/** The friendly name (or dotted OID) of the first child OID of a SEQUENCE - used
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String? {
val vR = DerReader(inB, inSeq[1], inSeq[2])
@@ -139,7 +139,7 @@ private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String? {
if (inTlv[0] == 0x18) // GeneralizedTime
{
vYear = vS.substring(0, 4); vRest = vS.substring(4)
} else // UTCTime — RFC 5280 pivot at 50
} else // UTCTime - RFC 5280 pivot at 50
{
val vYy = vS.substring(0, 2).toInt()
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
+1 -1
View File
@@ -11,7 +11,7 @@ import androidx.compose.ui.text.font.FontFamily
// back to the default proportional font.
const val kMonoFamily = "noto-mono"
/** The raw family name string — used by wrappedRowCount, which measures via the
/** The raw family name string - used by wrappedRowCount, which measures via the
platform text pipeline (accepts a name string). Null when not bundled. */
expect val monoFontFamilyName: String?
+1 -1
View File
@@ -32,7 +32,7 @@ fun formatBody(inText: String, inFormat: BodyFormat): String =
// Minimal XML re-indenter: a newline between adjacent tags, one tab per
// nesting level (rendered at the editor's tab size). Declarations (<?…?>), comments / doctype (<!…>), self-closing tags and
// single-line <a>text</a> elements don't change depth. Best-effort — content
// single-line <a>text</a> elements don't change depth. Best-effort - content
// with a literal '>' inside attributes or CDATA may not round-trip, hence the
// runCatching guard at the call site.
private fun formatXml(inText: String): String {
+8 -8
View File
@@ -13,13 +13,13 @@ import okio.buffer
import kotlin.time.TimeSource
// ==================
// MARK: HttpRunner — executes an ApiRequest through Ktor
// MARK: HttpRunner - executes an ApiRequest through Ktor
// ==================
/** Holds one Ktor HttpClient for the app's lifetime. One engine on every desktop
target: Ktor's Curl engine (bundled libcurl — Schannel on Windows, OpenSSL on
target: Ktor's Curl engine (bundled libcurl - Schannel on Windows, OpenSSL on
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
run() is a suspend fun — call it off the UI dispatcher. */
run() is a suspend fun - call it off the UI dispatcher. */
class HttpRunner {
private val fClient = createApiHttpClient()
@@ -32,7 +32,7 @@ class HttpRunner {
suspend fun run(inReq: ApiRequest): ApiResponse {
// Client-certificate requests bypass Ktor (no engine exposes a cert API)
// and go straight through libcurl — same bundled TLS stack.
// and go straight through libcurl - same bundled TLS stack.
if (inReq.hasClientCert) return curlSendWithClientCert(inReq)
val vMark = TimeSource.Monotonic.markNow()
return try {
@@ -81,7 +81,7 @@ class HttpRunner {
.sortedBy { it.first.lowercase() },
body = when {
vIsImage -> ""
vBinary -> "(${vContentType ?: "binary"} · ${vBody.size} bytes — not shown; use Save as…)"
vBinary -> "(${vContentType ?: "binary"} · ${vBody.size} bytes - not shown; use Save as…)"
else -> vBody.decodeToString()
},
bytes = vBody,
@@ -97,13 +97,13 @@ class HttpRunner {
httpVersion = vResp.version.toString(),
)
} catch (e: CancellationException) {
// The caller cancelled (Cancel button) — let it propagate so the
// The caller cancelled (Cancel button) - let it propagate so the
// request is dropped rather than reported as a failed response.
throw e
} catch (e: Throwable) {
ApiResponse(
status = 0,
statusText = "—",
statusText = "-",
timeMs = vMark.elapsedNow().inWholeMilliseconds,
sizeBytes = 0,
headers = emptyList(),
@@ -129,7 +129,7 @@ internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
return inBytes.take(1024).any { it.toInt() == 0 } // no type: sniff for NUL bytes
}
/** True when the bytes are gzip — either the response says so or they carry the
/** True when the bytes are gzip - either the response says so or they carry the
gzip magic number (1f 8b). */
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
inEncoding?.contains("gzip", ignoreCase = true) == true ||
@@ -22,7 +22,7 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
/** A request tab pairing an inherited (read-only, source-tagged) list with the
request's own editable list: Override copies an inherited entry down, and own
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
Headers — the only differences are the key case-sensitivity and the labels. */
Headers - the only differences are the key case-sensitivity and the labels. */
@Composable
internal fun InheritedEditableTab(
inInherited: List<InheritedKv>,
@@ -131,7 +131,7 @@ internal fun SourceTag(inLabel: String, inPath: String) {
}
}
/** The tiny marker shown on an own value that shadows an inherited one — hover for
/** The tiny marker shown on an own value that shadows an inherited one - hover for
"Overrides <key> from <path>". */
@Composable
internal fun OverrideMark(inKey: String, inPath: String) {
+18 -18
View File
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
// ==================
/** A "pack" is a savable collection of requests plus its environment variables
— the export/import unit. Variables are referenced as {{name}} in any URL,
- the export/import unit. Variables are referenced as {{name}} in any URL,
query value, header or body and substituted just before the request is sent
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
in defaultSession(). */
@@ -27,7 +27,7 @@ data class Pack(
)
/** A client-certificate (mutual TLS) configuration. Lives on a request today and
— with the pack tree — on the session / packs too (inherited downward). Maps
- with the pack tree - on the session / packs too (inherited downward). Maps
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
@Serializable
data class CertConfig(
@@ -39,7 +39,7 @@ data class CertConfig(
)
/** The starter SESSION loaded on first launch (and on demand from the session
menu) — a guided tour of every feature against httpbin.org: the inheritance
menu) - a guided tour of every feature against httpbin.org: the inheritance
ladder (session → pack → sub-pack → request, for variables / query params /
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
@@ -47,7 +47,7 @@ per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
inherited result is visible in the response. Loaded as an unsaved session. */
fun defaultSession(): Session = Session(
activePack = 0,
// ── Session level — the base of every inheritance ladder ──
// ── Session level - the base of every inheritance ladder ──
globalEnv = listOf(
KeyVal("baseUrl", "https://httpbin.org"),
KeyVal("token", "session-token-abc"),
@@ -64,7 +64,7 @@ fun defaultSession(): Session = Session(
), // every request gets ?trace=session (echoed in /get args)
// No session-level cert: it would route every request through mTLS and fail
// without a real cert file. Client certs are shown on the "Secure (mTLS)" pack.
// ── Loose requests at the session root (in no pack — inherit session only) ──
// ── Loose requests at the session root (in no pack - inherit session only) ──
root = Pack(
isRoot = true, name = "", requests = listOf(
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
@@ -72,7 +72,7 @@ fun defaultSession(): Session = Session(
)
),
packs = listOf(
// ── Methods — pack header/param/var inherited by its requests, plus a
// ── Methods - pack header/param/var inherited by its requests, plus a
// nested sub-pack and per-request overrides of each kind. ──
SavedPack(
pack = Pack(
@@ -92,15 +92,15 @@ fun defaultSession(): Session = Session(
), // overrides the session's apiVer for this pack
requests = listOf(
ApiRequest(
name = "GET — echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
name = "GET - echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("q", "compose"))
), // own q + inherited trace=session + source=methods
ApiRequest(
name = "GET — overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
name = "GET - overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("trace", "request"))
), // request's trace wins over the session's
ApiRequest(
name = "POST — overrides header",
name = "POST - overrides header",
method = ReqMethod.POST,
url = "{{baseUrl}}/post",
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
@@ -109,17 +109,17 @@ fun defaultSession(): Session = Session(
body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"
),
ApiRequest(
name = "POST — overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
name = "POST - overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
variables = listOf(
KeyVal(
"user",
"request-user"
)
), // request var beats session/pack — see body echo
), // request var beats session/pack - see body echo
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"
),
ApiRequest(
name = "PUT — replace",
name = "PUT - replace",
method = ReqMethod.PUT,
url = "{{baseUrl}}/put",
bodyType = BodyType.TEXT,
@@ -132,7 +132,7 @@ fun defaultSession(): Session = Session(
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"
),
),
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
// Nested sub-pack - inherits Session → Methods → here (apiVer becomes v3,
// plus its own X-Nested header; trace / source / Accept still flow down).
subPacks = listOf(
Pack(
@@ -141,7 +141,7 @@ fun defaultSession(): Session = Session(
headers = listOf(KeyVal("X-Nested", "true")),
requests = listOf(
ApiRequest(
name = "GET — deep inheritance",
name = "GET - deep inheritance",
method = ReqMethod.GET,
url = "{{baseUrl}}/anything/{{apiVer}}"
),
@@ -150,7 +150,7 @@ fun defaultSession(): Session = Session(
)
)
),
// ── Auth & status — uses the inherited {{token}} / {{user}} / {{password}} ──
// ── Auth & status - uses the inherited {{token}} / {{user}} / {{password}} ──
SavedPack(
pack = Pack(
name = "Auth & status", color = 2, requests = listOf(
@@ -170,7 +170,7 @@ fun defaultSession(): Session = Session(
)
)
),
// ── Formats — response viewer (JSON / XML / HTML / image) ──
// ── Formats - response viewer (JSON / XML / HTML / image) ──
SavedPack(
pack = Pack(
name = "Formats", color = 3, requests = listOf(
@@ -183,7 +183,7 @@ fun defaultSession(): Session = Session(
)
)
),
// ── Secure (mTLS) — a PACK-LEVEL client cert, inherited by its requests.
// ── Secure (mTLS) - a PACK-LEVEL client cert, inherited by its requests.
// Open a request's Cert tab to see the inherited cert (source pill +
// Override). Set real cert paths to actually send; the example paths
// won't load. The second request overrides with its own cert. ──
@@ -303,7 +303,7 @@ fun ApiRequest.bodyContentType(): String? = when (bodyType) {
}
// ==================
// MARK: Response (runtime only — not part of a pack)
// MARK: Response (runtime only - not part of a pack)
// ==================
class ApiResponse(
+4 -4
View File
@@ -47,7 +47,7 @@ data class SavedPack(
)
/** A whole session, the export/import unit for the working set: every open pack
(self-contained — full content embedded) plus the shared global env and which
(self-contained - full content embedded) plus the shared global env and which
pack was active. Only one session is open at a time. */
@Serializable
data class Session(
@@ -94,20 +94,20 @@ fun loadAppState(): AppState {
}
}
/** Write the app state. Best-effort — failures are swallowed (it's a cache of
/** Write the app state. Best-effort - failures are swallowed (it's a cache of
the session, not the user's exported .json packs). */
fun saveAppState(inState: AppState) {
val vPath = stateFilePath() ?: return
try {
systemFileSystem.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
} catch (_: Throwable) {
// ignore — nothing the user can act on, and packs can still be exported
// ignore - nothing the user can act on, and packs can still be exported
}
}
/** Open the per-user app-data folder where the state file lives in the OS file
manager. Revealing the state file opens its containing folder, which SDL has
already created even when nothing's been saved yet. Fire-and-forget — the
already created even when nothing's been saved yet. Fire-and-forget - the
reveal launches off the main thread (see revealInFileManager). */
fun openSettingsFolder() {
val vPath = stateFilePath() ?: return
+6 -6
View File
@@ -49,7 +49,7 @@ internal fun OptionsMenu(
Text("Light mode", color = if (!inDark) c.accent else c.text, fontSize = 13.sp)
}, onClick = { if (inDark) inOnToggleTheme(); vOpen = false })
HorizontalDivider(color = c.border)
// Palette picker — a swatch of each theme's primary + a check on the active one.
// Palette picker - a swatch of each theme's primary + a check on the active one.
Box(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 2.dp)) {
Text("Theme", color = c.dim, fontSize = 11.sp)
}
@@ -86,7 +86,7 @@ internal fun OptionsMenu(
// MARK: Method picker (dropdown)
// ==================
/** Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
/** Panel 2 - one unified bar: a method dropdown (coloured label + unfold arrows),
a borderless URL field that melts into the bar, and Send (or Cancel). */
@Composable
internal fun UrlBar(
@@ -126,7 +126,7 @@ internal fun UrlBar(
}
}
// Borderless URL field — no box, so it reads as part of the bar.
// Borderless URL field - no box, so it reads as part of the bar.
Box(
modifier = Modifier.weight(1f).alpha(if (inReadOnly) 0.55f else 1f).onKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) {
@@ -154,8 +154,8 @@ internal fun UrlBar(
}
// Inspect the server's TLS certificate chain (handshake-only probe).
// During the probe the lock glyph swaps to a spinner IN PLACE — same
// box, padding and size — so the bar doesn't shift.
// During the probe the lock glyph swaps to a spinner IN PLACE - same
// box, padding and size - so the bar doesn't shift.
val vLockHoverSrc = remember { MutableInteractionSource() }
val vLockHover by vLockHoverSrc.collectIsHoveredAsState()
TooltipBox(
@@ -185,7 +185,7 @@ internal fun UrlBar(
}
// Send and Cancel are the same Material Button (same MinHeight + padding)
// so the bar never changes height when toggling — Cancel is just red.
// so the bar never changes height when toggling - Cancel is just red.
if (inLoading) Button(
onClick = inOnCancel,
colors = ButtonDefaults.buttonColors(
@@ -21,7 +21,7 @@ import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// ==================
// MARK: Panel 3 — request builder (Query / Headers / Body)
// MARK: Panel 3 - request builder (Query / Headers / Body)
// ==================
/** Tabs to edit query params, headers and the body, a Preview toggle top-right,
@@ -63,7 +63,7 @@ internal fun RequestBuilder(
}
// Tabs, with the status message and a Preview toggle (icon only) on the
// same line — the toggle no longer needs its own row.
// same line - the toggle no longer needs its own row.
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 10.dp, top = 6.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -98,7 +98,7 @@ internal fun RequestBuilder(
}
HorizontalDivider(color = c.border)
// Tab content — scrolls, except a Text body which fills the whole panel
// Tab content - scrolls, except a Text body which fills the whole panel
// (so it stays one editable surface you can click anywhere in). Greyed when
// read-only (a linked-copy request).
val vScroll = rememberScrollState()
@@ -117,7 +117,7 @@ internal fun RequestBuilder(
}
}
// Body-type selector — only on the Body tab, pinned at the bottom. For a
// Body-type selector - only on the Body tab, pinned at the bottom. For a
// Text body the format/type picker sits beside it; that type drives both the
// syntax colours and the sent Content-Type.
if (inRs.reqTab == 3) {
@@ -170,7 +170,7 @@ internal fun FormatButton(inOnClick: () -> Unit) {
HoverIconBtn(MaterialSymbols.AutoFixHigh, "Format (pretty-print)", inOnClick)
}
/** Icon button with the toolbar's standard hover treatment — accent-tinted
/** Icon button with the toolbar's standard hover treatment - accent-tinted
background + accent icon on hover (matching the TLS chain button). inActive
keeps it lit, for toggles such as the preview eye. */
@Composable
@@ -212,7 +212,7 @@ internal fun HoverIconBtn(
}
/** Tab-size picker (2 / 4 / 8), shown left of the format button. Sets the global
editor tab width via TextLayoutConfig — how wide a typed '\t' renders AND how
editor tab width via TextLayoutConfig - how wide a typed '\t' renders AND how
deep the formatter indents. Snapshot-backed, so the change is live. */
@Composable
internal fun TabSizeSelector() {
@@ -258,7 +258,7 @@ internal fun TabSizeSelector() {
}
}
/** FILE body — pick a file; its path is stored in body and sent as raw bytes. */
/** FILE body - pick a file; its path is stored in body and sent as raw bytes. */
@Composable
internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
val c = LocalAppColors.current
@@ -271,7 +271,7 @@ internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) ->
}
}
/** Client-certificate (mTLS) editor — certificate + optional separate key +
/** Client-certificate (mTLS) editor - certificate + optional separate key +
passphrase. PEM/DER are used directly by OpenSSL on macOS/Linux; on Windows
they're imported into the certificate store for the request then removed
(see CurlMtls). PKCS#12 bundles its own private key. */
@@ -331,7 +331,7 @@ internal fun CertConfigEditor(
}
}
Text(
inCert.keyPath.ifBlank { "Optional — only if the key is in a separate file." },
inCert.keyPath.ifBlank { "Optional - only if the key is in a separate file." },
color = if (inCert.keyPath.isNotBlank()) c.text else c.dim, fontSize = 12.sp,
)
}
@@ -402,7 +402,7 @@ internal fun RequestCertTab(
}
}
/** Scope settings editor (Variables / Query / Headers / Cert sub-tabs) — shared by
/** Scope settings editor (Variables / Query / Headers / Cert sub-tabs) - shared by
the session settings tab and each pack's settings tab. Each sub-tab shows what
the scope inherits from above (source-tagged, with Override) over its own
editable list, mirroring the request panels. */
@@ -415,7 +415,7 @@ internal fun ScopeSettings(
inParams: List<KeyVal>, inOnParams: (List<KeyVal>) -> Unit, inParamHelp: String,
inHeaders: List<KeyVal>, inOnHeaders: (List<KeyVal>) -> Unit, inHeaderHelp: String,
inCert: CertConfig?, inOnCert: (CertConfig?) -> Unit, inCertHelp: String, inCertHeading: String,
// What this scope inherits from above (empty for the session — it's the top).
// What this scope inherits from above (empty for the session - it's the top).
inInheritedVars: List<InheritedKv> = emptyList(),
inInheritedParams: List<InheritedKv> = emptyList(),
inInheritedHeaders: List<InheritedKv> = emptyList(),
@@ -33,7 +33,7 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
/** Strip of open requests above the editor. Each tab is clickable to select
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
fades, and follows the cursor via a graphicsLayer translation — its
fades, and follows the cursor via a graphicsLayer translation - its
neighbours don't shuffle; the reorder commits on drop, to the slot whose
neighbours' centres the cursor passed. key(tab) keeps each tab's
LayoutNode stable so the drag session survives the reorder. */
@@ -57,7 +57,7 @@ internal fun RequestTabStrip(
var vDragTarget by remember { mutableStateOf(-1) }
// One shared context menu, anchored to the tab that opened it (m3 DropdownMenu
// is parent-anchored, not cursor-anchored — the old x/y capture is gone).
// is parent-anchored, not cursor-anchored - the old x/y capture is gone).
var vMenu by remember { mutableStateOf(false) }
var vMenuTab by remember { mutableStateOf<StripTab?>(null) }
@@ -89,7 +89,7 @@ internal fun RequestTabStrip(
.onGloballyPositioned { vLeft[vKey] = it.x }
.onSizeChanged { vWidth[vKey] = it.width }
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` — clip is
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` - clip is
// applied in the ALPHA layer's local coord space (before any child layer's
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
// ghost inside its original bounds and the ghost disappears as it slides. One
@@ -103,7 +103,7 @@ internal fun RequestTabStrip(
.pointerInput(vKey) {
// Accumulate the per-frame delta rather than reading change.position.
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
// which transforms the modifier's local pointer frame — so position.x
// which transforms the modifier's local pointer frame - so position.x
// would compensate for the translation each frame and the tab would
// trail the mouse by roughly half its offset. dragAmount is the
// root-frame delta and doesn't feed back through the layer transform.
@@ -143,7 +143,7 @@ internal fun RequestTabStrip(
)
}
// Middle-click closes the tab; right-click opens the tab menu. Compose
// has no first-class match for either — inline pointerInput checks the
// has no first-class match for either - inline pointerInput checks the
// pressed PointerButton at each Press.
.pointerInput(vTab) {
awaitPointerEventScope {
+11 -11
View File
@@ -79,7 +79,7 @@ internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
// MARK: Add-pack menu (header '+')
// ==================
/** The header '+' — a small menu to add a pack to the open session, either blank
/** The header '+' - a small menu to add a pack to the open session, either blank
or imported from a .json file. Importing a pack always lands it in the session. */
@Composable
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
@@ -131,7 +131,7 @@ internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
}
/** Header dropdown for the one open session. Shows its file name with the full
path underneath when it has a file (so you can see it's saved — file-backed
path underneath when it has a file (so you can see it's saved - file-backed
sessions auto-save), or "Untitled session" with a dot when it has no file yet.
Menu: Save / Save as… / Rename / Reveal / Open… / New + a recent list. */
@Composable
@@ -240,7 +240,7 @@ internal fun SessionMenu(
HorizontalDivider(color = c.border)
// Aligned to the icon column (the DropdownMenuItem's 16dp pad), so
// it lines up with the recent items' file icon. Padding goes on a
// wrapping Box — a leaf Text's own start padding isn't applied to its
// wrapping Box - a leaf Text's own start padding isn't applied to its
// draw position by this layout engine.
Box(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 2.dp)) {
Text("Recent", color = c.dim, fontSize = 11.sp)
@@ -263,7 +263,7 @@ internal fun SessionMenu(
Text(fileLeaf(vPath), color = c.text, fontSize = 12.sp)
Text(ellipsizeMiddle(vPath, 32), color = c.dim, fontSize = 10.sp, softWrap = false)
}
// Reveal this session's folder — its own hover + tooltip so it
// Reveal this session's folder - its own hover + tooltip so it
// reads as a separate action from "open the session".
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
@@ -332,7 +332,7 @@ internal class PackOps(
/** Renders a pack then its sub-packs recursively, each indented by its depth.
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
subPacks) — used to position the pack-reorder drop bars by index. */
subPacks) - used to position the pack-reorder drop bars by index. */
@Composable
internal fun PackTree(
inPack: PackState,
@@ -519,7 +519,7 @@ internal fun PackSection(
Text(inPack.name, color = c.text, fontSize = 14.sp, modifier = Modifier.weight(1f))
Text("${inPack.requests.size}", color = c.dim, fontSize = 11.sp)
}
// + (new request) and ⋮ (pack menu) — both reveal on hover only, so the
// + (new request) and ⋮ (pack menu) - both reveal on hover only, so the
// header width never shifts (alpha, not conditional layout). A linked
// copy mirrors its source's requests, so it has no "new request".
Row(
@@ -573,7 +573,7 @@ internal fun PackSection(
}
// ============
// Pack body — request list (expanded, or always for the loose root).
// Pack body - request list (expanded, or always for the loose root).
// Rows drag within and across packs via the shared controller; the drop
// bar shows here whenever this pack is the resolved target.
if (inPack.expanded || inHeaderless) {
@@ -584,7 +584,7 @@ internal fun PackSection(
val vBarBefore = if (vIsTarget) vOthers.getOrNull(inDrag.dropIndex) else null
val vBarAtEnd = vIsTarget && inDrag.dropIndex >= vOthers.size
// The loose root has no header, so register the body box as its drop
// anchor — that gives an empty root a region to target while dragging.
// anchor - that gives an empty root a region to target while dragging.
var vBodyMod = Modifier.fillMaxWidth().padding(start = 10.dp, top = 2.dp, bottom = 4.dp)
if (inHeaderless) vBodyMod = vBodyMod
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
@@ -605,7 +605,7 @@ internal fun PackSection(
key(vRs) {
val vDragged = vRs === inDrag.dragReq && vMoving
var vMod: Modifier = Modifier
// Linked copies mirror the source's requests — read-only, so
// Linked copies mirror the source's requests - read-only, so
// their rows neither register geometry (would clobber the
// source's) nor drag.
if (!inPack.isLinked) vMod = vMod
@@ -616,7 +616,7 @@ internal fun PackSection(
if (vDragged) vMod =
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
if (!inPack.isLinked) vMod = vMod.pointerInput(vRs) {
// Same graphicsLayer feedback concern as the pack drag —
// Same graphicsLayer feedback concern as the pack drag -
// accumulate dragAmount instead of reading change.position.
detectDragGestures(
onDragStart = { offset ->
@@ -706,7 +706,7 @@ internal fun RequestRow(
) {
MethodTag(vReq.method)
Text(vReq.name, color = c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
// Overflow (vertical-dots) menu — always laid out so the row width
// Overflow (vertical-dots) menu - always laid out so the row width
// never changes; only its opacity toggles on hover / while open. Delete
// lives inside this menu (no separate button on the row).
Box(modifier = Modifier.alpha(if (vHover || vMenu) 1f else 0f)) {
@@ -7,11 +7,11 @@ import androidx.compose.ui.text.buildAnnotatedString
import kotlinx.serialization.Serializable
// ==================
// MARK: BodyFormat — viewer/highlighter selection
// MARK: BodyFormat - viewer/highlighter selection
// ==================
/** Format of a body payload as shown in the viewer, and (for a TEXT request body)
the type the user picks on the right of the Body tab — it drives both syntax
the type the user picks on the right of the Body tab - it drives both syntax
highlighting and the sent Content-Type. RAW disables highlighting and falls back
to the selectable BasicTextField; other values pick a tokeniser. */
@Serializable
@@ -68,7 +68,7 @@ data class SyntaxPalette(
val punct: Color,
) {
companion object {
/** Light-on-dark — VS Code Dark+. */
/** Light-on-dark - VS Code Dark+. */
val Dark = SyntaxPalette(
key = Color(0xFF9CDCFE),
string = Color(0xFFCE9178),
@@ -79,7 +79,7 @@ data class SyntaxPalette(
punct = Color(0xFFCCCCCC),
)
/** Dark-on-light — VS Code Light+. Higher contrast on white. */
/** Dark-on-light - VS Code Light+. Higher contrast on white. */
val Light = SyntaxPalette(
key = Color(0xFF0451A5),
string = Color(0xFFA31515),
@@ -113,13 +113,13 @@ fun highlight(
}
// ==================
// MARK: JSON tokeniser — single-pass, error-tolerant
// MARK: JSON tokeniser - single-pass, error-tolerant
// ==================
/** Tokeniser for JSON. Walks the string once and emits style spans for
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
(strings followed by ':') from string values by peeking after each
string. Error-tolerant — malformed JSON still highlights as best
string. Error-tolerant - malformed JSON still highlights as best
effort rather than throwing. */
private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vN = inText.length
@@ -224,7 +224,7 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
append(inText.substring(vTagStart, vJ))
pop()
}
// Attributes — name="value" pairs.
// Attributes - name="value" pairs.
while (vJ < vGt - 1) {
val vC2 = inText[vJ]
when {
@@ -271,7 +271,7 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
}
// ==================
// MARK: YAML tokeniser — line-based
// MARK: YAML tokeniser - line-based
// ==================
/** Tokeniser for YAML. Walks line by line because YAML's grammar is
@@ -320,7 +320,7 @@ private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString =
}
}
/** Render the value portion of a YAML line — applies string / number /
/** Render the value portion of a YAML line - applies string / number /
keyword / list-marker colours where applicable. */
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
val vTrimmed = inSeg.trimStart()
@@ -378,7 +378,7 @@ private fun findUnquotedHash(inLine: String): Int {
}
/** Locate the colon that separates a YAML key from its value at the
current indent level — the FIRST unquoted ':' that's followed by
current indent level - the FIRST unquoted ':' that's followed by
either whitespace or end-of-line. Returns -1 if the line has no key. */
private fun indexOfKeyColon(inLine: String): Int {
var vInSingle = false
+1 -1
View File
@@ -4,7 +4,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ==================
// MARK: Theme — the app's legacy colour slots, derived from the active palette
// MARK: Theme - the app's legacy colour slots, derived from the active palette
// ==================
// AppColors is the app's own colour vocabulary (used by the custom widgets). It
// is now DERIVED from the selected Voltic M3 ColorScheme via appColorsFromScheme
@@ -125,7 +125,7 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
vSubject?.let { CertLine("Subject", it) }
if (vSelfSigned) CertLine("Issuer", "Self-signed", kSelfSignedColor)
else vIssuer?.let { CertLine("Issuer", it) }
// Hosts this cert is valid for — the field that actually has to match the URL.
// Hosts this cert is valid for - the field that actually has to match the URL.
(certField(vFields, "X509v3 Subject Alternative Name")
?: certField(vFields, "Subject Alternative Name")
?: vParsed?.sans?.takeIf { it.isNotEmpty() }?.joinToString(", "))
@@ -141,7 +141,7 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
internal val kSelfSignedColor = Color(0xFF3FB950L)
/** Copy button — rounded, hover overlay, real click that copies inText and shows
/** Copy button - rounded, hover overlay, real click that copies inText and shows
a green check. Icon-only (per cert) flashes a small floating "Copied" bubble
for 2s; the labelled variant (Copy chain) flips its label to "Copied" instead.
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
@@ -218,7 +218,7 @@ format the platform backends emit:
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
default, with spaces — what
default, with spaces - what
libcurl on macOS emits)
Null when there's no CN. */
internal fun cnOf(inDn: String): String? {
+3 -3
View File
@@ -4,14 +4,14 @@ package apidemo
// MARK: TLS chain / client-certificate seam
// ==================
// The real implementations drive the bundled libcurl directly (CurlMtls.kt,
// nativeMain) — Ktor's engines expose no client-cert / CERTINFO API. The jvm
// nativeMain) - Ktor's engines expose no client-cert / CERTINFO API. The jvm
// parity app has no bundled libcurl, so its actuals report the feature as
// native-only instead.
/** One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
Issuer / the PEM under "Cert" / dates …) and whether the server actually
presented it. Derived certs — an issuer resolved from the OS store, or a
name-only placeholder — have fromServer = false and are drawn dotted. */
presented it. Derived certs - an issuer resolved from the OS store, or a
name-only placeholder - have fromServer = false and are drawn dotted. */
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
/** The server's TLS certificate chain. error is set instead when it couldn't be
+3 -3
View File
@@ -20,8 +20,8 @@ fun substituteVars(inText: String, inVars: List<KeyVal>): String {
return kVarRegex.replace(inText) { vMatch -> vMap[vMatch.groupValues[1]] ?: vMatch.value }
}
/** Apply variable substitution across every field that actually gets sent — URL,
query params, headers and body — returning a fully-resolved request. The
/** Apply variable substitution across every field that actually gets sent - URL,
query params, headers and body - returning a fully-resolved request. The
original (template) request is left untouched. */
fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
val vActive = inVars.any { it.enabled && it.key.isNotBlank() }
@@ -54,7 +54,7 @@ fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
}
/** The distinct {{names}} referenced anywhere in the request that have no enabled
variable to fill them — surfaced as a warning in the editor. */
variable to fill them - surfaced as a warning in the editor. */
fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
val vDefined = inVars
.filter { it.enabled && it.key.isNotBlank() }
+4 -4
View File
@@ -35,7 +35,7 @@ width so request names line up. */
@Composable
internal fun MethodTag(inMethod: ReqMethod) {
val vCol = methodColor(inMethod)
// No background — just the coloured method name, fixed width so the request
// No background - just the coloured method name, fixed width so the request
// names still line up in the sidebar list.
Box(
modifier = Modifier.width(46.dp).padding(vertical = 2.dp),
@@ -53,7 +53,7 @@ internal fun RowDropBar() {
Box(modifier = Modifier.fillMaxWidth().height(2.dp).background(c.accent, RoundedCornerShape(1.dp)))
}
/** Tab indices listed in inDots get a small accent dot after their label — used
/** Tab indices listed in inDots get a small accent dot after their label - used
to flag a tab that holds content (e.g. a non-empty request body). */
@Composable
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
@@ -90,7 +90,7 @@ internal fun TogglePill(inLabel: String, inSelected: Boolean, inOnClick: () -> U
) { Text(inLabel, color = if (inSelected) c.onAccent else c.dim, fontSize = 12.sp) }
}
/** Compact single-line (or fixed-height multi-line) input — a BasicTextField in
/** Compact single-line (or fixed-height multi-line) input - a BasicTextField in
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
@Composable
internal fun ThinField(
@@ -177,7 +177,7 @@ internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit)
/** Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
Uses the m3 Button so its size + ripple + interaction match every other
Button in the app — previously handrolled Box + background + clickable + padding
Button in the app - previously handrolled Box + background + clickable + padding
gave it a different height / horizontal padding, and the Discard button sat
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
@Composable
+53 -53
View File
@@ -47,7 +47,7 @@ import kotlinx.coroutines.launch
// MARK: Response
// ==================
/** Panel 4 — Request / Response viewer. Each tab stacks a HEADERS section over a
/** Panel 4 - Request / Response viewer. Each tab stacks a HEADERS section over a
BODY section. "Request" shows the resolved request that would be sent (the
Preview); "Response" shows the result (image-aware). A copy / save toolbar
acts on whichever view is showing. */
@@ -61,7 +61,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
val vPreview = inRs.preview
val vShowRequest = vPreview || inRs.viewTab == 0
// Pretty-print once per received body (not every recomposition) — the whole
// Pretty-print once per received body (not every recomposition) - the whole
// body is shown; the renderer line-culls + wrap-caches so even a 20k-line
// payload stays cheap. (Previously capped at 20000 chars, which silently cut
// long responses off around line ~1100.)
@@ -89,7 +89,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
Spacer(Modifier.weight(1f))
Text("not sent", color = VolticTheme.extended.warning, fontSize = 11.sp)
} else {
// Request tab — adds the coloured method only once the
// Request tab - adds the coloured method only once the
// request has actually been sent (sentReq != null).
val vSent = inRs.sentReq
ViewerTab(
@@ -99,7 +99,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
inSelected = inRs.viewTab == 0,
inOnClick = { inRs.viewTab = 0 },
)
// Response tab — adds the coloured status only when a
// Response tab - adds the coloured status only when a
// response (or error) is in. No placeholder during the
// pre-send / loading state.
val (vRespAccent, vRespColor) = when {
@@ -115,7 +115,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
inOnClick = { inRs.viewTab = 1 },
)
Spacer(Modifier.weight(1f))
// Spinner only — the Cancel control already lives next to
// Spinner only - the Cancel control already lives next to
// Send in the URL bar; no need for a duplicate here.
if (inRs.viewTab == 1 && vLoading) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), color = c.accent, strokeWidth = 2.dp)
@@ -134,7 +134,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
val vSentHeaders =
if (!vPreview) inRs.response?.requestHeaders?.takeIf { it.isNotEmpty() } else null
// Synthesize wire-level headers (Host, Content-Length, User-Agent)
// the engine adds without telling Ktor — same set httpie shows.
// the engine adds without telling Ktor - same set httpie shows.
val vRawHeaders =
if (vSentHeaders != null) vSentHeaders else parseHeaderLines(requestHeadersText(vR))
val vHeaders = synthesizeRequestHeaders(vR, vRawHeaders)
@@ -204,7 +204,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
inAutoLabel = if (inRs.respFormatOverride == null) vAuto.label else null,
)
}
// Wrap toggle — soft-wrap long lines (default) vs. no wrap +
// Wrap toggle - soft-wrap long lines (default) vs. no wrap +
// horizontal scroll (one source line per row). Shown for any text
// body (request preview or response), hidden for an image.
val vHasTextBody = (vRespHasContent && !vRespImage) || vReqHasContent
@@ -257,7 +257,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
}
// ==================
// MARK: HttpFlowView — flat httpie-style request/response layout
// MARK: HttpFlowView - flat httpie-style request/response layout
// ==================
/** Renders the body of a Request or Response tab in a httpie-flavoured
@@ -280,12 +280,12 @@ internal fun HttpFlowView(
) {
val c = LocalAppColors.current
val vState = rememberLazyListState()
// Editor-style selection state — anchor + caret in absolute character offsets
// Editor-style selection state - anchor + caret in absolute character offsets
// into inBody. Resets when the body changes so a stale range never highlights
// a shorter/different response. Lives outside any SelectionContainer so it
// survives scroll and spans off-screen chunks (see the BodySel comment).
var vSel: BodySel? by remember(inBody) { mutableStateOf(null) }
// Selection background from the app theme — same colour that
// Selection background from the app theme - same colour that
// LocalTextSelectionColors publishes to Compose text selection elsewhere.
val vSelColor = LocalTextSelectionColors.current.backgroundColor
val vFocus = remember { FocusRequester() }
@@ -297,12 +297,12 @@ internal fun HttpFlowView(
// auto-scroll effect that scrolls the LazyColumn while the pointer sits in
// the top / bottom edge zone.
var vDragPos: Offset? by remember { mutableStateOf(null) }
// The body is highlighted ONCE, then sliced into BLOCKS of lines — one BasicText
// The body is highlighted ONCE, then sliced into BLOCKS of lines - one BasicText
// per block, NOT one per line. Far fewer nodes / selectables / paragraph set-ups
// churn through the viewport while scrolling (the smoothness win), and the
// O(total-spans) AnnotatedString.subSequence runs once per block at build time
// (memoised) instead of per visible line every frame. LazyColumn still virtualizes,
// so only the ~2-3 on-screen blocks are composed/measured — 12k lines stay cheap.
// so only the ~2-3 on-screen blocks are composed/measured - 12k lines stay cheap.
val vDark = isDarkBg(c.bg)
val vChunks = remember(inBody, inBodyFormat, vDark) {
if (inBody == null) emptyList()
@@ -315,16 +315,16 @@ internal fun HttpFlowView(
// Per-visible-chunk TextLayoutResult, keyed by chunk index. Populated as each
// BodyChunkRow's BasicText lays out, dropped implicitly when the chunks list
// changes (new response) via the remember key. Off-screen chunks aren't in
// this map — we can't hit-test them via mouse either since they're not painted.
// this map - we can't hit-test them via mouse either since they're not painted.
val vChunkLayouts = remember(vChunks) { mutableStateMapOf<Int, TextLayoutResult>() }
val vTotalLines = remember(inBody) { if (inBody == null) 0 else inBody.count { it == '\n' } + 1 }
// No-wrap mode pans horizontally; every block shares this one scroll state so the
// whole body pans together (the gutter stays pinned — not scrolled).
// whole body pans together (the gutter stays pinned - not scrolled).
val vHScroll = rememberScrollState()
val vGutterWidth = (vTotalLines.coerceAtLeast(1).toString().length * 7 + 4).dp
// Per-line styles hoisted once (not allocated per line per frame). The rows use
// BasicText with these fixed styles instead of material3 Text — no per-node
// LocalTextStyle merge / LocalContentColor read — which is the main scroll-
// BasicText with these fixed styles instead of material3 Text - no per-node
// LocalTextStyle merge / LocalContentColor read - which is the main scroll-
// smoothness win when thousands of line items churn through the viewport.
val vBodyStyle = remember(c.text) { TextStyle(color = c.text, fontSize = 12.sp, fontFamily = monoFontFamily) }
val vNumStyle = remember(c.dim) {
@@ -339,7 +339,7 @@ internal fun HttpFlowView(
// Body-relative x offset (px) of the block's BasicText inside the outer Box.
// Mirrors BodyChunkRow's layout: 4dp Row start padding + gutter width + 6dp spacer.
val vTextStartPx = with(density) { (4.dp + vGutterWidth + 6.dp).toPx() }
// LazyColumn item index of chunk index 0 — status row + optional header
// LazyColumn item index of chunk index 0 - status row + optional header
// table + divider. Used to scroll a specific chunk into view when the caret
// moves off-screen via keyboard. Only stable while we're on the "have body"
// branch, but that's the only branch that renders chunks in the first place.
@@ -358,7 +358,7 @@ internal fun HttpFlowView(
if (!vVisible) vScope.launch { vState.scrollToItem(vChunkItemOffset + vCi) }
}
// Hit-test — turns a pointer position (outer Box local frame) into a global
// Hit-test - turns a pointer position (outer Box local frame) into a global
// character offset into inBody, or null if the pointer isn't over any chunk.
// Clamps past-viewport pointers to the nearest visible chunk boundary so a
// drag that overshoots still keeps extending toward that end.
@@ -387,7 +387,7 @@ internal fun HttpFlowView(
// Drag auto-scroll: while a drag is in progress (vDragPos != null), tick
// every frame and scroll the LazyColumn when the pointer sits in a 40dp
// edge zone at the top / bottom. Speed ramps linearly with depth into the
// zone up to 24dp per frame — roughly a page per second at 60fps at max
// zone up to 24dp per frame - roughly a page per second at 60fps at max
// depth. After each scroll we re-hit-test the current pointer position so
// the selection keeps extending onto the newly revealed chunks.
val vEdgePx = with(density) { 40.dp.toPx() }
@@ -412,7 +412,7 @@ internal fun HttpFlowView(
// contentAlignment pins the narrow scrollbar to the right edge; the
// fillMaxSize content fills the rest (this project's Box has no BoxScope, so
// there's no Modifier.align — alignment is via contentAlignment).
// there's no Modifier.align - alignment is via contentAlignment).
Box(
modifier = Modifier
.fillMaxSize()
@@ -421,7 +421,7 @@ internal fun HttpFlowView(
// Mouse: press starts a selection, drag extends it, Shift+press extends
// from the existing anchor. We drive this by hand instead of going
// through SelectionContainer because SC only sees composed selectables
// (visible chunks) — Ctrl+A and Shift+Arrow can't reach off-screen text
// (visible chunks) - Ctrl+A and Shift+Arrow can't reach off-screen text
// through it. Header items above the chunks stay non-selectable, which
// matches the previous DisableSelection wrappers.
.pointerInput(vChunks) {
@@ -437,7 +437,7 @@ internal fun HttpFlowView(
val vDown = awaitPointerEvent()
if (vDown.type != PointerEventType.Press) continue
val vChange = vDown.changes.firstOrNull() ?: continue
// Skip presses a child already handled — most importantly the
// Skip presses a child already handled - most importantly the
// VerticalScrollbar sibling in this Box. Without this, dragging
// the scrollbar would also start (and continuously extend) a
// body selection because my hit-test still finds a chunk under
@@ -584,7 +584,7 @@ internal fun HttpFlowView(
if (inShowSecureLock) {
MaterialSymbolsOutlined(
icon = MaterialSymbols.Lock,
tint = Color(0xFF36B37E), // green — TLS verified by OS
tint = Color(0xFF36B37E), // green - TLS verified by OS
size = 14.dp,
)
Spacer(Modifier.width(6.dp))
@@ -592,7 +592,7 @@ internal fun HttpFlowView(
Text(inStatusLine, color = c.text, fontSize = 13.sp)
}
}
// Headers as a key/value table — only when not collapsed.
// Headers as a key/value table - only when not collapsed.
if (!inHeadersCollapsed && inHeaders.isNotEmpty()) {
item {
HeaderTable(inHeaders, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
@@ -611,7 +611,7 @@ internal fun HttpFlowView(
}
}
} else if (vChunks.isNotEmpty()) {
// One BasicText per BLOCK of lines (not per line) — far fewer
// One BasicText per BLOCK of lines (not per line) - far fewer
// nodes to churn while scrolling. Chunk key lets the pointer
// hit-test map a visible item back to its chunk index.
items(vChunks.size, key = { "chunk_$it" }, contentType = { "chunk" }) { vCi ->
@@ -658,7 +658,7 @@ where they've moved to. When they differ, the selected range is [ min..max ].
When equal, it's a zero-width caret still useful as a starting point for
subsequent Shift+Arrow / Shift+Click extensions.
Note this lives OUTSIDE the SelectionContainer machinery — that machinery only
Note this lives OUTSIDE the SelectionContainer machinery - that machinery only
tracks selectables composed by the LazyColumn (visible chunks), which is why
Ctrl+A and drag-select cannot reach off-screen text through it. This state is
plain integer offsets so it survives scroll and covers the whole body. */
@@ -676,7 +676,7 @@ private fun lineStartAt(inText: String, inOffset: Int): Int {
return if (vPrev < 0) 0 else vPrev + 1
}
/** Character offset of the end of the line containing [inOffset] — the position of
/** Character offset of the end of the line containing [inOffset] - the position of
its '\n', or the length of [inText] if this is the last line. */
private fun lineEndAt(inText: String, inOffset: Int): Int {
val vClamped = inOffset.coerceIn(0, inText.length)
@@ -684,7 +684,7 @@ private fun lineEndAt(inText: String, inOffset: Int): Int {
return if (vNext < 0) inText.length else vNext
}
/** Move a caret one visual "row" down while trying to keep its column — i.e. the
/** Move a caret one visual "row" down while trying to keep its column - i.e. the
number of chars past the current line's start. If the target line is shorter,
the caret snaps to its end. Off-by-one at EOF collapses to the body length. */
private fun moveDown(inText: String, inOffset: Int): Int {
@@ -728,7 +728,7 @@ private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
}
/** The index of the chunk in [inChunks] whose character range contains
[inOffset], or null when the body is empty. Uses a linear scan — the chunk
[inOffset], or null when the body is empty. Uses a linear scan - the chunk
count is O(body / kLinesPerChunk) which stays modest even for huge bodies. */
private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
if (inChunks.isEmpty()) return null
@@ -791,7 +791,7 @@ private fun BodyChunkRow(
// Precompute this block's slice of the selection in LOCAL offsets. `null`
// when the selection is empty or doesn't touch this block. Also computes the
// caret's local offset when the caret itself falls inside this block (drawn
// even when the range is empty — that's the read-only viewer's cursor).
// even when the range is empty - that's the read-only viewer's cursor).
val vChunkStart = inChunk.startCharOffset
val vChunkEnd = vChunkStart + inChunk.body.length
val vLocalRange: IntRange? = if (inSel == null || inSel.isEmpty) null else {
@@ -886,7 +886,7 @@ private class BodyChunk(
/** Slice a highlighted body into blocks of [inLinesPerChunk] lines. The O(total-spans)
subSequence runs once per block HERE (build time, memoised) instead of once per
visible line every frame — the difference between smooth and janky on a big body. */
visible line every frame - the difference between smooth and janky on a big body. */
private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerChunk: Int): List<BodyChunk> {
val vStarts = lineStartOffsets(inText)
val vN = vStarts.size
@@ -908,7 +908,7 @@ private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerCh
/** Offsets where each source line starts (line i spans [starts[ i], starts[i+1]-1),
the -1 dropping the '\n'; the last line runs to the string end). One cheap O(n)
scan, memoised — replaces the old gutter pre-pass that measured wrapping on
scan, memoised - replaces the old gutter pre-pass that measured wrapping on
every one of N lines on each width change (the other half of the freeze). */
private fun lineStartOffsets(inText: String): IntArray {
val vStarts = ArrayList<Int>(64)
@@ -943,7 +943,7 @@ internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifi
}
}
/** Body view with line numbers in the gutter — mimics the code panel
/** Body view with line numbers in the gutter - mimics the code panel
in httpie. Numbers are dim; body text uses the regular colour. */
@Composable
internal fun BodyView(
@@ -963,7 +963,7 @@ internal fun BodyView(
val vGutterWidth = (vDigits * 7 + 4).dp
// Body wrap width (physical px, from onSizeChanged), reported by the body
// Box once it's laid out (0 on the first frame). The gutter numbering
// depends on it — see vNumbers. Wrap measurement expects the font
// depends on it - see vNumbers. Wrap measurement expects the font
// size + max-width in the SAME unit (both physical px on Retina), so we
// scale 12.sp through the current density here, matching what the paragraph
// engine does when it builds the body's real paragraph.
@@ -972,16 +972,16 @@ internal fun BodyView(
// Horizontal scroll for no-wrap mode (read-only body only); dormant when wrapping.
val vHScroll = rememberScrollState()
Row(modifier = modifier) {
// Numbers are reference-only — half-alpha so they stay legible without
// Numbers are reference-only - half-alpha so they stay legible without
// competing with the body. Rendered as ONE '\n'-joined multi-line Text
// (not one Text per line): a 1000-line gutter is then a single node the
// renderer line-culls + wrap-caches exactly like the body, instead of
// 1000 leaf nodes — which lagged and tripped SDL's ~16384px draw-
// 1000 leaf nodes - which lagged and tripped SDL's ~16384px draw-
// coordinate limit (line numbers vanished past ~955).
//
// When the body soft-wraps, a source line can span several visual rows.
// To keep the numbers aligned we emit each line's number once (on its
// first row) followed by one blank row per wrapped continuation — by
// first row) followed by one blank row per wrapped continuation - by
// re-running the SAME wrap the body uses (same measurer, font, width),
// so the gutter ends up with exactly as many rows as the body. Without
// this, a line wrapping into 3 rows would read 10/11/12 instead of
@@ -997,7 +997,7 @@ internal fun BodyView(
append(vIdx + 1)
if (vWrapRows) {
// Pass the density-scaled font size so this wrap matches the body's
// actual glyph pixel widths — passing 12 (sp) here counted each
// actual glyph pixel widths - passing 12 (sp) here counted each
// char as half its rendered size, so long lines that visually
// wrapped into 3 rows in the body were estimated as 1 or 2 rows
// here and the gutter numbers drifted upward.
@@ -1033,7 +1033,7 @@ internal fun BodyView(
}
if (inOnChange != null) {
// EDITABLE request body: BasicTextField with a colour-only
// syntax-highlight visualTransform — cursor / selection map to
// syntax-highlight visualTransform - cursor / selection map to
// the plain text. RAW = no highlight. Remembered per (format,
// theme) so typing and cursor-blink reuse it.
val vDark = isDarkBg(c.bg)
@@ -1079,7 +1079,7 @@ internal fun BodyView(
}
SelectionContainer {
// No-wrap mode: one source line per row, long lines overflow
// and the body pans horizontally (the gutter stays pinned —
// and the body pans horizontally (the gutter stays pinned -
// it's outside this scroll). The scroll must sit on a PARENT
// of the Text: a node's own scroll offset shifts its children,
// not itself. Wrap mode: plain soft-wrapping Text.
@@ -1138,12 +1138,12 @@ internal fun ViewerTab(
}
// ==================
// MARK: Helpers — header parsing / formatting
// MARK: Helpers - header parsing / formatting
// ==================
/** "HTTP/1.1 200 OK" status line for a Response, with the protocol
token dimmed (it's structural) and the status code + reason in the
status colour. Just "HTTP/1.1" pre-response — no placeholder. Triple-
status colour. Just "HTTP/1.1" pre-response - no placeholder. Triple-
spaced to match the request-line formatting. */
internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): AnnotatedString {
if (inResp == null) return buildAnnotatedString {
@@ -1197,7 +1197,7 @@ internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColor
}
/** Pull the path (+ query) out of a URL, dropping scheme + host. The
request line in HTTP is "METHOD path HTTP/x.y" — the host lives on a
request line in HTTP is "METHOD path HTTP/x.y" - the host lives on a
separate Host: header line, not in the path. Falls back to the input
string when no "://" is present (relative URL). */
internal fun urlPathOnly(inUrl: String): String {
@@ -1208,7 +1208,7 @@ internal fun urlPathOnly(inUrl: String): String {
return if (vSlash < 0) "/" else vAfterScheme.substring(vSlash)
}
/** Extract the host (+ port) from a URL — used to synthesize a Host
/** Extract the host (+ port) from a URL - used to synthesize a Host
header for display when the engine didn't surface one. */
internal fun urlHost(inUrl: String): String? {
val vIdx = inUrl.indexOf("://")
@@ -1219,7 +1219,7 @@ internal fun urlHost(inUrl: String): String? {
return vAuthority.ifEmpty { null }
}
/** The user-agent string our Darwin engine sends is opaque to Ktor —
/** The user-agent string our Darwin engine sends is opaque to Ktor -
NSURLSession picks the default. Match what httpie does: identify
ourselves so the wire log isn't missing the field entirely. */
internal const val kUserAgent: String = "compose-apidemo/1.0"
@@ -1248,7 +1248,7 @@ internal fun synthesizeRequestHeaders(
/** Body length in bytes for the headers synthesis. JSON / TEXT use the
raw UTF-8 byte count; FORM serialises and counts; FILE skips
(loading the file just for the count would be wasteful — the engine
(loading the file just for the count would be wasteful - the engine
sets the field anyway). */
internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType) {
BodyType.TEXT -> inReq.body.encodeToByteArray().size
@@ -1267,12 +1267,12 @@ internal fun formatTimingSize(inResp: ApiResponse): String {
}
// ==================
// MARK: ViewerOverflowMenu — 3-dot menu in the bottom-right of the viewer
// MARK: ViewerOverflowMenu - 3-dot menu in the bottom-right of the viewer
// ==================
/** Replaces the inline Copy / Save chips with a single MoreHoriz menu.
Copy actions target whichever tab is showing (request or response);
Clear is global — wipes the response, sentReq, preview state, and any
Clear is global - wipes the response, sentReq, preview state, and any
memory-backed image resource for the current request. */
@Composable
internal fun ViewerOverflowMenu(
@@ -1370,7 +1370,7 @@ internal fun ViewerOverflowMenu(
}
}
/** Perceptual luminance check — true when the colour reads as "dark"
/** Perceptual luminance check - true when the colour reads as "dark"
(background gets a light foreground). Standard Rec. 709 weights. */
internal fun isDarkBg(inColor: Color): Boolean {
val vY = 0.299f * inColor.red + 0.587f * inColor.green + 0.114f * inColor.blue
@@ -1378,7 +1378,7 @@ internal fun isDarkBg(inColor: Color): Boolean {
}
// ==================
// MARK: BodyFormatSelector — small dropdown for RAW / JSON / XML / YAML / HTML
// MARK: BodyFormatSelector - small dropdown for RAW / JSON / XML / YAML / HTML
// ==================
/** Format / "type" picker. inBordered renders it as a full dropdown matching
@@ -1423,8 +1423,8 @@ internal fun BodyFormatSelector(
/** TLS validation indicator: only true when the URL is https AND we got
a real response back (i.e. the OS engine completed the TLS handshake
without an error). The engines we ship — NSURLSession on macOS,
WinHttp on Windows, libcurl on Linux — all reject an untrusted
without an error). The engines we ship - NSURLSession on macOS,
WinHttp on Windows, libcurl on Linux - all reject an untrusted
certificate by default, so a non-error response is implicit proof
that the OS validated the chain. */
internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
@@ -1469,7 +1469,7 @@ internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Mod
internal fun headersText(inHeaders: List<Pair<String, String>>): String =
inHeaders.joinToString("\n") { (vK, vV) -> "$vK: $vV" }.ifEmpty { "(no headers)" }
/** The headers that *would* be sent — explicit enabled headers plus the inferred
/** The headers that *would* be sent - explicit enabled headers plus the inferred
Content-Type for the body type (unless one is already set). Used for Preview;
a sent request shows the real headers via headersText(response.requestHeaders). */
internal fun requestHeadersText(inReq: ApiRequest): String {
@@ -9,7 +9,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ==================
// MARK: Voltic themes — selectable Material 3 palettes
// MARK: Voltic themes - selectable Material 3 palettes
// ==================
// The three "voltic" brand palettes (Kotlin purple, orange, and a stock-M3
// purple), each a full light + dark M3 ColorScheme plus an EXTENDED group that
@@ -11,7 +11,7 @@ import okio.FileSystem
// MARK: Platform seams (native = com.compose.sdl / SDL3, jvm = AWT + upstream desktop)
// ==================
// The port modules are native-only, so shared code can't call com.compose.sdl
// directly — every SDL-backed API the app uses goes through these expects.
// directly - every SDL-backed API the app uses goes through these expects.
// Signatures mirror the native originals so the native actuals are pure
// delegation (same pattern as :demo's demo.shim package).
@@ -21,7 +21,7 @@ expect fun appDataDir(inOrg: String, inApp: String): String?
/** Open the OS file manager showing the given path. Fire-and-forget. */
expect fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)? = null)
/** "Explorer" / "Finder" / "Files" — for menu labels. */
/** "Explorer" / "Finder" / "Files" - for menu labels. */
expect fun fileManagerName(): String
/** Async save-file dialog; the callback gets the chosen absolute path or null. */
@@ -54,7 +54,7 @@ inFontPx (already density-scaled). Used by the body gutter so its line
numbers stay aligned with the wrapped body text. */
expect fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int
/** Editor tab width in spaces — how wide a typed '\t' renders. On the native
/** Editor tab width in spaces - how wide a typed '\t' renders. On the native
stack this drives the project text pipeline (TextLayoutConfig); the jvm
parity app only stores the preference. */
expect var editorTabWidth: Int
@@ -63,10 +63,10 @@ expect var editorTabWidth: Int
// Infrastructure
/** okio's FileSystem.SYSTEM is declared per-platform, not in its common
metadata — surface it through a seam. */
metadata - surface it through a seam. */
internal expect val systemFileSystem: FileSystem
/** The app's Ktor client: Curl on native (bundled libcurl — same TLS stack as
/** The app's Ktor client: Curl on native (bundled libcurl - same TLS stack as
the mTLS path), CIO on the jvm parity target. */
expect fun createApiHttpClient(): HttpClient
@@ -1,5 +1,5 @@
// COPY of the port's Scrollbar (package com.compose.sdl.scrollbar) so the SAME
// implementation runs on the jvm parity target — the port modules are
// implementation runs on the jvm parity target - the port modules are
// native-only, so apidemo's commonMain cannot reference them directly.
// Keep in sync with the original when it changes.
package apidemo.compat
@@ -41,7 +41,7 @@ import kotlin.math.roundToInt
/** A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
no BoxScope (so no Modifier.align) — pin the bar with the Box's
no BoxScope (so no Modifier.align) - pin the bar with the Box's
contentAlignment; the fillMaxSize content fills the rest:
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
@@ -100,7 +100,7 @@ interface ScrollbarAdapter {
/** Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
size of its VISIBLE items, so exact pixel geometry of off-screen content is
unknowable — we estimate it from the average visible item size × total item
unknowable - we estimate it from the average visible item size × total item
count (the same approach as Compose Desktop). Accurate enough for a thumb
whose length/position track the scroll position; the estimate self-corrects
as items of different sizes scroll through the viewport. */
@@ -131,7 +131,7 @@ private class LazyListScrollbarAdapter(private val state: LazyListState) : Scrol
// Within a viewport, scroll by EXACT pixels so a drag stays smooth. A lazy
// list's pixel geometry is only ESTIMATED (average visible item size), and
// that estimate drifts as differently-sized items scroll through the
// viewport — so index-snapping every frame makes the thumb jump. scrollBy
// viewport - so index-snapping every frame makes the thumb jump. scrollBy
// sidesteps the estimate entirely. This is what Compose Desktop's own
// LazyListScrollbarAdapter does, and for the same reason.
if (abs(vDistance) <= viewportSize) {
@@ -184,7 +184,7 @@ private fun Scrollbar(
val vHovered by vHoverSource.collectIsHoveredAsState()
var vDraggingThumb by remember { mutableStateOf(false) }
// onSizeChanged reports PHYSICAL pixels (Option-B density flow), so every
// int/px carried through here — vTrackPx, vThumbLen, vThumbPos — is in
// int/px carried through here - vTrackPx, vThumbLen, vThumbPos - is in
// physical pixels. Passing them into `.dp` doubles again through
// density.toPx() on Retina, which visibly bloated the thumb (filled the
// whole track) and made drag/track-click positions land 2× too far down.
@@ -230,13 +230,13 @@ private fun Scrollbar(
Box(modifier = vThumbMod)
// ============
// Drag the thumb / page on track click — handled on the track so the
// Drag the thumb / page on track click - handled on the track so the
// coordinates have a stable (non-moving) origin. The press is CONSUMED:
// a scrollbar owns the pointer input it handles, so content sharing this
// region (the scrollable beneath, or a parent that also reads pointer
// input) never reacts to the same press. Geometry is read LIVE from the
// adapter at press time — not from the captured render metrics above,
// which go stale between pointerInput relaunches — so a grab after a
// adapter at press time - not from the captured render metrics above,
// which go stale between pointerInput relaunches - so a grab after a
// wheel-scroll still hit-tests against the thumb's current position.
Box(
modifier = Modifier
@@ -246,7 +246,7 @@ private fun Scrollbar(
val vDown = awaitFirstDown(requireUnconsumed = false)
vDown.consume()
// Live geometry — stable for the drag's duration (content /
// Live geometry - stable for the drag's duration (content /
// viewport / track sizes don't change mid-drag).
val vContentPx = adapter.contentSize
val vViewportPx = adapter.viewportSize
@@ -262,7 +262,7 @@ private fun Scrollbar(
// Map a track-relative thumb-top to a scroll offset. The
// target uses the LIVE max scroll (read fresh each move),
// not the press-time value — the adapter compares it against
// not the press-time value - the adapter compares it against
// its live scrollOffset, and mixing a stale scale with a live
// one makes the delta run away (grab at the bottom, drag up,
// and the list overshoots to the middle).
@@ -1,5 +1,5 @@
// COPY of the port's SplitPane (package com.compose.sdl.widgets) so the SAME
// implementation runs on the jvm parity target — the port modules are
// implementation runs on the jvm parity target - the port modules are
// native-only, so apidemo's commonMain cannot reference them directly.
// Keep in sync with the original when it changes.
package apidemo.compat
@@ -66,7 +66,7 @@ fun HorizontalSplitPane(
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
// Solid divider that fills its own slot — what you see is exactly what
// Solid divider that fills its own slot - what you see is exactly what
// you can grab. Hover or an in-progress drag just changes the colour (no
// size change → no layout shift, and no flicker when the pointer briefly
// leaves the slot mid-drag).
+2 -2
View File
@@ -4,14 +4,14 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.platform.Font
// ==================
// MARK: Monospace body font — JVM actuals (classpath font/ staged by
// MARK: Monospace body font - JVM actuals (classpath font/ staged by
// jvmProcessResources)
// ==================
actual val monoFontFamily: FontFamily? by lazy {
val vBytes = object {}.javaClass.getResourceAsStream("/font/NotoSansMono.ttf")?.use { it.readBytes() }
if (vBytes == null) {
println("apidemo: NotoSansMono.ttf not on the classpath — body uses the default font")
println("apidemo: NotoSansMono.ttf not on the classpath - body uses the default font")
null
} else {
FontFamily(Font(identity = kMonoFamily, data = vBytes))
+1 -1
View File
@@ -23,7 +23,7 @@ fun main() = application {
// The shared App installs a persist-then-close hook (InstallWindowHooks).
onCloseRequest = { if (jvmOnCloseRequest?.invoke() != false) exitApplication() },
onPreviewKeyEvent = { jvmOnKeyShortcut?.invoke(it) ?: false },
title = "API Manager — JVM (upstream Compose)",
title = "API Manager - JVM (upstream Compose)",
icon = vIcon,
state = rememberWindowState(width = 1240.dp, height = 820.dp),
) {
+2 -2
View File
@@ -1,14 +1,14 @@
package apidemo
// ==================
// MARK: TLS chain / client-certificate — JVM actuals (native-only feature)
// MARK: TLS chain / client-certificate - JVM actuals (native-only feature)
// ==================
// The native builds drive the bundled libcurl (CURLOPT_SSLCERT / CERTINFO);
// there is no libcurl on the jvm parity stack, so both entry points report
// the limitation instead of half-implementing it over JSSE.
private const val kNativeOnly =
"Client-certificate (mTLS) features need the native build — they drive the bundled libcurl directly."
"Client-certificate (mTLS) features need the native build - they drive the bundled libcurl directly."
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain = TlsChain(emptyList(), kNativeOnly)
@@ -29,7 +29,7 @@ import java.awt.datatransfer.StringSelection
import java.io.File
// ==================
// MARK: JVM actuals — AWT + upstream Compose Desktop
// MARK: JVM actuals - AWT + upstream Compose Desktop
// ==================
private val kIsWindows = System.getProperty("os.name").startsWith("Windows")
@@ -41,7 +41,7 @@ actual fun createApiHttpClient(): io.ktor.client.HttpClient =
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
// ============
// Window hooks — the shared App installs them; MainJvm wires them into the
// Window hooks - the shared App installs them; MainJvm wires them into the
// upstream Window() (onCloseRequest / onPreviewKeyEvent).
internal var jvmOnCloseRequest: (() -> Boolean)? = null
@@ -154,7 +154,7 @@ actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inF
).lineCount
}.getOrDefault(1)
/** The jvm parity app only stores the preference — upstream's text pipeline has
/** The jvm parity app only stores the preference - upstream's text pipeline has
no global tab-width knob. Backed by state so menu checkmarks recompose. */
private var fTabWidth by mutableStateOf(4)
actual var editorTabWidth: Int
@@ -1,7 +1,7 @@
package apidemo
// Linux: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
// certificate and key files directly — no certificate-store dance needed.
// certificate and key files directly - no certificate-store dance needed.
/** Point libcurl straight at the certificate / key files. */
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
@@ -17,6 +17,6 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
/** No temporary certificate store off Windows. */
actual fun sweepTempClientCerts() {}
/** No OS-store issuer resolution here — just continue with the name-only issuer. */
/** No OS-store issuer resolution here - just continue with the name-only issuer. */
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> =
serverChainWithIssuerName(inServerCerts)
@@ -11,14 +11,14 @@ import kotlin.io.encoding.Base64
// Windows certificate store, referenced by SHA-1 thumbprint. So for a request
// that carries a certificate we import the cert + private key into the standard
// CurrentUser\MY ("Personal") store, hand curl "CurrentUser\MY\<thumbprint>",
// and delete it again the instant the request finishes — nothing accumulates.
// and delete it again the instant the request finishes - nothing accumulates.
//
// Our entries are tagged (friendly name + key-container name) with a unique
// prefix so the startup sweep can clear crash leftovers without ever touching
// the user's own certificates.
//
// Kotlin/Native maps several CryptoAPI LPCSTR params to String?, which makes the
// CryptDecodeObjectEx route (numeric struct-type sentinels) unusable — so the
// CryptDecodeObjectEx route (numeric struct-type sentinels) unusable - so the
// RSA private key is parsed here (ASN.1) and laid out as a CryptoAPI
// PRIVATEKEYBLOB directly, then handed to CryptImportKey. RSA (PKCS#1 or PKCS#8)
// and PKCS#12 are handled; EC PEM/DER is reported as unsupported with a hint.
@@ -42,7 +42,7 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert {
}
return PreparedCert(
sslCert = "$kStorePath\\$vThumb",
sslCertType = null, // store reference — not a file type
sslCertType = null, // store reference - not a file type
sslKey = null,
sslKeyType = null,
keyPassword = null,
@@ -62,7 +62,7 @@ private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<Str
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
val vKeyDer = privateKeyDer(vKeyBytes)
?: throw RuntimeException("No private key found — PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
?: throw RuntimeException("No private key found - PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
val vBlob = try {
rsaKeyDerToBlob(vKeyDer)
} catch (e: Throwable) {
@@ -374,7 +374,7 @@ private fun deleteContainer(inContainer: String) = memScoped {
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
}
/** Remove every temp cert we ever added (prefix-tagged) plus its container — for
/** Remove every temp cert we ever added (prefix-tagged) plus its container - for
clearing crash leftovers at startup. The user's own certs are never matched. */
@OptIn(ExperimentalForeignApi::class)
actual fun sweepTempClientCerts() {
@@ -417,7 +417,7 @@ private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped
// Chain extension via the OS certificate store
// ============
/** Continue the server's chain by asking the OS to build it from the leaf — this
/** Continue the server's chain by asking the OS to build it from the leaf - this
pulls intermediates/roots from the Windows store with full info. Falls back to
a name-only issuer if the OS can't (or the leaf can't be parsed). */
@OptIn(ExperimentalForeignApi::class)
+10 -10
View File
@@ -11,7 +11,7 @@ import kotlin.time.TimeSource
// MARK: Client-certificate (mTLS) sending via libcurl
// ==================
// Ktor's native engines expose no client-certificate API, so a request that
// carries a client certificate is sent here instead — straight through the
// carries a client certificate is sent here instead - straight through the
// libcurl that ktor-client-curl already bundles (package `libcurl`, embedded
// static archive; Schannel on Windows, OpenSSL on macOS/Linux). This is the
// same TLS stack the default engine uses, so behaviour matches; we just get to
@@ -19,7 +19,7 @@ import kotlin.time.TimeSource
//
// The transfer is synchronous (curl_easy_perform) and runs on Dispatchers.Default
// from HttpRunner.run(); cancellation is best-effort (an in-flight perform can't
// be interrupted — the result is simply discarded if the caller cancelled).
// be interrupted - the result is simply discarded if the caller cancelled).
/** A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
other SSL options to set, and a cleanup to run once the request is done.
@@ -40,7 +40,7 @@ Throws with a user-facing message if the cert/key can't be loaded. */
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
// ChainCert / TlsChain and the sweepTempClientCerts / inspectTlsChain /
// curlSendWithClientCert expects live in commonMain (TlsCommon.kt) — the UI
// curlSendWithClientCert expects live in commonMain (TlsCommon.kt) - the UI
// consumes them from shared code; the per-OS ClientCert*.kt actuals below the
// native tree actualize sweepTempClientCerts directly.
@@ -75,7 +75,7 @@ private class CurlSink {
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
}
/** Body write callback — appends each chunk to the sink's buffer. */
/** Body write callback - appends each chunk to the sink's buffer. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlBody(
inBuffer: CPointer<ByteVar>,
@@ -89,7 +89,7 @@ private fun onCurlBody(
return vLen.convert()
}
/** Header callback — one parsed header line per call; accumulated for parsing. */
/** Header callback - one parsed header line per call; accumulated for parsing. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlHeader(
inBuffer: CPointer<ByteVar>,
@@ -114,7 +114,7 @@ private fun onCurlDiscard(
): size_t =
(inSize * inCount)
/** Open a TLS connection to the request's URL (handshake only — no body) and
/** Open a TLS connection to the request's URL (handshake only - no body) and
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
request's client certificate if it has one (so mTLS endpoints work too).
inReq is already var-resolved. */
@@ -294,7 +294,7 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse {
headers = parseRawHeaders(vSink.headerRaw.toString()),
body = when {
vIsImage -> ""
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes - not shown; use Save as…)"
else -> vBytes.decodeToString()
},
bytes = vBytes,
@@ -314,7 +314,7 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse {
private fun errorResponse(inMessage: String, inMs: Long): ApiResponse =
ApiResponse(
status = 0,
statusText = "—",
statusText = "-",
timeMs = inMs,
sizeBytes = 0,
headers = emptyList(),
@@ -341,8 +341,8 @@ private fun requestBodyBytes(inReq: ApiRequest): CurlBody? {
}
/** Parse libcurl's accumulated raw header text into ordered key/value pairs,
keeping only the final response's block (a new "HTTP/" status line — emitted
per redirect hop — resets what we've gathered). */
keeping only the final response's block (a new "HTTP/" status line - emitted
per redirect hop - resets what we've gathered). */
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>> {
var vCurrent = mutableListOf<Pair<String, String>>()
for (vRawLine in inRaw.split("\r\n", "\n")) {
+2 -2
View File
@@ -6,13 +6,13 @@ import com.compose.sdl.loadComposeResourceBytes
import com.compose.sdl.text.namedFontFamily
// ==================
// MARK: Monospace body font — native actuals (data.kres + IconFont)
// MARK: Monospace body font - native actuals (data.kres + IconFont)
// ==================
actual val monoFontFamilyName: String? by lazy {
val vBytes = loadComposeResourceBytes("font/NotoSansMono.ttf")
if (vBytes == null) {
println("apidemo: NotoSansMono.ttf not bundled — body uses the default font")
println("apidemo: NotoSansMono.ttf not bundled - body uses the default font")
null
} else {
IconFont.register(kMonoFamily, vBytes)
+2 -2
View File
@@ -4,10 +4,10 @@ import com.compose.sdl.AppWindowIcon
import com.compose.sdl.nativeComposeWindow
// ==================
// MARK: Entry point (native — SDL window shell)
// MARK: Entry point (native - SDL window shell)
// ==================
// The voltic window/taskbar icon — pre-decoded .rgba blobs bundled into data.kres
// The voltic window/taskbar icon - pre-decoded .rgba blobs bundled into data.kres
// under icon/ by the bridge plugin (compose.desktop.native { icon {} }); the
// backend uses the largest as the base and the rest as alternate sizes.
private val kAppIcon = AppWindowIcon(
@@ -28,7 +28,7 @@ import io.ktor.client.engine.curl.*
import okio.FileSystem
// ==================
// MARK: Native actuals — pure delegation to the port's SDL-backed APIs
// MARK: Native actuals - pure delegation to the port's SDL-backed APIs
// ==================
actual fun appDataDir(inOrg: String, inApp: String): String? =
@@ -55,7 +55,7 @@ actual fun removeMemoryImage(inKey: String) = removeMemoryResource(inKey)
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter =
painterResource(inKey, if (inSvg) ResourceKind.Svg else ResourceKind.Raster)
// Cached resolver for wrappedRowCount — the skiko engine resolves fonts by name
// Cached resolver for wrappedRowCount - the skiko engine resolves fonts by name
// (projectFontName), so this is just the required non-null Paragraph argument.
private val mRowCountFontResolver = createFontFamilyResolver()
+8 -8
View File
@@ -12,7 +12,7 @@ plugins {
apiValidation {
@OptIn(ExperimentalBCVApi::class)
klib { enabled = true }
// demo/apidemo: apps, no published API. sdl-core: naked SDL cinterop — its
// demo/apidemo: apps, no published API. sdl-core: naked SDL cinterop - its
// "API" is the generated sdl3.* bindings (per-OS, can't infer macos), not our
// surface. material-symbols: auto-generated icon codepoint maps + its jvm
// dump trips BCV's ASM on newer JDKs (class file major 69). Track the
@@ -63,7 +63,7 @@ allprojects {
// ==================
// Every library module (everything except the two demo apps) auto-registers a
// MavenPublication via the kotlin-multiplatform plugin — one per target + one
// MavenPublication via the kotlin-multiplatform plugin - one per target + one
// for the shared kotlinMultiplatform metadata. The CI publish workflow runs on
// three hosts (macOS / Linux / Windows) and each invokes only the publication
// tasks Gradle actually generated for its own targets, so the group of hosts
@@ -84,7 +84,7 @@ val kPublishedLibs = setOf(
// -PuseGithubPackages=true swaps every `project(":<lib>")` reference the demo
// apps make for the published Maven coordinate. Library modules keep resolving
// each other as `project(...)` — the substitution only fires at the
// each other as `project(...)` - the substitution only fires at the
// app→library boundary, so the swap validates end-to-end consumption of the
// published klibs without touching the source of `implementation(project(...))`.
// Version defaults to 0.1.0 (matches the git tag) but can be pinned via -PconsumeVersion=….
@@ -153,7 +153,7 @@ subprojects {
publications.withType<MavenPublication>().configureEach {
pom {
name.set("${rootProject.name} ${project.name}")
description.set("Compose Multiplatform on SDL3 (Kotlin/Native, no JVM) — ${project.name}")
description.set("Compose Multiplatform on SDL3 (Kotlin/Native, no JVM) - ${project.name}")
url.set("https://github.com/${System.getenv("GITHUB_REPOSITORY") ?: "bitsycore/ComposeDesktopNative"}")
licenses {
license {
@@ -170,7 +170,7 @@ subprojects {
// FULL-COMMONIZATION BRIDGE (repo-wide): a module may declare the OFFICIAL
// Maven Compose artifacts in its commonMain so metadata + jvm resolve them
// (e.g. :material-symbols' common API); every NATIVE target configuration
// swaps those modules for the port's project equivalents — the Maven
// swaps those modules for the port's project equivalents - the Maven
// artifacts ship no mingwX64/linux klibs. org.jetbrains.compose.runtime is
// deliberately NOT here: the port uses the official runtime klibs everywhere.
val vNativeTargetTokens = listOf("mingwX64", "linuxX64", "linuxArm64", "macosArm64")
@@ -194,10 +194,10 @@ allprojects {
substitute(module("org.jetbrains.compose.animation:animation-core")).using(project(":animation-core"))
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":material3"))
// navigation3-ui: the JB Maven artifact has no K/N desktop
// klibs — the port vendors it as :navigation3-ui.
// klibs - the port vendors it as :navigation3-ui.
substitute(module("org.jetbrains.androidx.navigation3:navigation3-ui")).using(project(":navigation3-ui"))
// components-resources: the official resources runtime ships no
// mingwX64/linux klibs — the port vendors it as :components-resources.
// mingwX64/linux klibs - the port vendors it as :components-resources.
substitute(module("org.jetbrains.compose.components:components-resources")).using(project(":components-resources"))
}
}
@@ -205,7 +205,7 @@ allprojects {
}
// Whether the current host can build the mingwX64 target. Kotlin/Native can
// only cross-compile the mingwX64 sdl3 cinterop from a Windows host — it needs
// only cross-compile the mingwX64 sdl3 cinterop from a Windows host - it needs
// the Windows SDL3 headers under libs/ (produced by scripts/build-sdl/build-all.py
// run on a Windows host). Declaring `mingwX64()` on a non-Windows host is safe
// for pure-Kotlin modules but blows up the moment the sdl3 cinterop tries to
@@ -20,7 +20,7 @@ import java.net.URI
// ==================
/**
* Per-app knobs for [registerComposeFontBundling]. EVERYTHING is opt-in — with no
* Per-app knobs for [registerComposeFontBundling]. EVERYTHING is opt-in - with no
* flag set the call registers no tasks and stages nothing.
*/
class ComposeFontBundlingConfig {
@@ -31,7 +31,7 @@ class ComposeFontBundlingConfig {
var bundleNotoSans: Boolean = false
/**
* Always bundle font/NotoSansMono.ttf — for apps that load the mono font through
* Always bundle font/NotoSansMono.ttf - for apps that load the mono font through
* their own seam (apidemo's body font, Fonts.kt), invisible to auto-detection.
*/
var bundleNotoSansMono: Boolean = false
@@ -51,7 +51,7 @@ class ComposeFontBundlingConfig {
/**
* Subset each bundled Material Symbols font to the glyphs the sources reference
* (scripts/subset-material-symbols.py picks the codepoints; the subset task uses
* hb-subset when present, else fontTools via Python — auto-installed — and only
* hb-subset when present, else fontTools via Python - auto-installed - and only
* bundles the full font if neither is available). Needs [bundleMaterialSymbols];
* still gated by -PsubsetIcons.
*/
@@ -74,11 +74,11 @@ private val kMonospaceRegex = Regex("\\bFontFamily\\.Monospace\\b")
/**
* Wires the app font pipeline shared by :demo and :apidemo. Everything is OPT-IN via
* [ComposeFontBundlingConfig] — a bare call is a no-op.
* [ComposeFontBundlingConfig] - a bare call is a no-op.
*
* - Registers downloadNotoFonts (Noto Sans + Noto Sans Mono variable fonts →
* build/fonts/) when a Noto font is bundled.
* - Adds the opted-in font/ entries to every data.kres Zip task — the bridge plugin's
* - Adds the opted-in font/ entries to every data.kres Zip task - the bridge plugin's
* package<Variant>ComposeResources<Target> tasks or an app's own copy* tasks
* (matching{} is lazy: the plugin's tasks appear in its afterEvaluate). Material
* Symbols fonts are hb-subset to the used glyphs when subsetting is enabled.
@@ -142,7 +142,7 @@ fun Project.registerComposeFontBundling(configure: ComposeFontBundlingConfig.()
addFontEntries(this, vNotoFiles, vDownloadNotoFonts, vUsedStyles, vSubsetTasksByStyle)
}
tasks.withType<ProcessResources>().matching { it.name == "jvmProcessResources" }.configureEach {
// Full fonts on JVM — Skiko applies the variable axes itself (Typeface.makeClone).
// Full fonts on JVM - Skiko applies the variable axes itself (Typeface.makeClone).
addFontEntries(this, vNotoFiles, vDownloadNotoFonts, vUsedStyles, emptyMap())
}
}
@@ -182,7 +182,7 @@ private fun Project.detectUsedStyles(): List<String> {
// ==================
/**
* Adds the font/ entries (Notos + used Material Symbols styles) to inTask — a data.kres
* Adds the font/ entries (Notos + used Material Symbols styles) to inTask - a data.kres
* Zip or jvmProcessResources. inSubsetTasksByStyle maps styles to hb-subset tasks whose
* output replaces the full font; pass an empty map to stage the full fonts.
*/
@@ -304,21 +304,21 @@ private fun Project.registerSubsetTask(
)
}
// 1. hb-subset (harfbuzz) — fastest path when it is on PATH.
// 1. hb-subset (harfbuzz) - fastest path when it is on PATH.
if (runCmd("hb-subset", vInputFile.absolutePath, "-o", vOut.absolutePath, "--unicodes=$vUnicodes").first
&& vOut.length() > 0L
) {
logShrink("hb-subset"); return@doLast
}
// 2. fontTools (pyftsubset) via Python — the portable fallback. Python is
// 2. fontTools (pyftsubset) via Python - the portable fallback. Python is
// already a build prerequisite (SDL build + vendor sync) and fontTools is
// variable-font-aware, so the FILL/wght/GRAD/opsz axes survive. It is
// auto-installed if missing, so no manual harfbuzz install is ever required.
val vPython = listOf("python3", "python", "py").firstOrNull { runCmd(it, "--version").first }
if (vPython != null) {
if (!runCmd(vPython, "-c", "import fontTools").first) {
logger.lifecycle("[subset $inStyle] fontTools not present — installing it via pip…")
logger.lifecycle("[subset $inStyle] fontTools not present - installing it via pip…")
// --user (no root) → --break-system-packages (PEP 668) → plain.
listOf(listOf("--user"), listOf("--break-system-packages"), emptyList<String>())
.firstOrNull { vExtra ->
@@ -336,7 +336,7 @@ private fun Project.registerSubsetTask(
logger.warn("[subset $inStyle] fontTools subset failed :\n$vFtOut")
}
// 3. Last resort : the full font — correct, just larger.
// 3. Last resort : the full font - correct, just larger.
vInputFile.copyTo(vOut, overwrite = true)
logger.warn(
"[subset $inStyle] no working subsetter found (hb-subset or python3+fontTools) : " +
@@ -1,10 +1,10 @@
// :components-resources — the official Compose Multiplatform resources runtime
// :components-resources - the official Compose Multiplatform resources runtime
// (org.jetbrains.compose.components:components-resources), vendored because the
// Maven artifact ships no mingwX64/linux klibs. Public API is byte-for-byte
// upstream (painterResource / stringResource / Font / qualifiers / Res codegen
// compatibility); the platform actuals are this port's: data.kres reading,
// image decode via :ui's Skia decoder, SDL locale/theme environment. Apps' JVM
// targets keep using the official Maven artifact — this module is native-only.
// targets keep using the official Maven artifact - this module is native-only.
plugins {
alias(libs.plugins.kotlin.multiplatform)
@@ -40,7 +40,7 @@ kotlin {
}
nativeMain {
kotlin.srcDir("src/vendor/native/kotlin")
// src/nativeMain/kotlin also holds the Font / Image resource actuals —
// src/nativeMain/kotlin also holds the Font / Image resource actuals -
// pure project code that delegates to the project font registry
// (IconFont / NamedFont) and the :ui-graphics Skia decoder
// (decodeEncodedImageBitmap). Skiko-free, so a single native set covers
@@ -1,9 +1,9 @@
# Files vendored VERBATIM from JetBrains/compose-multiplatform (the umbrella repo —
# Files vendored VERBATIM from JetBrains/compose-multiplatform (the umbrella repo -
# NOT compose-multiplatform-core) into :components-resources. The official
# org.jetbrains.compose.components:components-resources artifact publishes NO
# mingwX64/linux variants, so the runtime library is vendored and the platform
# actuals are project code:
# src/nativeMain — ResourceReader (data.kres), getSystemEnvironment (SDL),
# src/nativeMain - ResourceReader (data.kres), getSystemEnvironment (SDL),
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser),
# and the Font/Image resource actuals: renderer-agnostic
# project code (image decode via the :ui-graphics Skia
@@ -43,16 +43,16 @@ blockingMain/kotlin/ -> src/vendor/native/kotlin/
nativeMain/kotlin/ -> src/vendor/native/kotlin/
# | nativeMain/kotlin/org/jetbrains/compose/resources/ImageResources.native.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ImageResources.native.kt
# | nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt
# Darwin-only (Foundation NSXMLParser) — replaced by a pure-Kotlin project parser
# Darwin-only (Foundation NSXMLParser) - replaced by a pure-Kotlin project parser
# in src/nativeMain so mingwX64/linux compile.
!nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt
# Portable pieces of upstream skikoMain (no Skia references — pure delegation /
# runtime-only) — every native target gets them.
# Portable pieces of upstream skikoMain (no Skia references - pure delegation /
# runtime-only) - every native target gets them.
skikoMain/kotlin/org/jetbrains/compose/resources/ImageDecoders.skiko.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ImageDecoders.skiko.kt
skikoMain/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt
# Skia-bound pieces — upstream's skikoMain actuals rely on Compose-Desktop Skia
# Skia-bound pieces - upstream's skikoMain actuals rely on Compose-Desktop Skia
# extensions (SystemFont / toComposeImageBitmap / nativeCanvas) this port's :ui
# doesn't expose. The module's own Font/Image actuals (src/nativeMain/kotlin/)
# use pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap)
@@ -15,14 +15,14 @@ import com.compose.sdl.icons.IconFont
import com.compose.sdl.text.NamedFont
// ==================
// MARK: Font actuals — Skia text stack via the project font registry
// MARK: Font actuals - Skia text stack via the project font registry
// ==================
/** The Skia text stack resolves fonts by FAMILY NAME through the project font
registry (IconFont.registerIcon — the general byte-font table, not just
registry (IconFont.registerIcon - the general byte-font table, not just
icons). A font resource therefore loads its bytes through the resource
reader, registers them under a per-resource family name, and returns a
[NamedFont] — `FontFamily(Font(Res.font.x))` then renders through the
[NamedFont] - `FontFamily(Font(Res.font.x))` then renders through the
standard project pipeline. Weight/style are carried on the NamedFont;
variation axes apply at draw time via Skia's axis support. */
@@ -19,7 +19,7 @@ import com.compose.sdl.graphics.svgIntrinsicSize
import kotlin.math.roundToInt
// ==================
// MARK: Image actuals — Skia decode via the :ui-graphics hook
// MARK: Image actuals - Skia decode via the :ui-graphics hook
// ==================
/** Decoding goes through the :ui-graphics Skia hook the SDL backend registers
@@ -28,7 +28,7 @@ import kotlin.math.roundToInt
drawables ship at a single density, so no decode-time rescale applies. */
internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity: Int): ImageBitmap =
decodeEncodedImageBitmap(this)
?: error("Image decode failed — is the render backend initialised before painterResource ran?")
?: error("Image decode failed - is the render backend initialised before painterResource ran?")
/** SVG element = the raw document bytes; rendering is size-driven (see SvgPainter). */
internal actual class SvgElement(val bytes: ByteArray)
@@ -8,13 +8,13 @@ import com.compose.sdl.res.preferredLocaleProvider
import com.compose.sdl.res.systemThemeIsDarkProvider
// ==================
// MARK: ResourceEnvironment — platform-env-seam actual
// MARK: ResourceEnvironment - platform-env-seam actual
// ==================
/** Non-composable system environment for qualifier resolution (values-fr,
drawable-dark, …): locale + theme come from the platform-env seams installed
by the SDL layer, so :components-resources carries no dependency on the sdl3
cinterop. Density is reported as 1f — under this port's Option-B density flow
cinterop. Density is reported as 1f - under this port's Option-B density flow
layout runs in physical pixels and drawables are bundled at a single density,
so the mdpi bucket is always the right one. The COMPOSABLE path
(rememberResourceEnvironment) doesn't use this: it reads LocalDensity /
@@ -24,7 +24,7 @@ internal actual fun getSystemEnvironment(): ResourceEnvironment {
val vDark = systemThemeIsDarkProvider?.invoke() ?: false
return ResourceEnvironment(
language = LanguageQualifier(vLanguage),
// SDL locales carry language+country only — no script subtag.
// SDL locales carry language+country only - no script subtag.
script = ScriptQualifier(""),
region = RegionQualifier(vRegion),
theme = ThemeQualifier.selectByValue(isDark = vDark),
@@ -8,11 +8,11 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi
import com.compose.sdl.loadComposeResourceBytes
// ==================
// MARK: ResourceReader — data.kres actual
// MARK: ResourceReader - data.kres actual
// ==================
/** The platform reader for this port: every app ships its composeResources
content inside data.kres (a STORED zip next to the executable — see the
content inside data.kres (a STORED zip next to the executable - see the
apps' Zip tasks), and :ui's ResourceIO serves entries by exact path with an
fseek+fread. The paths the generated Res accessors produce
("composeResources/<package>/drawable/x.png") are stored verbatim in
@@ -1,23 +1,23 @@
package org.jetbrains.compose.resources.vector.xmldom
// ==================
// MARK: DomXmlParser — pure-Kotlin replacement for upstream's Darwin parser
// MARK: DomXmlParser - pure-Kotlin replacement for upstream's Darwin parser
// ==================
/* Upstream's nativeMain DomXmlParser is built on Foundation's NSXMLParser and
only compiles on Darwin; this port also targets mingwX64/linux, so the same
`parse(xml): Element` entry point is implemented as a small recursive-descent
XML parser instead (project file — the upstream one is excluded in
XML parser instead (project file - the upstream one is excluded in
compose-fork.txt).
Scope: exactly what XmlVectorParser + the resource string XMLs need —
Scope: exactly what XmlVectorParser + the resource string XMLs need -
elements, namespaced attributes (xmlns scoping, getAttributeNS,
lookupPrefix), text nodes (textContent), comments / prolog / DOCTYPE /
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. */
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)
// (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)
@@ -59,7 +59,7 @@ private class ElementImpl(
override val nodeName: String get() = if (fPrefix.isEmpty()) localName else "$fPrefix:$localName"
override val childNodes: NodeList get() = NodeListImpl(children)
// Concatenated descendant text — what DOM's textContent does.
// Concatenated descendant text - what DOM's textContent does.
override val textContent: String?
get() = buildString {
for (vChild in children) {
@@ -88,7 +88,7 @@ private class ElementImpl(
}
// ============
// The parser — one pass over the string with a cursor.
// The parser - one pass over the string with a cursor.
private class XmlDomParser(private val fXml: String) {
private var fPos = 0
@@ -114,7 +114,7 @@ private class XmlDomParser(private val fXml: String) {
val vQName = readName()
if (vQName.isEmpty()) throw MalformedXMLException("empty element name at $fPos")
// Attributes (collect raw first — xmlns declarations shape the scope
// Attributes (collect raw first - xmlns declarations shape the scope
// that THIS element's own prefix resolves against).
val vRawAttrs = ArrayList<Pair<String, String>>() // qualifiedName to value
while (true) {
@@ -217,7 +217,7 @@ private class XmlDomParser(private val fXml: String) {
fPos = vEnd + inToken.length
}
/* DOCTYPE may nest an internal subset in [ ] — skip to the matching '>'. */
/* DOCTYPE may nest an internal subset in [ ] - skip to the matching '>'. */
private fun skipDoctype() {
var vDepth = 0
while (fPos < fXml.length) {
@@ -1,11 +1,11 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
// :animation-core — androidx.compose.animation.core.* vendored VERBATIM from
// :animation-core - androidx.compose.animation.core.* vendored VERBATIM from
// upstream. Renderer-agnostic (spring/tween specs, animatable, transition,
// vector math). Split out of :ui so :ui stays about renderer+ui glue only.
//
// Provenance = animation-core/compose-fork.txt + scripts/compose-fork/compose.properties.
// Never hand-edit files under animation-core/src/vendor/ — change the manifest and
// Never hand-edit files under animation-core/src/vendor/ - change the manifest and
// re-run `bash scripts/compose-fork/sync.sh :animation-core`.
//
// Publication artifactId (when set up): compose-desktop-native-animation-core.
@@ -1,9 +1,9 @@
// :animation-graphics — androidx.compose.animation.graphics.*, vendored VERBATIM
// :animation-graphics - androidx.compose.animation.graphics.*, vendored VERBATIM
// from upstream into src/vendor/. Split out to mirror Compose Multiplatform's
// module packaging (compose/animation/animation-graphics).
//
// Provenance = animation-graphics/compose-fork.txt + scripts/compose-fork/compose.properties.
// Never hand-edit files under src/vendor/ — change the manifest and re-run
// Never hand-edit files under src/vendor/ - change the manifest and re-run
// `python scripts/compose-fork/sync.py compose/animation/animation-graphics`.
//
// Publication artifactId: desktop-animation-graphics.
+2 -2
View File
@@ -1,9 +1,9 @@
// :animation — androidx.compose.animation.* (non-core), vendored VERBATIM from
// :animation - androidx.compose.animation.* (non-core), vendored VERBATIM from
// upstream into src/vendor/. Split out of :foundation to mirror Compose
// Multiplatform's module packaging.
//
// Provenance = animation/compose-fork.txt + scripts/compose-fork/compose.properties.
// Never hand-edit files under src/vendor/ — change the manifest and re-run
// Never hand-edit files under src/vendor/ - change the manifest and re-run
// `python scripts/compose-fork/sync.py compose/animation/animation`.
//
// Publication artifactId: desktop-animation.
@@ -1,7 +1,7 @@
// :desktop-native-window — the module apps depend on. Owns nativeComposeWindow() (main loop,
// :desktop-native-window - the module apps depend on. Owns nativeComposeWindow() (main loop,
// recomposer lifecycle, event dispatch, Snapshot apply notifications).
// Renderer selection happens entirely inside :ui via source-set wiring
// (skikoRendererMain — Skia-only) — this module just calls
// (skikoRendererMain - Skia-only) - this module just calls
// `createRenderBackend(...)` and `rendererPreferredGpuMode()` from :ui and
// the right symbol resolves per target.
// Publication artifactId (when set up): compose-desktop-native.
@@ -23,7 +23,7 @@ kotlin {
applyDefaultHierarchyTemplate()
// sdl3.* types are api-exposed via :ui → :sdl-core's cinterop klib — no
// sdl3.* types are api-exposed via :ui → :sdl-core's cinterop klib - no
// separate sdl3 cinterop here.
sourceSets {
@@ -35,13 +35,13 @@ kotlin {
// Material widgets used to be re-exported from :material here; the
// module was retired when :apidemo and :demo migrated to :material3.
// Apps that want Material 3 widgets pull `implementation(project(":material3"))`
// themselves (:material3 doesn't need to be `api`-exposed — the
// themselves (:material3 doesn't need to be `api`-exposed - the
// upstream vendored surface is stable and apps import it directly).
api(project(":ui"))
api(project(":foundation"))
api(project(":animation-core"))
implementation(libs.kotlinx.coroutines.core)
// setMain() / resetMain() — see Sdl3MainDispatcher.kt for usage.
// setMain() / resetMain() - see Sdl3MainDispatcher.kt for usage.
implementation(libs.kotlinx.coroutines.test)
}
}
@@ -11,14 +11,14 @@ package com.compose.sdl
*
* Each list holds data.kres resource paths of PRE-DECODED `.rgba` icon blobs
* (produced by the bridge plugin's `icon { }` packaging, or by
* `scripts/make-app-icon.py rgba` — i.e. an 8-byte
* `scripts/make-app-icon.py rgba` - i.e. an 8-byte
* `[width u32-le][height u32-le]` header + straight-alpha RGBA pixels). List
* one path per size you bundle; the largest becomes the base and the rest are
* attached as alternate resolutions SDL chooses from (title bar vs taskbar vs
* Alt-Tab), so the icon stays crisp at every size.
*
* On Windows the runtime icon set here complements the icon EMBEDDED in the
* `.exe` (Explorer / pinned taskbar) — see the app / bridge-plugin Gradle setup.
* `.exe` (Explorer / pinned taskbar) - see the app / bridge-plugin Gradle setup.
*/
class AppWindowIcon(
val light: List<String>,
@@ -48,7 +48,7 @@ import sdl3.SDL_SetWindowTitle
import sdl3.SDL_WaitEventTimeout
// ==================
// MARK: Application entry — nativeComposeApp + Window()
// MARK: Application entry - nativeComposeApp + Window()
// ==================
/**
@@ -60,7 +60,7 @@ import sdl3.SDL_WaitEventTimeout
if (showTools) Window(onCloseRequest = { showTools = false }, title = "Tools") { ToolsUi() }
}
The app CONTENT is itself a composition (no UI tree — a Unit applier): each
The app CONTENT is itself a composition (no UI tree - a Unit applier): each
`Window(...)` call materialises an SDL window + renderer + root host + its own
recomposer/composition, and leaving the composition (state flips to false)
tears the window down. One main loop pumps the shared SDL event queue and
@@ -82,7 +82,7 @@ interface ApplicationScope {
/** Declares one native window for as long as this composable stays in the app
composition. `onCloseRequest` fires when the user asks the window to close
(OS close button, or `window.close()` from content) — remove the state that
(OS close button, or `window.close()` from content) - remove the state that
composes this Window to actually close it, or call exitApplication(). */
@Composable
fun ApplicationScope.Window(
@@ -135,10 +135,10 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
runBlocking {
runtime.scope = this
// App-level composition: no UI tree — it only declares Window()s.
// App-level composition: no UI tree - it only declares Window()s.
val appClock = SDL3FrameClock()
val appRecomposer = Recomposer(coroutineContext + appClock)
// The clock must be in the collector's context — runRecomposeAndApplyChanges
// The clock must be in the collector's context - runRecomposeAndApplyChanges
// awaits parent frames through it.
val appRecomposeJob = launch(appClock) { appRecomposer.runRecomposeAndApplyChanges() }
val appComposition = Composition(UnitApplier(), appRecomposer)
@@ -164,13 +164,13 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
var vRenderedSinceGc = false
while (!runtime.exitRequested) {
FrameProfiler.mark()
// One virtual 16.6ms tick per loop iteration (no-op unless useVirtualFrameTime) —
// One virtual 16.6ms tick per loop iteration (no-op unless useVirtualFrameTime) -
// all windows' clocks share the same timestamp this iteration.
advanceVirtualFrame()
Snapshot.sendApplyNotifications()
// ============
// Events — one shared SDL queue, routed by window id.
// Events - one shared SDL queue, routed by window id.
val vEvents = pollEvents()
for (vEvent in vEvents) {
when (vEvent) {
@@ -200,7 +200,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
FrameProfiler.phase("events")
// ============
// App composition pump — Window()s may appear / disappear here.
// App composition pump - Window()s may appear / disappear here.
Snapshot.sendApplyNotifications()
appClock.sendFrame(frameClockNanos())
yield()
@@ -250,7 +250,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
FrameProfiler.frameDone(vAnyRendered)
// ============
// Drain deferred native-resource disposals on the MAIN thread —
// Drain deferred native-resource disposals on the MAIN thread -
// textures/surfaces whose owner closed them or whose Cleaner fired
// on a GC worker enqueue here (SDL calls aren't thread-safe). This
// is the ownership path that makes the GC nudge below a mere
@@ -270,7 +270,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
for (vW in runtime.windows) vW.resetFpsWindow()
} else {
// App composition has pending work but nothing rendered this
// iteration — yield briefly instead of spinning at full speed
// iteration - yield briefly instead of spinning at full speed
// until the composition settles into a renderable state.
SDL_Delay(1u)
}
@@ -278,7 +278,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// ============
// Native-memory nudge. Renderer resources (Skia surfaces / images /
// fonts, SDL textures) are freed by Cleaners that only run when the
// Kotlin/Native GC collects — and a Compose app's Kotlin heap is
// Kotlin/Native GC collects - and a Compose app's Kotlin heap is
// small enough that the allocation-driven scheduler can starve them
// for minutes while the NATIVE heap balloons (issue #2: memory
// "never released" while navigating). Collect periodically, only
@@ -317,13 +317,13 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
/** 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
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. */
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)")
@@ -331,7 +331,7 @@ private object CancelInfiniteAnimationsPolicy : androidx.compose.ui.platform.Inf
/** 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.
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. */
@@ -347,12 +347,12 @@ private fun advanceVirtualFrame() {
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
// 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
// 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
@@ -379,7 +379,7 @@ private fun displayFrameDelayMs(window: COpaquePointer?): UInt {
return if (vHz > 0f) (1000f / vHz).toUInt().coerceAtLeast(1u) else 16u
}
/** Single-window compatibility wrapper — the pre-multi-window entry point.
/** Single-window compatibility wrapper - the pre-multi-window entry point.
Closing the window exits the app, exactly as before. */
fun nativeComposeWindow(
title: String = "ComposeNativeSDL3",
@@ -413,7 +413,7 @@ internal class ApplicationScopeImpl(val runtime: AppRuntime) : ApplicationScope
}
/** Registry + lifecycle for the live windows. Windows are created by the app
composition (Window()'s remember) and destroyed by the LOOP — DisposableEffect
composition (Window()'s remember) and destroyed by the LOOP - DisposableEffect
onDispose only schedules, because teardown disposes a composition and that
must not run re-entrantly inside another composition's apply pass. */
internal class AppRuntime {
@@ -441,7 +441,7 @@ internal class AppRuntime {
inContent: () -> (@Composable ComposeWindowScope.() -> Unit),
): WindowInstance {
val vWindow = WindowInstance(inTitle, inWidth, inHeight, inGpu, inIcon, inOnFrame, inContent)
// A Window() was declared either way — the "exit when the last window
// A Window() was declared either way - the "exit when the last window
// is gone" rule must also fire when every declared window failed to
// initialise (otherwise the loop would spin forever with none).
hadWindow = true
@@ -466,7 +466,7 @@ internal class AppRuntime {
}
}
// No-op applier for the app-level composition — it emits no UI nodes, only
// No-op applier for the app-level composition - it emits no UI nodes, only
// side effects (each Window() manages a native window).
private class UnitApplier : AbstractApplier<Unit>(Unit) {
override fun insertTopDown(index: Int, instance: Unit) {}
@@ -477,7 +477,7 @@ private class UnitApplier : AbstractApplier<Unit>(Unit) {
}
// ==================
// MARK: WindowInstance — one SDL window + renderer + composition
// MARK: WindowInstance - one SDL window + renderer + composition
// ==================
internal class WindowInstance(
@@ -537,7 +537,7 @@ internal class WindowInstance(
private val backNavigationInput = BackNavigationInput()
// WINDOW-scoped architecture-components owner (Lifecycle + ViewModelStore +
// SavedStateRegistry) — the same trio upstream desktop's
// SavedStateRegistry) - the same trio upstream desktop's
// DefaultArchitectureComponentsOwner supplies through the window
// PlatformContext (compose/ui skikoMain PlatformOwnerProvider.skiko.kt).
// viewModel(), SavedStateHandle plumbing and navigation3's
@@ -563,7 +563,7 @@ internal class WindowInstance(
else -> androidx.lifecycle.Lifecycle.State.STARTED
}
)
// Shown / restored / focus-gained also invalidate the contents — keep
// Shown / restored / focus-gained also invalidate the contents - keep
// the pre-lifecycle RedrawNeeded behaviour of these SDL events.
needsFrame = true
}
@@ -598,7 +598,7 @@ internal class WindowInstance(
// CPU raster (Software) so the window still opens instead of failing.
var vRender = makeBackend(gpuMode)
if (vRender == null && gpuMode is GpuMode.Skia) {
println("GPU renderer ($gpuMode) unavailable — falling back to CPU raster (Software)")
println("GPU renderer ($gpuMode) unavailable - falling back to CPU raster (Software)")
gpuMode = GpuMode.Software
vRender = makeBackend(gpuMode)
}
@@ -611,7 +611,7 @@ 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
// 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)
@@ -622,7 +622,7 @@ internal class WindowInstance(
// setContent below).
installGlobals()
// Effect context for this window's composition — the screenshot flag injects the
// 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
@@ -646,7 +646,7 @@ internal class WindowInstance(
LocalComposeNativeWindow provides facade,
LocalPopupHost provides vPopupHost,
// Upstream vendored CompositionLocals.kt declares each of these as
// `staticCompositionLocalOf<T> { noLocalProvidedFor("…") }` — reading
// `staticCompositionLocalOf<T> { noLocalProvidedFor("…") }` - reading
// one without a Provider throws. Seed them all from the ComposeOwner.
androidx.compose.ui.platform.LocalDensity provides host.density,
androidx.compose.ui.platform.LocalLayoutDirection provides host.layoutDirection,
@@ -663,7 +663,7 @@ internal class WindowInstance(
androidx.compose.ui.platform.LocalFontFamilyResolver provides
com.compose.sdl.text.font.projectFontFamilyResolver,
androidx.compose.ui.platform.LocalUriHandler provides vUriHandler,
// Desktop windows have no system bars / notch / IME insets — the
// Desktop windows have no system bars / notch / IME insets - the
// interface's all-zero defaults are exactly right.
androidx.compose.ui.platform.LocalPlatformWindowInsets provides
object : androidx.compose.ui.platform.PlatformWindowInsets {},
@@ -681,7 +681,7 @@ internal class WindowInstance(
androidx.lifecycle.compose.LocalLifecycleOwner provides architectureOwner,
androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner provides architectureOwner,
androidx.savedstate.compose.LocalSavedStateRegistryOwner provides architectureOwner,
// Runtime-level host defaults — this window's navigation-event
// Runtime-level host defaults - this window's navigation-event
// owner (SearchBar / sheets back plumbing). The viewmodel store
// is provided through the plain LocalViewModelStoreOwner above
// instead: the google lifecycle artifacts this port ships have no
@@ -710,7 +710,7 @@ internal class WindowInstance(
}
}
}
// First composition done (setContent is synchronous) — promote the
// First composition done (setContent is synchronous) - promote the
// window lifecycle from CREATED to the focus/visibility-derived state.
// Composition itself runs at CREATED so enableSavedStateHandles()
// callers see a legal state, mirroring upstream desktop's
@@ -760,7 +760,7 @@ internal class WindowInstance(
PointerButton.Tertiary -> 2
else -> 0
}
// SDL3 delivers mouse coords in logical points on HiDPI — multiply by
// SDL3 delivers mouse coords in logical points on HiDPI - multiply by
// DPR so hit-testing lands in the pixel space layout uses.
val vDpr = backend.pixelDensity
val vPx = inEvent.event.x * vDpr
@@ -806,7 +806,7 @@ internal class WindowInstance(
installGlobals()
// A focused text field runs an IME session (ComposeOwner.textInputSession):
// commit through it so the text REPLACES any active composition. Falls back
// to synthesising typed KeyEvents when no field is focused — SDL key events
// to synthesising typed KeyEvents when no field is focused - SDL key events
// carry UNSHIFTED keycodes (no uppercase/numpad/dead keys), so committed
// text is the only layout-correct character source.
if (!com.compose.sdl.text.input.ImeBridge.commit(inEvent.text)) {
@@ -865,7 +865,7 @@ internal class WindowInstance(
facade.onResized()
}
/** OS light/dark theme changed — re-pick the theme-appropriate window icon
/** OS light/dark theme changed - re-pick the theme-appropriate window icon
(no-op if this window has no icon configured or the choice is unchanged). */
fun onSystemThemeChanged() {
backend.applyThemeIcon()
@@ -889,7 +889,7 @@ internal class WindowInstance(
/** 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
frame. Sampled by windowHasInvalidations() from onFrame probes - the quiescence
signal for render-to-settle screenshot capture. */
fun hasInvalidations(): Boolean =
needsFrame || recomposer?.hasPendingWork == true ||
@@ -905,13 +905,13 @@ internal class WindowInstance(
// Charge the GPU back-buffer acquire to its own phase. On Metal this is
// where nextDrawable() blocks on vsync (the natural frame pacing), so
// folding it into "layout" made the profiler read the vsync wait as
// layout cost — a ~6-7ms phantom that hid the real (tiny) layout time.
// layout cost - a ~6-7ms phantom that hid the real (tiny) layout time.
FrameProfiler.phase(" acquire")
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
// Deliver any state written by this iteration's frame-clock continuations
// (withFrameNanos animations — notably smooth wheel scrolling) BEFORE we lay
// (withFrameNanos animations - notably smooth wheel scrolling) BEFORE we lay
// out. Those writes happen in the per-window pump's sendFrame()/yield(), which
// is AFTER the loop's last sendApplyNotifications() — so without this the
// is AFTER the loop's last sendApplyNotifications() - so without this the
// relayout they trigger wasn't registered until the next frame, and the frame
// drew last frame's positions. That one-frame trail read as "the clip is a
// frame late" when scrolling (content briefly drawn past the viewport edge).
@@ -938,14 +938,14 @@ internal class WindowInstance(
FrameProfiler.phase(" present")
frameIndex++
// FPS — instantaneous inter-frame rate, EMA-smoothed, title refreshed
// 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
// 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
// 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 vDt = (vNowMs - fpsLastFrameMs).toInt()
@@ -9,17 +9,17 @@ import sdl3.SDL_GetTicks
// MARK: FrameProfiler
// ==================
/** CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
/** 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. */
second - see ROADMAP.md. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
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
// 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.
@@ -6,7 +6,7 @@ import sdl3.SDL_GetTicksNS
/** MonotonicFrameClock for the SDL main loop, with upstream frame semantics:
- withFrameNanos ALWAYS suspends until the NEXT sendFrame — never completes
- withFrameNanos ALWAYS suspends until the NEXT sendFrame - never completes
with a stale frame. Deferred animations (AnimatedVisibility's enter/exit
size + fade, SharedTransition bounds) are registered during the
measure/draw pass AFTER the recomposition that starts a Transition; the
@@ -31,7 +31,7 @@ internal class SDL3FrameClock : MonotonicFrameClock {
}
// Coroutines (recomposer / composition animations) currently suspended in withFrameNanos
// awaiting the next sendFrame — the "an animation is still running" half of the window's
// 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
}
@@ -12,7 +12,7 @@ import sdl3.SDL_GetCurrentThreadID
/** Single-threaded Main dispatcher driven by the SDL3 main loop. Kotlin/Native
ships no Dispatchers.Main on Linux/Windows targets (and the Darwin one
posts to GCD's main queue, which only drains when the run loop pumps —
posts to GCD's main queue, which only drains when the run loop pumps -
our SDL_Delay-based loop doesn't reliably pump it). nativeComposeWindow
installs this dispatcher via kotlinx.coroutines.test.setMain at startup
and resets it on shutdown, so app code can withContext(Dispatchers.Main)
@@ -24,13 +24,13 @@ import sdl3.SDL_GetCurrentThreadID
(Dispatchers.IO, Default, etc.) are safe.
`immediate` is a REAL immediate dispatcher: its isDispatchNeeded() returns
false when already on the SDL main thread, so work runs inline — matching
false when already on the SDL main thread, so work runs inline - matching
Android's Main.immediate / Swing's EDT semantics. This is load-bearing:
androidx.lifecycle's KMP LifecycleRegistry enforces main-thread access by
round-tripping through Dispatchers.Main.immediate (MainDispatcherChecker);
with a queue-only Main that trip can never complete from the main thread
while the loop is inside setContent, deadlocking the app at 0% CPU (this
froze Navigation3's NavDisplay — rememberLifecycleOwner — on mingwX64,
froze Navigation3's NavDisplay - rememberLifecycleOwner - on mingwX64,
see NAV_FIX.md). The BASE dispatcher intentionally KEEPS always-queue
semantics so LaunchedEffect / recomposer ordering is unchanged: state
writes land at the loop's drainPending(), observable on the same frame. */
@@ -38,7 +38,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
private val fQueue = Channel<Runnable>(Channel.UNLIMITED)
// The SDL main thread — the dispatcher is constructed by nativeComposeApp
// The SDL main thread - the dispatcher is constructed by nativeComposeApp
// on the thread that then runs the loop.
private val fMainThreadId = SDL_GetCurrentThreadID()
@@ -13,11 +13,11 @@ import androidx.lifecycle.enableSavedStateHandles
default ViewModel factory), so viewModel(), SavedStateHandle and
rememberSaveable-backed registries all resolve against the WINDOW scope.
The lifecycle registry uses createUnsafe (no main-thread enforcement) —
The lifecycle registry uses createUnsafe (no main-thread enforcement) -
same as the root owner this replaces; the SDL loop is single-threaded
anyway. RESUMED from construction; destroy() moves to DESTROYED and clears
the ViewModelStore (onCleared runs). SavedState restores from nothing (no
process-death persistence on desktop — upstream desktop passes null too). */
process-death persistence on desktop - upstream desktop passes null too). */
internal class WindowArchitectureOwner :
androidx.lifecycle.LifecycleOwner,
androidx.lifecycle.ViewModelStoreOwner,
@@ -41,13 +41,13 @@ internal class WindowArchitectureOwner :
init {
savedStateController.performAttach()
savedStateController.performRestore(null)
// SavedStateHandle support for WINDOW-scoped ViewModels — must run while
// SavedStateHandle support for WINDOW-scoped ViewModels - must run while
// the lifecycle is still ≤ CREATED; upstream desktop's ComposeContainer
// calls this at the same point. With it, `viewModel { ... }` against the
// window owner (the activityViewModels() analog) can take a
// SavedStateHandle instead of needing a saved-state-less child owner.
enableSavedStateHandles()
// CREATED (not RESUMED) until the first composition is done — code that
// CREATED (not RESUMED) until the first composition is done - code that
// runs enableSavedStateHandles() during composition (nav3's decorators,
// rememberViewModelStoreOwner, …) requires INITIALIZED/CREATED, and
// upstream desktop windows likewise compose first and resume after.
@@ -55,7 +55,7 @@ internal class WindowArchitectureOwner :
}
/** Focus/visibility-driven state (see WindowInstance.onActivationEvent).
Ignored once destroyed — a stray SDL event during teardown must not
Ignored once destroyed - a stray SDL event during teardown must not
resurrect the registry. */
fun setLifecycleState(inState: androidx.lifecycle.Lifecycle.State) {
if (lifecycle.currentState != androidx.lifecycle.Lifecycle.State.DESTROYED &&
@@ -1,9 +1,9 @@
// :foundation-layout — androidx.compose.foundation.layout.*, vendored VERBATIM
// :foundation-layout - androidx.compose.foundation.layout.*, vendored VERBATIM
// from upstream into src/vendor/. Split out of :foundation to mirror Compose
// Multiplatform's module packaging.
//
// Provenance = foundation-layout/compose-fork.txt + scripts/compose-fork/compose.properties.
// Never hand-edit files under src/vendor/ — change the manifest and re-run
// Never hand-edit files under src/vendor/ - change the manifest and re-run
// `python scripts/compose-fork/sync.py compose/foundation/foundation-layout`.
//
// Publication artifactId: desktop-foundation-layout.
@@ -1,6 +1,6 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
// :foundation — androidx.compose.foundation.* AND androidx.compose.animation.*
// :foundation - androidx.compose.foundation.* AND androidx.compose.animation.*
// (non-core) vendored VERBATIM from upstream. Sits on :ui (which still owns
// androidx.compose.ui.*, the Skia renderer, cinterops) + :animation-core.
//
@@ -14,12 +14,12 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
// module boundary is coalesced.
//
// Provenance = foundation/compose-fork.txt + scripts/compose-fork/compose.properties.
// Never hand-edit files under foundation/src/vendor/ — change the manifest and
// Never hand-edit files under foundation/src/vendor/ - change the manifest and
// re-run `bash scripts/compose-fork/sync.sh :foundation`.
//
// Note on DarkTheme: foundation declares `internal expect fun _isSystemInDarkTheme()`,
// and its actual lives here as a plain nativeMain kt file (see
// src/nativeMain/kotlin/androidx/compose/foundation/DarkTheme.native.kt) — a single
// src/nativeMain/kotlin/androidx/compose/foundation/DarkTheme.native.kt) - a single
// stub returning false. The upstream skiko per-renderer actuals were dropped
// when :foundation was split from :ui: expect/actual must live in the same
// module, and duplicating the skikoRenderer source-set hierarchy on
@@ -24,7 +24,7 @@
// infoList index to the valid range. Upstream assumes that if the drag position never
// resolves ON or BEFORE any selectable, at least one slot is anchored on a text (odd).
// That assumption breaks when a mouse-down starts a NEW selection AFTER every selectable
// in the container — both slots stay UNASSIGNED and get defaulted to lastSlot = 2N by
// in the container - both slots stay UNASSIGNED and get defaulted to lastSlot = 2N by
// SelectionLayoutBuilder.build(), so startSlot / 2 == infoList.size (OOB). Repro:
// apidemo response viewer, drag-select in the empty area below the last body block.
@@ -10,7 +10,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
// ==================
// MARK: IconText — icon-font text on the standard text path
// MARK: IconText - icon-font text on the standard text path
// ==================
/**
@@ -21,7 +21,7 @@ import androidx.compose.ui.unit.TextUnit
Now just a [BasicText]: the icon font family and its variable axes are threaded
through [namedFontFamily] (`axes = …`), which the skiko text engine reads via
`FontFamily.projectFontVariations()` and applies to the typeface. So icons
measure + draw through the same skiko `skparagraph` path as ordinary text — no
measure + draw through the same skiko `skparagraph` path as ordinary text - no
separate renderer/measurer seam.
Material `Icon(codepoint = …, fontFamily = …)` uses this; Text / BasicText /
@@ -15,7 +15,7 @@ import androidx.compose.ui.unit.IntOffset
/** One selection-highlight rectangle (in layout pixels), painted behind the glyphs.
Internal render glue shared by BasicText (selection-aware) and BasicTextField.
Sizes come from Paragraph.getBoundingBox / getPathForRange — already pixels
Sizes come from Paragraph.getBoundingBox / getPathForRange - already pixels
(see § HiDPI in CLAUDE.md), so use the pixel-based offset + a raw Layout
sizing shim instead of `.dp` (which would double-scale on Retina). */
@Composable
@@ -73,7 +73,7 @@ fun HorizontalSplitPane(
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
// Solid divider that fills its own slot — what you see is exactly what
// Solid divider that fills its own slot - what you see is exactly what
// you can grab. Hover or an in-progress drag just changes the colour (no
// size change → no layout shift, and no flicker when the pointer briefly
// leaves the slot mid-drag).
@@ -3,7 +3,7 @@ package androidx.compose.foundation
import androidx.compose.ui.node.DelegatableNode
// ==================
// MARK: Clickable — native actuals
// MARK: Clickable - native actuals
// ==================
/** Desktop/SDL values (mirror upstream desktop): no tap-indication delay, and the
@@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalAccessorScope
// ==================
// MARK: Overscroll actuals — desktop no-op
// MARK: Overscroll actuals - desktop no-op
// ==================
/**
@@ -2,7 +2,7 @@ package androidx.compose.foundation
// Native actual for vendored `commonMain/RequestFocusOnClick.kt`.
// Mirrors the upstream `macos.kt` / `desktop.kt` / `ios.kt` actuals which
// all return `true`. (Upstream has no `nativeMain` / `skikoMain` actual —
// all return `true`. (Upstream has no `nativeMain` / `skikoMain` actual -
// the desktop targets fold in via per-platform source sets we don't
// replicate, so this hand-written stub stands in for the whole native
// family. Drop in favour of `vendor/native/...` if upstream gains one.)
@@ -1,6 +1,6 @@
// VENDOR-BASE: compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/Scrollbar.skiko.kt @ v1.12.0-beta03+dev4483
// MANUAL VENDOR: refused in compose-fork.txt (flat native source set). Only edit vs base is the
// K2 @file:Suppress below — identical to what sync.py injects into src/vendor .kt (this file lives
// K2 @file:Suppress below - identical to what sync.py injects into src/vendor .kt (this file lives
// in src/nativeMain so sync doesn't regenerate it). Needed so compileNativeMainKotlinMetadata (the
// Windows publish path) tolerates the JVM-only annotations orphaned by our native-only nativeMain.
/*
@@ -30,7 +30,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
* actuals here provide:
*
* - A start detector that fires on any primary-button drag via the standard
* `detectDragGestures` — no touch-vs-mouse distinction. Matches upstream's
* `detectDragGestures` - no touch-vs-mouse distinction. Matches upstream's
* intent (drag begins on first pointer movement) for the mouse-only desktop
* use case.
* - A no-op drag-shadow callback: the visual ghost of the dragged content
@@ -54,7 +54,7 @@ internal actual object DragAndDropSourceDefaults {
internal actual class CacheDrawScopeDragShadowCallback actual constructor() {
actual fun drawDragShadow(drawScope: DrawScope) {
// Skipped — see file header.
// Skipped - see file header.
}
actual fun cachePicture(scope: CacheDrawScope): DrawResult {
@@ -12,14 +12,14 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastFold
// ==================
// MARK: Scrollable — native actuals (mouse-wheel config + platform fling)
// MARK: Scrollable - native actuals (mouse-wheel config + platform fling)
// ==================
/* Native actuals for the vendored upstream `Scrollable.kt` `expect`s. Matches Compose Desktop's
WINDOWS config (DesktopScrollable.desktop.kt → WindowsWinUIConfig), which is what the app is
compared against:
- each wheel unit scrolls a FRACTION of the viewport (bounds / 20), not a fixed dp — this is
- each wheel unit scrolls a FRACTION of the viewport (bounds / 20), not a fixed dp - this is
what makes a wheel click move a natural amount regardless of list length;
- smooth scrolling ON, so MouseWheelScrollingLogic tweens each accumulated delta over up to
~100ms instead of jumping in one frame (the "velocity" feel: spin fast and pending notches
@@ -27,7 +27,7 @@ import androidx.compose.ui.util.fastFold
- wheel is NEVER treated as "precise" (Windows animates even free-spinning wheels), so every
notch stays on the smooth path.
The smooth animation is the whole feel — Compose Desktop does NOT fling on the wheel (its
The smooth animation is the whole feel - Compose Desktop does NOT fling on the wheel (its
default FlingBehavior is a ScrollableDefaultFlingBehavior, so `shouldBeTriggeredByMouseWheel`
is false). We match that: no synthetic wheel fling. */
@@ -50,7 +50,7 @@ private object Sdl3ScrollConfig : ScrollConfig {
override fun Density.calculateMouseWheelScroll(event: PointerEvent, bounds: IntSize): Offset {
val vTotal = event.changes.fastFold(Offset.Zero) { acc, c -> acc + c.scrollDelta }
// WindowsWinUIConfig formula: viewport-proportional * scrollAmount (sign kept as SDL
// delivers it — the scrollDelta arriving here is already SDL-oriented, see
// delivers it - the scrollDelta arriving here is already SDL-oriented, see
// feedScrollToProcessor).
return Offset(
vTotal.x * (bounds.width / 20f) * kWheelLinesPerNotch,
@@ -63,11 +63,11 @@ internal actual fun CompositionLocalConsumerModifierNode.platformScrollConfig():
// Fallback for AbstractScrollableNode.defaultFlingBehavior (used when the caller provides no
// flingBehavior). A ScrollableDefaultFlingBehavior so updateDensity keeps working AND so
// `shouldBeTriggeredByMouseWheel` stays false — no wheel fling, matching Compose Desktop.
// `shouldBeTriggeredByMouseWheel` stays false - no wheel fling, matching Compose Desktop.
internal actual fun platformScrollableDefaultFlingBehavior(): ScrollableDefaultFlingBehavior =
DefaultFlingBehavior(splineBasedDecay(Density(1f)))
// Natural platform fling decay — identical to Compose Desktop's Scrollable.desktop.kt: a plain
// Natural platform fling decay - identical to Compose Desktop's Scrollable.desktop.kt: a plain
// DefaultFlingBehavior (spline decay). It IS a ScrollableDefaultFlingBehavior, so touch drags
// fling but the mouse wheel does not (its smooth tween handles the wheel feel).
@Composable
@@ -5,7 +5,7 @@ import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.text.AnnotatedString
// Native actuals for vendored commonMain `ClipboardUtils.kt`. Mirror the
// macOS / iOS / wasm actuals — all plain-text-only since our ClipEntry
// macOS / iOS / wasm actuals - all plain-text-only since our ClipEntry
// carries a single optional `plainText` field. The SDL3 clipboard
// (PlatformClipboard.native.kt) provides full read+write support on
// every host platform so `isReadSupported` / `isWriteSupported` are
@@ -2,6 +2,6 @@ package androidx.compose.foundation.lazy
import androidx.compose.runtime.Composable
// Native actual for the vendored LazyList.kt expect — matches every non-iOS platform (desktop /
// Native actual for the vendored LazyList.kt expect - matches every non-iOS platform (desktop /
// android / web all return 0): no extra beyond-bounds items composed past the visible window.
@Composable internal actual fun defaultLazyListBeyondBoundsItemCount(): Int = 0
@@ -6,11 +6,11 @@ import androidx.compose.foundation.text.selection.TextFieldSelectionManager
import androidx.compose.runtime.Composable
// ==================
// MARK: ContextMenu — native actuals
// MARK: ContextMenu - native actuals
// ==================
/**
Mirrors upstream macosMain / desktopMain / iosMain — all three overloads
Mirrors upstream macosMain / desktopMain / iosMain - all three overloads
delegate to CommonContextMenuArea (vendored). This unblocks vendored
BasicTextField / SelectionContainer / CoreTextField call sites.
*/
@@ -16,11 +16,11 @@ import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TextFieldValue
// ==================
// MARK: CoreTextField / TextFieldKeyInput / TextFieldPointerModifier — native actuals
// MARK: CoreTextField / TextFieldKeyInput / TextFieldPointerModifier - native actuals
// ==================
/**
Byte-identical mirror of upstream macosMain — all three CoreTextField
Byte-identical mirror of upstream macosMain - all three CoreTextField
expects delegate to default helpers baked into CoreTextField.kt /
TextFieldPointerModifier.common.kt. `isTypedEvent` uses the desktop-shape
filter (not ISO control, not AppKit reserved, not Meta/Ctrl).
@@ -5,7 +5,7 @@ import kotlin.native.OsFamily
import kotlin.native.Platform
// ==================
// MARK: platformDefaultKeyMapping — native actual
// MARK: platformDefaultKeyMapping - native actual
// ==================
/**
@@ -13,14 +13,14 @@ import kotlin.native.Platform
platform (macos: createMacOsDefaultKeyMapping, ios: create.../etc). We use
Kotlin/Native's `Platform.osFamily` to pick at startup:
- MACOSX → `createMacOsDefaultKeyMapping()` — Cmd-based bindings (Cmd+C /
- MACOSX → `createMacOsDefaultKeyMapping()` - Cmd-based bindings (Cmd+C /
Cmd+V / Cmd+Z / Cmd+A), Cmd+Home/End for document, Alt-based word/paragraph
motion, Cmd+Backspace = delete to line start, Alt+Backspace = delete word.
- anything else → `DefaultSkikoKeyMapping` — Ctrl-based bindings (Ctrl+C /
- anything else → `DefaultSkikoKeyMapping` - Ctrl-based bindings (Ctrl+C /
Ctrl+V / etc.), matching Windows/Linux desktop conventions.
Platform.osFamily is compile-time-known per target (Kotlin/Native emits a
per-target executable), so this is effectively a per-binary choice — no
per-target executable), so this is effectively a per-binary choice - no
runtime SDL_GetPlatform() branch needed.
*/
@OptIn(ExperimentalNativeApi::class)
@@ -12,7 +12,7 @@ package androidx.compose.foundation.text
findPrecedingBreak / findFollowingBreak / findCodePointOrEmojiStartBefore
walk on code-point boundaries (skip low surrogate pairs). No ICU grapheme-
cluster support yet — combining marks / emoji ZWJ sequences will land at
cluster support yet - combining marks / emoji ZWJ sequences will land at
codepoint boundaries. Good enough for basic caret navigation pending a
proper break iterator.
*/
@@ -7,11 +7,11 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
// ==================
// MARK: TextFieldScroll — native actuals
// MARK: TextFieldScroll - native actuals
// ==================
/**
Byte-identical mirror of upstream desktopMain — no overscroll on desktop,
Byte-identical mirror of upstream desktopMain - no overscroll on desktop,
textFieldScroll delegates to `defaultTextFieldScroll` (a private helper in
the vendored `TextFieldScroll.kt`).
*/
@@ -1,7 +1,7 @@
package androidx.compose.foundation.text.input.internal
// ==================
// MARK: CodepointHelpers — native actuals
// MARK: CodepointHelpers - native actuals
// ==================
internal actual fun CharSequence.codePointAt(index: Int): Int {
@@ -7,11 +7,11 @@ import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
// ==================
// MARK: TextFieldCoreModifier — native actuals
// MARK: TextFieldCoreModifier - native actuals
// ==================
/**
Byte-identical mirror of upstream macosMain — both delegate to the default
Byte-identical mirror of upstream macosMain - both delegate to the default
selection-highlight and cursor draw helpers baked into the vendored
TextFieldCoreModifier.kt.
*/
@@ -1,11 +1,11 @@
package androidx.compose.foundation.text.input.internal
// ==================
// MARK: createTextFieldKeyEventHandler — native actual
// MARK: createTextFieldKeyEventHandler - native actual
// ==================
/**
Mirrors upstream macosMain / desktopMain / iosMain / webMain — all
Mirrors upstream macosMain / desktopMain / iosMain / webMain - all
delegate to `createSkikoTextFieldKeyEventHandler` from the already-vendored
`TextFieldKeyEventHandler.skiko.kt`.
*/
@@ -4,11 +4,11 @@ import androidx.compose.foundation.text.input.internal.TextLayoutState
import androidx.compose.foundation.text.input.internal.TransformedTextFieldState
// ==================
// MARK: TextFieldMagnifierNode — native actual (no-op)
// MARK: TextFieldMagnifierNode - native actual (no-op)
// ==================
/**
Mirrors upstream macosMain / desktopMain — no magnifier on desktop.
Mirrors upstream macosMain / desktopMain - no magnifier on desktop.
*/
internal actual fun textFieldMagnifierNode(
textFieldState: TransformedTextFieldState,
@@ -9,17 +9,17 @@ import androidx.compose.ui.platform.Clipboard
import kotlinx.coroutines.CoroutineScope
// ==================
// MARK: TextFieldSelectionState — native actuals
// MARK: TextFieldSelectionState - native actuals
// ==================
/**
Byte-close mirror of upstream macosMain — desktop tap / selection gestures
Byte-close mirror of upstream macosMain - desktop tap / selection gestures
delegate to the default helpers baked into TextFieldSelectionState.
addBasicTextFieldTextContextMenuComponents is a NO-OP by design: it belongs to
the NEW text-context-menu API (ComposeFoundationFlags.isNewContextMenuEnabled),
which is false on native. The working right-click Cut/Copy/Paste/SelectAll menu
goes through the vendored LEGACY CommonContextMenuArea path instead.
ClipboardPasteState uses our project Clipboard's plaintext presence — no
ClipboardPasteState uses our project Clipboard's plaintext presence - no
NSPasteboard access on native.
*/
@@ -5,11 +5,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LayoutCoordinates
// ==================
// MARK: makeSelectionModifier — native actual
// MARK: makeSelectionModifier - native actual
// ==================
/**
Mirrors upstream desktopMain / macosMain / iosMain actuals — all just
Mirrors upstream desktopMain / macosMain / iosMain actuals - all just
delegate to `makeDefaultSelectionModifier`. That helper lives inside
`SelectionController.kt` (vendored) and installs a SelectionModifierNode
wiring gestures + hover pointer icon + BringIntoViewRequester through
@@ -11,13 +11,13 @@ import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.DpSize
// ==================
// MARK: Selection engine — native actuals
// MARK: Selection engine - native actuals
// ==================
/*
Actuals for the `expect` declarations upstream distributes across
macosMain / iosMain / webMain / desktopMain / androidMain (never
nativeMain/skikoMain — those source sets don't carry these). Our flat
nativeMain/skikoMain - those source sets don't carry these). Our flat
nativeMain covers the whole cross-platform native surface, so we
hand-write them here.
@@ -27,13 +27,13 @@ import androidx.compose.ui.unit.DpSize
*/
/** Cmd on macOS, Ctrl elsewhere. We don't know which platform at compile
time — accept both, matches Ctrl+C on Windows/Linux and Cmd+C on macOS.
time - accept both, matches Ctrl+C on Windows/Linux and Cmd+C on macOS.
Also honours the dedicated Copy key. */
internal actual fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean =
keyEvent.key == Key.Copy ||
(keyEvent.key == Key.C && (keyEvent.isMetaPressed || keyEvent.isCtrlPressed))
/** No selection magnifier on desktop — mobile-only affordance. */
/** No selection magnifier on desktop - mobile-only affordance. */
internal actual fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier = this
/** NO-OP by design: this seam belongs to the NEW text-context-menu API
@@ -49,7 +49,7 @@ internal actual fun Modifier.addSelectionContainerTextContextMenuComponents(
internal actual val FirstLongPressSelectionAdjustment: SelectionAdjustment
get() = SelectionAdjustment.Word
/** Mobile-style draggable selection handle. Desktop never shows one —
/** Mobile-style draggable selection handle. Desktop never shows one -
mouse drag directly extends the selection, no handle bubble.
TODO: If touch input lands (SDL_EVENT_FINGER_*), route to a real
handle Composable driven by SelectionHandleInfo. */
@@ -63,5 +63,5 @@ internal actual fun SelectionHandle(
lineHeight: Float,
modifier: Modifier,
) {
// no-op — no handle rendered
// no-op - no handle rendered
}
@@ -4,11 +4,11 @@ import androidx.compose.ui.Modifier
import kotlinx.coroutines.CoroutineScope
// ==================
// MARK: TextFieldSelectionManager — native actuals
// MARK: TextFieldSelectionManager - native actuals
// ==================
/**
Byte-identical mirror of upstream macosMain — magnification and toolbar
Byte-identical mirror of upstream macosMain - magnification and toolbar
context-menu components are desktop no-ops. `isSelectionHandleInVisibleBound`
delegates to the default-inside-visible-rect check (defined in the vendored
TextFieldSelectionManager.kt).
@@ -503,7 +503,7 @@ internal class SliderAdapter(
// Because k/js and k/wasm don't have runBlocking. This port targets only
// native desktop (macos/linux/mingw), which all have runBlocking, so the
// upstream expect/actual is inlined here — our flat native source set can't
// upstream expect/actual is inlined here - our flat native source set can't
// hold the expect (in this .skiko.kt) and its actual in the same set.
internal fun runBlockingIfPossible(block: suspend CoroutineScope.() -> Unit) =
kotlinx.coroutines.runBlocking(block = block)
@@ -11,14 +11,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
// ==================
// MARK: IconFontIcon — codepoint-based Icon rendered via a text glyph
// MARK: IconFontIcon - codepoint-based Icon rendered via a text glyph
// ==================
/** Renders a single Unicode codepoint from a named IconFont (Material Symbols /
any glyph font registered via IconFont.registerIcon) at the given size.
Lived in `androidx.compose.material.Icon` until :material was retired. Moved
here (project-specific package) because it's a project-only extension —
here (project-specific package) because it's a project-only extension -
upstream Compose Icon takes only Painter / ImageVector, never a codepoint,
and the icon-font pipeline is a project stand-in for icon-font Compose
Multiplatform never shipped.
@@ -49,7 +49,7 @@ fun IconFontIcon(
object IconDefaults {
val DefaultIconSize: Dp = 24.dp
/** STATIC fallback tint for direct IconFontIcon use — :foundation cannot
/** STATIC fallback tint for direct IconFontIcon use - :foundation cannot
read material3's LocalContentColor (module layering, same as upstream).
The themed default lives one layer up: MaterialSymbols<Style> defaults
its tint to material3's LocalContentColor.current, mirroring upstream
@@ -1,9 +1,9 @@
// :material-ripple — androidx.compose.material.ripple, vendored VERBATIM from
// :material-ripple - androidx.compose.material.ripple, vendored VERBATIM from
// upstream into src/vendor/. Split out of :material3 to mirror Compose
// Multiplatform's module packaging.
//
// Provenance = material-ripple/compose-fork.txt + scripts/compose-fork/compose.properties.
// Never hand-edit files under src/vendor/ — change the manifest and re-run
// Never hand-edit files under src/vendor/ - change the manifest and re-run
// `python scripts/compose-fork/sync.py compose/material/material-ripple`.
//
// Publication artifactId: desktop-material-ripple.
+4 -4
View File
@@ -1,9 +1,9 @@
// :material3 — Material 3 widgets vendored VERBATIM from upstream
// :material3 - Material 3 widgets vendored VERBATIM from upstream
// androidx.compose.material3, into src/vendor/. Objective: eventually retire
// the hand-written :material module in favour of this one.
//
// Provenance = material3/compose-fork.txt + scripts/compose-fork/compose.properties.
// Never hand-edit files under material3/src/vendor/ — change the manifest and
// Never hand-edit files under material3/src/vendor/ - change the manifest and
// re-run `bash scripts/compose-fork/sync.sh :material3`.
//
// Publication artifactId (when set up): compose-desktop-native-material3.
@@ -37,10 +37,10 @@ kotlin {
// androidx.collection is used by material3 internals (MutableIntObjectMap, …).
// Already on the classpath via :ui's runtime; declare here for clarity.
implementation("androidx.collection:collection:1.5.0")
// androidx.graphics.shapes.* — MaterialShapes (RoundedPolygon /
// androidx.graphics.shapes.* - MaterialShapes (RoundedPolygon /
// Morph / CornerRounding) that WavyProgress / LoadingIndicator use.
implementation("androidx.graphics:graphics-shapes:1.1.0")
// kotlinx.datetime — DatePicker / TimePicker / CalendarModel.
// kotlinx.datetime - DatePicker / TimePicker / CalendarModel.
implementation(libs.kotlinx.datetime)
}
kotlin.srcDir("src/vendor/common/kotlin")
@@ -4,13 +4,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
// ==================
// MARK: CalendarLocale — native project actuals
// MARK: CalendarLocale - native project actuals
// ==================
/**
Upstream's darwinMain / desktopMain provide these actuals from platform locale
APIs (NSLocale on darwin, java.util.Locale on JVM). Our K/N native target has
neither, so we ship a locale-agnostic stub — a single implicit "current" locale
neither, so we ship a locale-agnostic stub - a single implicit "current" locale
the DatePicker / TimePicker use for formatting. All weekdays / month names
rendered by material3 use its own l10n tables (vendored under
material3.internal.l10n) keyed by this stub's identity, which resolves to the
@@ -8,7 +8,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
// ==================
// MARK: PlatformDateFormat — native project actual
// MARK: PlatformDateFormat - native project actual
// ==================
/**
@@ -18,7 +18,7 @@ import kotlinx.datetime.toLocalDateTime
CLDR-style pattern / skeleton (so DatePicker/TimePicker headlines read
"Jul 29, 2026" / "July 2026" instead of a raw ISO date). Field VALUES are
localized by the requested TimeZone.UTC calendar; field NAMES (month / weekday)
stay English — full CLDR name localization needs ICU data we don't bundle.
stay English - full CLDR name localization needs ICU data we don't bundle.
*/
@OptIn(ExperimentalTime::class)
internal actual class PlatformDateFormat actual constructor(
+1 -1
View File
@@ -1,4 +1,4 @@
// :ui-backhandler — androidx.compose.ui.backhandler.*, vendored from upstream (split of :ui, CMP layout).
// :ui-backhandler - androidx.compose.ui.backhandler.*, vendored from upstream (split of :ui, CMP layout).
// Publication artifactId: desktop-ui-backhandler.
plugins {
alias(libs.plugins.kotlin.multiplatform)
+1 -1
View File
@@ -1,4 +1,4 @@
// :ui-geometry — androidx.compose.ui.geometry.*, vendored from upstream (split of :ui, CMP layout).
// :ui-geometry - androidx.compose.ui.geometry.*, vendored from upstream (split of :ui, CMP layout).
// Publication artifactId: desktop-ui-geometry.
plugins {
alias(libs.plugins.kotlin.multiplatform)
+1 -1
View File
@@ -12,5 +12,5 @@ commonMain/kotlin/ -> src/vendor/common/kotlin/
# | commonMain/kotlin/androidx/compose/ui/geometry/Size.kt -> src/vendor/common/kotlin/androidx/compose/ui/geometry/Size.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
# (none — every source set under SET_FOLDER is vendored)
# (none - every source set under SET_FOLDER is vendored)
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
+2 -2
View File
@@ -1,6 +1,6 @@
// :ui-graphics — androidx.compose.ui.graphics.* split out of :ui (upstream layout).
// :ui-graphics - androidx.compose.ui.graphics.* split out of :ui (upstream layout).
// Canvas / Paint / Path / Brush / Color / ImageBitmap / Shader / GraphicsLayer +
// the Skia actuals (SkiaBackedCanvas, SkiaImageCache, …). SDL-free — image bytes
// the Skia actuals (SkiaBackedCanvas, SkiaImageCache, …). SDL-free - image bytes
// come through the composeResourceReader seam, so it depends only on skiko +
// the low ui primitives, NOT on :sdl-core.
+3 -3
View File
@@ -107,13 +107,13 @@ 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
# B6.1 (2026-07-16): un-refused — skiko leg adopts upstream graphics actuals.
# 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
# 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
# B6.1: MANUAL-VENDORED (flattened toBitmap/putBytesInto expect-actuals — see the file).
# B6.1: MANUAL-VENDORED (flattened toBitmap/putBytesInto expect-actuals - see the file).
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaImageAsset.skiko.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<

Some files were not shown because too many files have changed in this diff Show More