mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-07 19:57:27 +00:00
Compare commits
60
Commits
v0.3.1
...
164d2b5e66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164d2b5e66 | ||
|
|
b8f7595a5f | ||
|
|
cdce0575e3 | ||
|
|
623266d467 | ||
|
|
52817af282 | ||
|
|
749a405a03 | ||
|
|
675bc99b23 | ||
|
|
eae87b98ba | ||
|
|
982dc50d2a | ||
|
|
5ec5f4cb9a | ||
|
|
3c759476ac | ||
|
|
7ee2e4a09f | ||
|
|
2b49ececa7 | ||
|
|
f16ceea0da | ||
|
|
7f332ead0a | ||
|
|
d022462cad | ||
|
|
9637215931 | ||
|
|
66653f903e | ||
|
|
88efdba9f7 | ||
|
|
487e151ac8 | ||
|
|
e68de8794f | ||
|
|
c8aefebbf1 | ||
|
|
cc04c82042 | ||
|
|
416ae3a03e | ||
|
|
9e97b4357f | ||
|
|
8c5a795055 | ||
|
|
9a8540499d | ||
|
|
68ea57204c | ||
|
|
e3f3d64d0b | ||
|
|
229a39b38d | ||
|
|
57cf99608b | ||
|
|
05ec6d5b40 | ||
|
|
e2d125dfea | ||
|
|
50b31641ed | ||
|
|
71738dafb7 | ||
|
|
739d6e4852 | ||
|
|
c828888590 | ||
|
|
9b165a07d2 | ||
|
|
971047b799 | ||
|
|
1e8ccdbeee | ||
|
|
8f53880854 | ||
|
|
82d2e9b16f | ||
|
|
27d1dc1637 | ||
|
|
b16f456aaf | ||
|
|
bdb5c64df3 | ||
|
|
5dc78f47b5 | ||
|
|
862da85de0 | ||
|
|
25689c1f17 | ||
|
|
275ee97d56 | ||
|
|
e5fb04f882 | ||
|
|
23849330d1 | ||
|
|
4272cec614 | ||
|
|
3f4b3a23be | ||
|
|
d058f1b6a2 | ||
|
|
924ebe9776 | ||
|
|
5dc4b21867 | ||
|
|
accd74c9ee | ||
|
|
30cc39606a | ||
|
|
099211b015 | ||
|
|
37a44ffb46 |
@@ -7,6 +7,9 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# Manual fallback: run with --ref <tag> so github.ref_name is the tag itself
|
||||
# (PUBLISH_VERSION is derived from it).
|
||||
workflow_dispatch:
|
||||
|
||||
# Never overlap two publish runs for the same tag.
|
||||
concurrency:
|
||||
@@ -20,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.
|
||||
@@ -46,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.
|
||||
@@ -99,20 +102,17 @@ 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 native deps from scripts/build-sdl/build-sdl.properties.
|
||||
# Only Windows ships the SDL3 renderer (no Skiko on mingw), so only it needs
|
||||
# SDL3_ttf / SDL3_image / FreeType (SDL3_ttf text, SDL3_image decode, FreeType
|
||||
# icon-axis rasterisation). macOS / Linux publish the Skia renderer — Skiko
|
||||
# handles text / images / icons — so they only need base SDL3 for the main
|
||||
# loop (windowing / input / audio / filesystem). Matches the cinterop guard
|
||||
# in compose/ui/ui/build.gradle.kts, which skips those three cinterops on the
|
||||
# Skia targets. Cached against the manifest + scripts so version bumps
|
||||
# invalidate cleanly; bump the `v3` key when the toolchain (runner OS,
|
||||
# compiler flavour, K/N sysroot) changes without a scripts/build-sdl/ edit.
|
||||
# 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
|
||||
# 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
|
||||
# sysroot) changes without a scripts/build-sdl/ edit.
|
||||
- name: Cache libs/
|
||||
id: cache-libs
|
||||
uses: actions/cache@v4
|
||||
@@ -120,18 +120,12 @@ jobs:
|
||||
path: libs
|
||||
key: sdl-libs-v3-${{ matrix.host }}-${{ hashFiles('scripts/build-sdl/**') }}
|
||||
|
||||
# Windows: full SDL3 renderer stack. macOS / Linux (Skia): base SDL3 only.
|
||||
- name: Build static native deps
|
||||
if: steps.cache-libs.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||
python3 scripts/build-sdl/build-all.py
|
||||
else
|
||||
python3 scripts/build-sdl/build-all.py sdl3
|
||||
fi
|
||||
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.
|
||||
@@ -144,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,16 +154,35 @@ jobs:
|
||||
VER="${PUBLISH_VERSION#v}"
|
||||
OWNER="${GITHUB_REPOSITORY%%/*}"
|
||||
# Keep in sync with the module list in settings.gradle.kts.
|
||||
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
|
||||
MODULES="sdl-core ui ui-graphics ui-text ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple desktop-native-window material-symbols navigation3-ui components-resources"
|
||||
case "${{ matrix.host }}" in
|
||||
macos) SUFFIXES="-macosarm64" ;;
|
||||
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
|
||||
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
|
||||
esac
|
||||
# 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>".
|
||||
group_of() {
|
||||
case "$1" in
|
||||
ui|ui-graphics|ui-text|ui-util|ui-geometry|ui-unit|ui-backhandler|ui-tooling-preview)
|
||||
echo "com.bitsycore.compose.ui" ;;
|
||||
foundation|foundation-layout) echo "com.bitsycore.compose.foundation" ;;
|
||||
animation|animation-core|animation-graphics) echo "com.bitsycore.compose.animation" ;;
|
||||
material3) echo "com.bitsycore.compose.material3" ;;
|
||||
material-ripple) echo "com.bitsycore.compose.material" ;;
|
||||
components-resources) echo "com.bitsycore.compose.components" ;;
|
||||
navigation3-ui) echo "com.bitsycore.navigation3" ;;
|
||||
desktop-native-window) echo "com.bitsycore.compose" ;;
|
||||
*) echo "com.bitsycore.compose.sdl" ;;
|
||||
esac
|
||||
}
|
||||
host_packages() {
|
||||
for m in $MODULES; do
|
||||
g=$(group_of "$m")
|
||||
for s in $SUFFIXES; do
|
||||
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
|
||||
if [ "$s" = "_ROOT_" ]; then echo "$g.$m"; else echo "$g.$m$s"; fi
|
||||
done
|
||||
done
|
||||
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||
@@ -183,13 +196,13 @@ jobs:
|
||||
host_packages | while read -r pkg; do
|
||||
vid=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions?per_page=100" \
|
||||
| jq -r --arg v "$VER" '[.[]? | select(.name==$v) | .id] | first // empty')
|
||||
| jq -r --arg v "$VER" 'if type=="array" then ([.[] | select(.name==$v) | .id] | first // empty) else empty end')
|
||||
[ -z "$vid" ] && continue
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-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" \
|
||||
@@ -206,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
|
||||
@@ -214,12 +227,39 @@ jobs:
|
||||
echo "::error::Publish failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
# Release binaries for the host target — the binary plus the data.kres
|
||||
# Second target: the self-hosted Reposilite instance. Same publications,
|
||||
# task names derived from the matrix by swapping the repository name.
|
||||
# The releases repo has redeployment enabled, so a plain retry after a
|
||||
# mid-upload failure is safe (no GitHub-Packages-style 409 poisoning).
|
||||
- name: Publish to maven.bitsycore.com
|
||||
shell: bash
|
||||
env:
|
||||
BITSYCORE_MAVEN_USER: ${{ secrets.BITSYCORE_MAVEN_USER }}
|
||||
BITSYCORE_MAVEN_TOKEN: ${{ secrets.BITSYCORE_MAVEN_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
BITSY_TASKS=$(echo "${{ matrix.tasks }}" | sed 's/GitHubPackagesRepository/BitsycoreRepository/g')
|
||||
for attempt in 1 2 3; do
|
||||
echo "::group::Bitsycore publish attempt $attempt"
|
||||
if ./gradlew $BITSY_TASKS --continue --no-daemon --stacktrace; then
|
||||
echo "::endgroup::"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ $attempt -lt 3 ]; then
|
||||
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
|
||||
# (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
|
||||
@@ -234,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}"
|
||||
@@ -242,8 +282,21 @@ jobs:
|
||||
for app in demo apidemo; do
|
||||
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
|
||||
# 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
|
||||
for sidecar in skiko-windows-x64.dll icudtl.dat; do
|
||||
if [ ! -f "$src/$sidecar" ]; then
|
||||
echo "::error::Missing Windows runtime sidecar: $src/$sidecar"; exit 1
|
||||
fi
|
||||
files+=("$src/$sidecar")
|
||||
done
|
||||
fi
|
||||
python3 -c 'import sys,os,zipfile; z=zipfile.ZipFile(sys.argv[1],"w",zipfile.ZIP_DEFLATED,compresslevel=9); [z.write(f,os.path.basename(f)) for f in sys.argv[2:]]; z.close()' \
|
||||
"$zip_path" "$src/$app.${{ matrix.exe_ext }}" "$src/data.kres"
|
||||
"$zip_path" "${files[@]}"
|
||||
done
|
||||
ls -la dist/
|
||||
|
||||
|
||||
+1
-1
@@ -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/
|
||||
|
||||
@@ -1,53 +1,64 @@
|
||||
# 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.md](RENDERER.md) — the rendering layer: architecture, the retained-
|
||||
layer engine, convergence decisions, learnings, and open work. Read it before
|
||||
touching any renderer, graphics-actual, or layer-engine code.
|
||||
- [TOOLING.md](TOOLING.md) — build/vendor/verify scripts and workflows
|
||||
- Renderer - one Skia `RenderBackend` (Metal on macOS / OpenGL on Linux+Windows /
|
||||
CPU-raster fallback) driving upstream's vendored GraphicsLayer + Canvas engine.
|
||||
Retained per-node display lists: transform / alpha / clip changes REPLAY without
|
||||
re-recording (only a content change or resize re-records; dirty-region rendering
|
||||
is a non-goal). Text is a reduced-local port over Skia `skparagraph`. Read these
|
||||
before touching renderer / graphics-actual / layer-engine code:
|
||||
`SkiaRenderBackend.kt`, `RenderBackend.kt`, `GpuMode.kt`, `ComposeRootHost.kt`
|
||||
(root host + snapshot-observation sweep - omitting `clearInvalidObservations()`
|
||||
once leaked the whole graph), `ComposeOwner.kt`, and `SkiaParagraph.native.kt` /
|
||||
`SkiaParagraphEngine.kt`. Open renderer/fidelity work lives in [PLAN.md](PLAN.md).
|
||||
- [TOOLING.md](TOOLING.md) - build/vendor/verify scripts and workflows
|
||||
(build-sdl, sync + drift checks, parity, probe, profiler, coverage,
|
||||
verify-mac) + the version map and the ref-bump / release runbooks.
|
||||
- [TODO.md](TODO.md) — audited list of no-ops, stubs, and hardcodes left in the
|
||||
port, with severity, plus what's missing for a stable 1.12 release.
|
||||
- This file — architecture, module layout, vendoring rules, source-set
|
||||
- [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
|
||||
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
|
||||
`org.jetbrains.skiko:skiko:0.150.1-mingw.1` (override with
|
||||
`com.bitsycore.skiko:skiko:0.150.1-mingw.1` (override with
|
||||
`-PskikoMingwVersion`). The runtime DLL is auto-provisioned next to the exe by
|
||||
the bridge plugin (`installWindowsSkiaDll`). See SKIKO-MINGW-FEASIBILITY.md.
|
||||
the bridge plugin (`installWindowsSkiaDll`).
|
||||
|
||||
Windowing, input, audio, filesystem access, and the OS-integration surface
|
||||
(file dialogs, clipboard, "open in Finder/Explorer"…) all go through
|
||||
**SDL3**. The runtime (`androidx.compose.runtime.*`: composition, snapshots,
|
||||
recomposer, `mutableStateOf`, `remember`, …) is the **official
|
||||
`org.jetbrains.compose.runtime` klibs from Maven** — this project only
|
||||
`org.jetbrains.compose.runtime` klibs from Maven** - this project only
|
||||
re-implements the layers on top (`androidx.compose.ui.*`, `.foundation.*`,
|
||||
`.animation.*`, `.material3.*`).
|
||||
|
||||
## Module layout
|
||||
|
||||
Library modules mirror upstream Compose Multiplatform's `compose/` tree.
|
||||
Only `:window` is the SDL integration layer ("Compose SDL" — SDL3 main loop)
|
||||
and lives under `compose/sdl/`.
|
||||
The SDL layer is two modules: `:sdl-core` (the NAKED sdl3 cinterop + platform
|
||||
primitives - zero Compose dep, like `skiko`) at `sdl/sdl-core/`, and
|
||||
`:desktop-native-window` (the SDL3 main-loop shell + app entry point) at
|
||||
`compose/desktop/native/window/`. `:ui` depends on `:sdl-core` and its renderer
|
||||
+ SDL↔Compose bridges pick the cinterop from it.
|
||||
|
||||
One Gradle module per upstream artifact; the directory mirrors the upstream
|
||||
`compose/` path, the gradle path is kept short (redirected via `projectDir`).
|
||||
@@ -55,42 +66,56 @@ One Gradle module per upstream artifact; the directory mirrors the upstream
|
||||
```
|
||||
compose/
|
||||
├── ui/
|
||||
│ ├── ui/ → :ui — androidx.compose.ui.* (ui + ui-graphics + ui-text) +
|
||||
│ │ com.compose.sdl.* — the sdl3 cinterop + the Skia renderer
|
||||
│ │ pipeline live here. ui-graphics/ui-text can't split off: their
|
||||
│ │ Canvas / Paragraph `expect`s are the renderer's `actual`s (same-module).
|
||||
│ ├── 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/ → :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
|
||||
│ │ (SkiaBackedCanvas/Path/Paint, GraphicsLayer, SkiaImageCache,
|
||||
│ │ painter/image + resource seams). → skiko; SDL-free.
|
||||
│ ├── 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.*
|
||||
│ (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.*
|
||||
└── sdl/ ("Compose SDL" — project modules, not upstream CMP artifacts)
|
||||
└── window/ → :window — nativeComposeApp { Window(...) {} } multi-window
|
||||
shell + SDL3 main loop; nativeComposeWindow() wrapper
|
||||
│ └── 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
|
||||
(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
|
||||
@@ -102,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
|
||||
@@ -112,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
|
||||
@@ -127,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
|
||||
@@ -154,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
|
||||
@@ -181,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`, `: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
|
||||
@@ -193,47 +218,51 @@ androidx KMP libs.
|
||||
:ui ← :animation-core ← :animation ← :foundation ← :material3 ← :demo, :apidemo
|
||||
:ui ← :foundation-layout ←──────────────────────┘ ↑ ↑
|
||||
:material-ripple ←────────────┘──────────────┘
|
||||
:foundation, :animation-core ← :window ; :foundation, :material3 ← :material-symbols
|
||||
:foundation, :animation-core ← :desktop-native-window ; :foundation, :material3 ← :material-symbols
|
||||
```
|
||||
|
||||
All edges are `api`, so a consumer of `:foundation` / `:material3` transitively
|
||||
sees the split modules. Full DAG: `:ui-util → collection`; `:ui-geometry → :ui-util`;
|
||||
`:ui-unit → :ui-geometry, :ui-util`; `:ui-backhandler → :ui-util, navigationevent`;
|
||||
`:ui → :ui-util, :ui-geometry, :ui-unit, :ui-backhandler`; `:animation-core → :ui`;
|
||||
`:sdl-core → sdl3 cinterop (NAKED - no Compose)`;
|
||||
`:ui-graphics → :ui-geometry, :ui-unit, :ui-util, skiko`;
|
||||
`:ui-text → :ui-graphics, :ui-unit, :ui-util, skiko`;
|
||||
`:ui → :ui-graphics, :ui-text, :sdl-core, :ui-util, :ui-geometry, :ui-unit, :ui-backhandler`; `:animation-core → :ui`;
|
||||
`:foundation-layout → :ui`; `:animation → :animation-core, :foundation-layout`;
|
||||
`:foundation → :animation, :foundation-layout, :animation-core, :ui`;
|
||||
`:material-ripple → :foundation, :animation-core`;
|
||||
`:material3 → :foundation, :material-ripple, :animation-core, :foundation-layout`.
|
||||
|
||||
`:ui` is the renderer/cinterop hub (the ui-graphics + ui-text `expect`s resolve to
|
||||
its Skia renderer `actual`s). The pure lower artifacts (`:ui-util`,
|
||||
`:ui-geometry`, `:ui-unit`, `:ui-backhandler`) are split out below it. Everything
|
||||
above `:ui` can only touch renderer / cinterop internals via its public surface.
|
||||
`:window` depends on `:ui` + `:foundation` (needs `LazyList`-style scaffolding to
|
||||
`:ui` is the Compose core + the Skia RenderBackend + the SDL↔Compose bridges; it
|
||||
sits on `:ui-graphics` / `:ui-text` (the graphics/text primitives + their skiko
|
||||
`actual`s, SDL-free) and the naked `:sdl-core` (sdl3 cinterop). The pure lower
|
||||
artifacts (`:ui-util`, `:ui-geometry`, `:ui-unit`, `:ui-backhandler`) are below.
|
||||
Everything above `:ui` touches renderer internals only via its public surface.
|
||||
`:desktop-native-window` depends on `:ui` + `:foundation` (needs `LazyList`-style scaffolding to
|
||||
install the popup / scaffold layer at the composition root).
|
||||
|
||||
## Vendoring philosophy — read this before writing any `androidx.compose.*` code
|
||||
## 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
|
||||
@@ -242,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
|
||||
@@ -270,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
|
||||
@@ -286,46 +315,49 @@ 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 `:ui/src/skikoRendererMain/`.** When upstream
|
||||
ships a `.skiko.kt` file that uses Skiko's Canvas / Paragraph / …, the
|
||||
`.skiko.kt` variant is fine to vendor into `skikoRendererMain` (the Skia
|
||||
drawing pipeline — `SkiaCanvas.kt`, `SkiaTextRenderer.kt`,
|
||||
`SkiaImageCache.kt`, …). All native targets attach this source set;
|
||||
4. **Skiko-specific things go in a `skikoRendererMain` source set.** When
|
||||
upstream ships a `.skiko.kt` file that uses Skiko's Canvas / Paragraph / …,
|
||||
the `.skiko.kt` variant is fine to vendor into `skikoRendererMain` - graphics
|
||||
into `:ui-graphics` (`SkiaBackedCanvas.skiko.kt`, `SkiaImageCache.kt`, …),
|
||||
text into `:ui-text` (`SkiaParagraphEngine.kt`), the render backend into
|
||||
`:ui` (`SkiaRenderBackend.kt`). All native targets attach this source set;
|
||||
mingwX64 layers its fork actuals on top under `skikoRendererMingwMain`.
|
||||
|
||||
5. **Multi-OS project code goes through SDL3, not hand-rolled per-target
|
||||
ifdefs.** SDL3 already handles the platform differences for filesystem
|
||||
paths (`SDL_GetBasePath`, `SDL_GetPrefPath`), clipboard, cursor,
|
||||
fullscreen, high-DPI, subprocess launch, MessageBox, and more.
|
||||
`file dialog / "reveal in Finder or Explorer" / app-data path` — do
|
||||
`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)
|
||||
|
||||
`:ui` owns the `sdl3` cinterop + the Skia renderer pipeline. Its source-set tree:
|
||||
`:ui` owns the Skia RenderBackend + GPU bridges. The SAME source-set tree is used
|
||||
by `:ui-graphics` / `:ui-text` for their skiko `actual`s (only the deps differ);
|
||||
the `sdl3` cinterop lives in `:sdl-core` (see below). `:ui`'s tree:
|
||||
|
||||
```
|
||||
commonMain
|
||||
└── nativeMain (vendored .native.kt + project native code)
|
||||
├── skikoRendererMain (Skia drawing pipeline; official Skiko on classpath)
|
||||
│ ├── skikoRendererMacosMain (macOS-only Skia actuals — Metal bridge)
|
||||
│ └── skikoRendererLinuxMain (Linux-only Skia actuals — OpenGL)
|
||||
│ ├── 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.
|
||||
@@ -339,23 +371,26 @@ fails to come up.
|
||||
|
||||
### Cinterop
|
||||
|
||||
`:ui` owns ONE cinterop in `src/nativeInterop/cinterop/`: `sdl3` (SDL_Window /
|
||||
SDL_Event / SDL_GetBasePath / clipboard / dialogs / GL+Metal context /
|
||||
SDL_Renderer-for-CPU-raster). It has no siblings, so there's no
|
||||
`depends = <sibling>` propagation to worry about — cinterop `.def` files list
|
||||
each other under `depends =` and Gradle does NOT auto-add a sibling cinterop's
|
||||
klib to the `-library` list, but with a single cinterop that gotcha is moot.
|
||||
The `sdl3` cinterop lives in the NAKED `:sdl-core` module
|
||||
(`sdl/sdl-core/src/nativeInterop/cinterop/sdl3.def`: SDL_Window / SDL_Event /
|
||||
SDL_GetBasePath / clipboard / dialogs / GL+Metal context / SDL_Renderer-for-CPU-
|
||||
raster). `:ui` depends on `:sdl-core` (`api`), so `:ui`'s renderer + SDL↔Compose
|
||||
bridges - and `:desktop-native-window` downstream - see `sdl3.*` and inherit SDL3's static-lib +
|
||||
linker-opt propagation (the `.def` bakes in `staticLibraries = libSDL3.a` + the
|
||||
per-OS `linkerOpts`, carried through the klib chain to the final exe, so apps
|
||||
link SDL just by depending on `:ui`/`:desktop-native-window`). `:sdl-core` has NO Compose
|
||||
dependency, so `:ui → :sdl-core` is cycle-free - the compose way (like `ui → skiko`).
|
||||
|
||||
## Density flow (Option B — layout in physical pixels)
|
||||
## 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
|
||||
@@ -364,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
|
||||
@@ -378,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).
|
||||
# Links the bitsycore skiko FORK from GitHub Packages (org.jetbrains.skiko:skiko:
|
||||
# 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
|
||||
@@ -394,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
|
||||
|
||||
@@ -437,60 +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/sdl/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
|
||||
- `compose/ui/ui/src/nativeMain/…/ui/text/SdlParagraph.native.kt` — the
|
||||
bridged `Paragraph` implementation (measurement, hit-test, line metrics,
|
||||
span painting).
|
||||
- `compose/ui/ui/src/nativeMain/…/ui/text/ParagraphFactories.native.kt` —
|
||||
### Text (all in `:ui-text` now)
|
||||
- `compose/ui/ui-text/src/nativeMain/…/ui/text/SkiaParagraph.native.kt` - the
|
||||
`Paragraph` actual (skiko-free; the 30 interface methods over plain data),
|
||||
driving the skiko engine through the `NativeParagraphOps` seam.
|
||||
- `compose/ui/ui-text/src/skikoRendererMain/…/renderer/skia/SkiaParagraphEngine.kt`
|
||||
- `SkiaParagraphOps` (builds/queries the real skiko `skparagraph`) + `SkiaFonts`
|
||||
(data.kres bytes → skiko Typeface; the port's family/variable-axis model).
|
||||
- `compose/ui/ui-text/src/nativeMain/…/ui/text/ParagraphFactories.native.kt` -
|
||||
actuals for the `Paragraph(…)` / `ParagraphIntrinsics(…)` factory family.
|
||||
- `compose/ui/ui/src/commonMain/…/text/TextMeasurer.kt` — `NativeTextMeasurer`
|
||||
interface (implemented by `SkiaTextRenderer`).
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -507,13 +543,13 @@ profiler. Quick rules of thumb:
|
||||
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
|
||||
- Any renderer change → the **`verify-mac`** runbook gates it before commit.
|
||||
|
||||
See [RENDERER.md](RENDERER.md) for the renderer work these support.
|
||||
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:
|
||||
|
||||
@@ -530,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
|
||||
@@ -562,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`
|
||||
@@ -637,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).
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
# 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
|
||||
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
|
||||
skiko-fork divergences), **completing native-actual stubs**, and the release mechanics
|
||||
(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
|
||||
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:
|
||||
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
|
||||
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
|
||||
(§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
|
||||
`check-vendor-drift.py`.
|
||||
- [~] 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
|
||||
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)
|
||||
|
||||
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
|
||||
can't render `.notdef`. Platform-independent, length-preserving.
|
||||
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
|
||||
instead of throwing.
|
||||
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
|
||||
captured, hypothesis sharpened (fork likely drops lineHeight leading).
|
||||
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
|
||||
== 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
|
||||
+ 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
|
||||
Kotlin, **zero fork rebuild**.
|
||||
|
||||
**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
|
||||
refactor (§6)**, sequenced after these fixes land.
|
||||
|
||||
---
|
||||
|
||||
## ▶ 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
|
||||
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
|
||||
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
|
||||
expected in upstream Compose Desktop too, so "matches Windows-JVM" is the acceptance bar, not
|
||||
"matches macOS".
|
||||
|
||||
### Build / run (Windows shell, repo root)
|
||||
|
||||
```bat
|
||||
gradlew.bat :demo:runDebugExecutableMingwX64 :: native demo (the port under test)
|
||||
gradlew.bat :apidemo:runDebugExecutableMingwX64 :: native apidemo (has the Session/Override buttons)
|
||||
gradlew :demo:run :: JVM Compose Desktop = the FIDELITY REFERENCE
|
||||
gradlew :apidemo:run :: JVM apidemo reference
|
||||
```
|
||||
|
||||
- The bridge plugin auto-drops `skiko-windows-x64.dll` next to the exe (`installWindowsSkiaDll`).
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
too: the whole module has exactly 2 `.addText(` sites, both `shapedText`.)
|
||||
- **§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;
|
||||
(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
|
||||
(你好世界 日本語 한국어) + **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`).
|
||||
|
||||
Reference commands actually used (both probes print `metrics:` lines; native runs a real window):
|
||||
```bat
|
||||
demo\build\bin\mingwX64\debugExecutable\demo.exe --metricsprobe :: native/fork metrics
|
||||
gradlew :demo:run --args="--metrics" :: JVM/upstream metrics
|
||||
set CDN_TEXT_METRICS=1 && ...demo.exe --screen=Buttons :: raw per-paragraph dump
|
||||
```
|
||||
(The JVM leg's screenshot flag is `--screenshot-all=<dir>`, not the native `--screenshot=<file>`.)
|
||||
|
||||
### 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
|
||||
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
|
||||
`--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 +
|
||||
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
|
||||
drift is auditable (doc-only, not a code blocker).
|
||||
|
||||
4. **[§5] WIN-SMOKE gate** (pre-ship): (1) the §1b metrics dump, (2) tab/control-char render clean,
|
||||
(3) the Windows-only `PrintWindow` probe (`scripts/probe/`), (4) `gradlew
|
||||
:<module>:compileCommonMainKotlinMetadata` (only the Windows job compiles common metadata).
|
||||
|
||||
5. **[§5] apiDump + publish FROM Windows** (host-specific; only the Windows job carries the full
|
||||
mingwX64 variant table). Then, when Compose **1.12.0 stable** ships, do the re-pin + version
|
||||
bump. Release runbook: [TOOLING.md](TOOLING.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Cross-platform FIDELITY (headline)
|
||||
|
||||
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.
|
||||
|
||||
**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)
|
||||
|
||||
Kotlin sets `ParagraphStyle.replaceTabCharacters = true`
|
||||
(`compose/ui/ui-text/src/skikoRendererMain/kotlin/.../renderer/skia/SkiaParagraphEngine.kt:234-236`,
|
||||
exact mirror of upstream `ParagraphBuilder.skiko.kt:625`, CMP-6589). Works on
|
||||
macOS/Linux (official skiko honors the flag → `U+0009` replaced by space before shaping).
|
||||
On Windows the same call runs against the fork's flat extern-C DLL, which evidently does
|
||||
**not** wire the setter → raw `\t` reaches HarfBuzz → NotoSans has no tab glyph → `.notdef`
|
||||
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
|
||||
(`--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.
|
||||
- [x] **P0** Fix (ship now, author project code): in
|
||||
`.../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`,
|
||||
`getCursorRect`, `wordBoundary`, `lineMetrics`, span cut-points). Length-preserving so
|
||||
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
|
||||
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
|
||||
in-tree.
|
||||
|
||||
### 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
|
||||
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
|
||||
handled in SHARED Kotlin (no fork rebuild, keeping fork-reliance minimal):
|
||||
|
||||
1. **Fork `LineMetrics.ascent/descent` mis-decode → FIXED in Kotlin (this session).**
|
||||
`CDN_TEXT_METRICS` showed the fork's per-line `ascent`/`descent` constant (18.837/5.163)
|
||||
regardless of `fontPx`, while `baseline`/`height`/`width` are correct (mac scales them:
|
||||
37.674@30px). This is the fork's flat extern-C LineMetrics binding, NOT a Skia difference.
|
||||
Lines stack contiguously so true ascent = `baseline - lineTop`; `SkiaParagraphOps.lineMetrics()`
|
||||
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`
|
||||
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
|
||||
macOS-native drifts from JVM by the same amount (parity tolerated it as sub-pixel). Fixable in
|
||||
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`:
|
||||
```
|
||||
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
|
||||
boundary 24/25 m3=25 base: 19.622 fork / 21.812 jvm
|
||||
```
|
||||
|
||||
The vertical-layout code path is **identical** on all targets. The lineHeight/HeightMode
|
||||
path is a byte-faithful port of upstream and is NOT the cause: m3 `labelLarge` has
|
||||
lineHeight 20sp > fontSize 14sp with `LineHeightStyle.Default.trim == Both` →
|
||||
`HeightMode.DISABLE_ALL`, which collapses the single-line box to `fontAscent + fontDescent`
|
||||
of the font itself (`SkiaParagraphEngine.kt:244-249`, matches upstream
|
||||
`ParagraphBuilder.skiko.kt:631-646`). So box height is driven entirely by the font's
|
||||
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,
|
||||
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`
|
||||
fs_selection bit differently. NotoSans is the textbook case (large top-heavy ascent).
|
||||
`SkiaParagraphEngine.kt:147-148` read `-defaultFont.metrics.ascent`/`.descent`;
|
||||
`:150-169 lineMetrics()` forwards skiko's numbers verbatim; `SkiaParagraph.native.kt:116,148-153`
|
||||
consume them. Contributors V2 (hinting Normal on Windows grid-fits vertically, CoreText
|
||||
ignores it) and V3 (subpixel/edging) are secondary and per-scaler.
|
||||
|
||||
**Hypothesis, not confirmed:** whether Windows-native actually *diverges from
|
||||
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
|
||||
above). Diagnostic **LANDED**: `SkiaParagraphEngine.kt` dumps
|
||||
`defaultFont.metrics.{ascent,descent,leading}`, `paragraph.height`,
|
||||
`lineMetrics[0].{ascent,descent,baseline,height}`, `familyName` + `fontPx` per built
|
||||
paragraph when `CDN_TEXT_METRICS=1` (mirrors the `CDN_PROFILE` env-flag convention).
|
||||
Run `CDN_TEXT_METRICS=1 <app> --screen=Buttons` (native) and the JVM parity app on the
|
||||
SAME host, diff the lines. macOS baseline captured (see below). **REMAINING (Windows
|
||||
host):** compare Windows-native vs **skiko-JVM-Windows**. Expectation: `fontPx` +
|
||||
lineHeight + heightMode identical, `metrics.ascent` differs by scaler.
|
||||
**Acceptance test:** Windows-native == skiko-JVM-Windows (→ correct-by-reference, not a
|
||||
bug); if they differ, it's a fork scaler fix (next item).
|
||||
|
||||
**macOS baseline captured (`--screen=Buttons`, DPR 2):** for the fontPx=30 button
|
||||
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,
|
||||
~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.
|
||||
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
|
||||
SAME label, compare `paraHeight` + `line0.height`. If macOS/JVM = 48 but Windows-native
|
||||
≈ 41, the fork drops lineHeight leading (fork fix: wire `heightMode`/strut, like
|
||||
`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
|
||||
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]
|
||||
text='OutlinedButton' 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]
|
||||
text='TextButton' 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]
|
||||
text='Outlined' fontPx=28.0 ascent=-29.932007 descent=8.203980 leading=0.0 paraHeight=40.0 line0[ascent=31.395 descent=8.605 baseline=31.364 height=40.0]
|
||||
```
|
||||
(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
|
||||
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.
|
||||
- [ ] **P2** (Optional, STRONGER than upstream) If cross-platform pixel-identity is
|
||||
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.
|
||||
- [ ] **DO NOT** "fix" by injecting a default `LineHeightStyle` or clamping ascent in
|
||||
`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
|
||||
(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
|
||||
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
|
||||
Noto + aliases) first, then the default/system manager for CJK/emoji fallback, which
|
||||
resolves because the system FontMgr has those fonts. Rewriting to
|
||||
`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
|
||||
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).**
|
||||
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
|
||||
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
|
||||
runtime difference. **Note (2026-08-04):** native Windows text AA looked clean in the render
|
||||
(no visible AA anomaly), so no fork gamma fix is pursued for 1.0. Still worth capturing the
|
||||
fork's values in the fork repo (see the args.gn item below). **Kotlin-side rasterization
|
||||
settled:** the `RASTER_EDGING/HINTING/SUBPIXEL` in `SkiaParagraphEngine` come from
|
||||
`FontRasterizationSettings.PlatformDefault`, which was verified (2026-08-04) to reproduce
|
||||
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
|
||||
create the surface `colorSpace = null` (`SkiaGLBridge.kt:68-74`,
|
||||
`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
|
||||
(`PlatformGpu.kt:20`), same backend as Linux, so GL-vs-Metal can't explain a
|
||||
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
|
||||
`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
|
||||
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
|
||||
this repo (kept lean); a comment in the fork repo or `build-sdl.properties`-style pin.
|
||||
|
||||
---
|
||||
|
||||
## 2. VENDORING & upstream-fidelity debt
|
||||
|
||||
Vendor hygiene is **release-ready**: `check-vendor-drift.py` reports all 10 manual vendors
|
||||
match pin `v1.12.0-beta03+dev4483`; zero commonMain rule-1 violations (the 3 authored
|
||||
`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
|
||||
re-vendoring.** The debt is **completing native-actual stubs**.
|
||||
|
||||
### P0 - blocks fidelity/correctness
|
||||
|
||||
- [~] **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
|
||||
`ComposeFoundationFlags.isNewContextMenuEnabled = false`, so the legacy branch) →
|
||||
`contextmenu.ContextMenuArea` → `contextMenuGestures`/`onRightClickDown`
|
||||
(`isSecondaryPressed`) → `ContextMenuPopup` → `Popup.native.kt` (hosted by
|
||||
`LocalPopupHost` at the app root). Items are real: `TextFieldSelectionManager` /
|
||||
`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
|
||||
`TODO(CMP-7819)` comments corrected to say so, in all three files.
|
||||
**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:
|
||||
`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
|
||||
remaining gaps, all narrower than a correctness blocker: field NAMES English-only
|
||||
(`: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
|
||||
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
|
||||
`LegacyPointerEvent.x/y` and `AppEvent.MouseWheel.x/y` to `Float`, dropped the
|
||||
`.toInt()` at the three mapper construction sites, and removed the now-redundant
|
||||
`.toFloat()` at the two `ComposeWindow` read sites. Consumers fully contained (verified
|
||||
by grep). Builds + runs clean on macosArm64.
|
||||
|
||||
### P1 - quality / parity
|
||||
|
||||
- [ ] **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`
|
||||
`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
|
||||
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
|
||||
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`.
|
||||
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` +
|
||||
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
|
||||
`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** Text paint path handles only `SolidColor`
|
||||
(`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
|
||||
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`
|
||||
`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.
|
||||
- [ ] **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 -
|
||||
re-verify.
|
||||
|
||||
### P2 - cosmetic / edge
|
||||
|
||||
- [ ] **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`
|
||||
`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.
|
||||
- [ ] **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
|
||||
skip ahead-of-time composition (scroll-in jank).
|
||||
- [ ] **P2** `ClipboardPasteState.hasClip` aliased to `hasText`
|
||||
(`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
|
||||
leave or fill.
|
||||
- [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
|
||||
as-is since it repoints per active window.)
|
||||
|
||||
---
|
||||
|
||||
## 3. SDL slimming
|
||||
|
||||
Static lib built from source per host (`scripts/build-sdl/build-all.py`, ref
|
||||
`release-3.4.12`); linked into the exe via `sdl3.def` (`staticLibraries=libSDL3.a`). Today
|
||||
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
|
||||
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
|
||||
Gamepad), Haptic, HIDAPI, Sensor, Power, Camera, GPU API, Offscreen video driver, virtual
|
||||
joystick.
|
||||
|
||||
- [x] **P1** Added to `build-all.py` shared `vExtra`: `-DSDL_AUDIO=OFF`,
|
||||
`-DSDL_JOYSTICK=OFF`, `-DSDL_HAPTIC=OFF`, `-DSDL_HIDAPI=OFF`, `-DSDL_SENSOR=OFF`,
|
||||
`-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`
|
||||
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;
|
||||
`:demo` links + runs to a settled screenshot (TextField screen, no visual regression);
|
||||
`:apidemo` links clean too. **PENDING:** both apps on Linux + Windows hosts (fold into
|
||||
WIN-SMOKE §5). If audio/gamepad is ever needed by a consumer app, re-enable the flag
|
||||
and rebuild the static lib (build-time only).
|
||||
- [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
|
||||
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
|
||||
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
|
||||
to that assumption. **Conservative: leave render drivers alone.**
|
||||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
## 4. Performance
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **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 -
|
||||
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
|
||||
Windows-GL perf gap surfaces.
|
||||
- [ ] **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
|
||||
add a provider-unregister path.
|
||||
- [ ] **P2** P4.1 second-renderer manual-vendor reversal (restore upstream RenderNode
|
||||
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.
|
||||
- [ ] **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
|
||||
the target monitor). Only open perf unknown.
|
||||
|
||||
---
|
||||
|
||||
## 5. API coverage / release mechanics
|
||||
|
||||
API coverage vs upstream is **99%** (8665/8751 decls via `compose-coverage.py`). Misses are
|
||||
AppKit/UIKit/web host actuals (N/A), `ImageComposeScene`/`renderComposeScene` (test util),
|
||||
and the `ui-text.platform` font layer (= P3.2 font resolver). The 1076 "extra" decls are
|
||||
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
|
||||
`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
|
||||
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
|
||||
**✅ 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
|
||||
publish job compiles common metadata** (owns the root KotlinMultiplatform publications
|
||||
- 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
|
||||
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
|
||||
'@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
|
||||
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 -
|
||||
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
|
||||
Windows metadata publish never went green.
|
||||
**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:
|
||||
`foundation/…/Scrollbar.skiko.kt` + `v2/Scrollbar.skiko.kt`. Added the identical `@file:Suppress`
|
||||
to both (provenance comment updated; `check-vendor-drift` still clean). Full
|
||||
`compileCommonMainKotlinMetadata` now green. **Remaining before publish:** run the actual
|
||||
publish + a downstream-consume smoke (the compile gate is necessary, not proof the `.module`
|
||||
variant table is complete).
|
||||
- [ ] **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
|
||||
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)
|
||||
|
||||
**Goal:** honour "VENDOR ALL WE CAN". Delete the hand-rolled reduced text engine
|
||||
(`SkiaParagraph.native.kt` + `NativeParagraphOps` seam + `SkiaParagraphEngine.kt` +
|
||||
`FontRasterizationSettings.native.kt` port) and vendor upstream's skiko text subsystem VERBATIM.
|
||||
|
||||
**Why it was hand-rolled (the REAL reason, not SDL_TTF):** `Paragraph` is `expect sealed interface`
|
||||
(`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.
|
||||
|
||||
**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.
|
||||
|
||||
**⚠️ 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`
|
||||
(`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`.**
|
||||
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`,
|
||||
`resolve`→`Immutable(Unit)`). The port's hand-rolled engine exists precisely to BYPASS that NOP by
|
||||
resolving through `SkiaFonts` (icon fonts + variable axes + data.kres bytes). So vendoring the
|
||||
upstream engine verbatim routes font resolution through the NOP → **all text goes blank/tofu**
|
||||
until the loader is real.
|
||||
- **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
|
||||
`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.
|
||||
|
||||
**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
|
||||
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`,
|
||||
`ParagraphLayouter.skiko.kt`, `PlatformFont.skiko.kt`, `FontRasterizationSettings.skiko.kt`,
|
||||
`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
|
||||
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
|
||||
`halfLeading` → the §1b M3-baseline drift disappears for free.
|
||||
5. Verify: mingwX64 build + `--screen=BasicText/Buttons/AnnotatedString` render clean +
|
||||
`--metricsprobe` == JVM `--metrics`. Then macOS/Linux verify + parity (needs those hosts).
|
||||
|
||||
**Sequencing:** AFTER the current shared-Kotlin fixes land (this session). If the sealed-actual move
|
||||
proves infeasible, fall back to keeping the seam but at least converting the hand files to tracked
|
||||
Rule-3 vendors (like `FontRasterizationSettings.native.kt` now has a `VENDOR-BASE` line).
|
||||
|
||||
## 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
|
||||
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).
|
||||
@@ -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
|
||||
|
||||
@@ -72,7 +72,7 @@ saved state behave as they do on Android.
|
||||
|
||||
```kotlin
|
||||
commonMain.dependencies {
|
||||
implementation(project(":window")) // window shell + main loop
|
||||
implementation(project(":desktop-native-window")) // window shell + main loop
|
||||
implementation(project(":material3")) // Material 3 widgets
|
||||
implementation(project(":material-symbols")) // icon-font composables (optional)
|
||||
}
|
||||
@@ -80,7 +80,10 @@ commonMain.dependencies {
|
||||
|
||||
### Building from your own project: the bridge plugin
|
||||
|
||||
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`. Apply
|
||||
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`,
|
||||
… (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
|
||||
while android, jvm, iOS, and wasm keep resolving the official artifacts.
|
||||
@@ -103,7 +106,15 @@ commonMain.dependencies {
|
||||
}
|
||||
```
|
||||
|
||||
Repositories, credentials, and version pinning:
|
||||
**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
|
||||
`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
|
||||
mingwX64 you can omit the fork repo). The exact `repositories {}` snippet,
|
||||
credentials, and version pinning:
|
||||
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||
|
||||
For a complete project that applies the bridge and builds one shared UI for
|
||||
@@ -142,8 +153,8 @@ client certificates.
|
||||
## Modules
|
||||
|
||||
One Gradle module per upstream Compose artifact, mirroring the upstream
|
||||
`compose/` tree. The renderer and cinterops live in `:ui`; `:window` is the SDL
|
||||
integration layer. `:material-symbols`, `:components-resources`, and
|
||||
`compose/` tree. The renderer lives in `:ui` and the `sdl3` cinterop in
|
||||
`:sdl-core`; `:desktop-native-window` is the SDL integration layer. `:material-symbols`, `:components-resources`, and
|
||||
`:navigation3-ui` are project or vendored modules that the official Maven
|
||||
artifacts do not cover for Kotlin/Native desktop.
|
||||
|
||||
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
# RENDERER.md
|
||||
|
||||
The durable reference for the rendering layer: how it is structured, the
|
||||
convergence work that shaped it, the decisions worth remembering, and what is
|
||||
left. Read this before touching anything under `renderer/`, the graphics
|
||||
actuals, or the layer engine.
|
||||
|
||||
For build and verification commands see [TOOLING.md](TOOLING.md). For module
|
||||
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## 1. The renderer
|
||||
|
||||
> **This branch is Skia-only.** The from-scratch SDL renderer leg
|
||||
> (`sdlRendererMain`: `Sdl3Canvas` / `Sdl3TextRenderer` / `FreeTypeIcons` / the
|
||||
> SDL render nodes, ~3,800 lines) and its `SDL3_ttf` / `SDL3_image` / FreeType
|
||||
> cinterops were removed once Skiko-on-mingwX64 landed (Route 1a — see
|
||||
> [SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)). SDL3 remains as the
|
||||
> windowing / input / platform layer only. Below, references to "the SDL leg", a
|
||||
> second `RenderBackend` actual, `-Prenderer=sdl3`, and the SDL-leg fidelity work
|
||||
> are **historical** — kept because the convergence decisions still explain the
|
||||
> Skia leg's shape.
|
||||
|
||||
Rendering goes through one `RenderBackend`, implemented once: the **Skia leg**
|
||||
(`skikoRendererMain`). It draws through upstream Compose Multiplatform's own Skia
|
||||
stack, vendored verbatim: `SkiaBackedCanvas`, `SkiaBackedPaint`, `SkiaShader`,
|
||||
`actual class GraphicsLayer` backed by `org.jetbrains.skiko.node.RenderNode`,
|
||||
`SkiaGraphicsContext` — the layer and draw engine internals are literally
|
||||
upstream. macOS/Linux link the official Skiko klibs; mingwX64 links the
|
||||
bitsycore skiko **fork** (`skikoRendererMingwMain`, Route 1a).
|
||||
|
||||
The text and image pipeline (`SdlParagraph`, `NativeTextMeasurer`, `IconFont`)
|
||||
is the port's own engine; glyph rasterization is Skia. GPU path per platform:
|
||||
Metal (macOS), OpenGL (Linux + Windows), with a CPU-raster `Software` fallback.
|
||||
|
||||
The seam is kept as narrow and low as possible. Code flows
|
||||
`Common (upstream) -> shared native engine -> Skia actual / SDL actual`. What we
|
||||
minimize is hand-rolled actual surface, not actual-side line count: a fat
|
||||
vendored-upstream actual (the Skia leg carrying upstream's whole GraphicsLayer
|
||||
stack) is preferred over a thin hand-written one.
|
||||
|
||||
## 2. The retained-layer engine (the model we copied)
|
||||
|
||||
Upstream skiko skips work at three levels. The Skia leg has all three.
|
||||
|
||||
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
|
||||
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
|
||||
`SDL_WaitEventTimeout` when idle.
|
||||
- **L2 measure/layout only dirty nodes.** Vendored `MeasureAndLayoutDelegate`
|
||||
used verbatim through `ComposeOwner.measureAndLayout()`.
|
||||
- **L3 draw record-once / replay** (the expensive one). Each isolated
|
||||
`LayoutNode` gets an `OwnedLayer` (`GraphicsLayerOwnerLayer`) owning a
|
||||
`GraphicsLayer`. The display list re-records only when content is dirty;
|
||||
otherwise it replays.
|
||||
|
||||
The critical property: **transform, alpha, and clip changes do NOT re-record.**
|
||||
Moving, scaling, rotating, or fading a layer replays cached content under a new
|
||||
transform. Only a genuine content change (a state read inside the draw block, or
|
||||
a resize) re-records. Upstream replays the whole scene every frame with no
|
||||
dirty-region present; the entire win is not re-recording clean layers, not
|
||||
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
|
||||
|
||||
The compositing-strategy contract (`requiresLayer()`, from
|
||||
`SkiaGraphicsLayer.skiko.kt`) decides when a layer needs an offscreen:
|
||||
|
||||
| Condition | Auto | Offscreen | ModulateAlpha |
|
||||
|---|---|---|---|
|
||||
| `alpha < 1` | offscreen | offscreen | per-op alpha multiply (no offscreen) |
|
||||
| `colorFilter != null` | offscreen | offscreen | offscreen |
|
||||
| `blendMode != SrcOver` | offscreen | offscreen | offscreen |
|
||||
| `renderEffect != null` | offscreen | offscreen | offscreen |
|
||||
| none of the above | replay in place | always offscreen | replay in place |
|
||||
|
||||
## 3. Current state
|
||||
|
||||
- **Skia leg runs upstream's engine.** Canvas (`SkiaBackedCanvas`) and
|
||||
GraphicsLayer/GraphicsContext (`org.jetbrains.skiko.node.RenderNode`) are
|
||||
vendored verbatim. The Skia leg gets real display-list caching and correct
|
||||
clip/shadow/renderEffect for free from upstream. After this landed, Skia
|
||||
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
|
||||
- **Parity is a golden-master against JVM** (about 2% median, dominated by the
|
||||
shared text engine's small metric delta). See TOOLING.md for how to read it.
|
||||
- **Memory is stable.** The historical composition leak is fixed (see section 6)
|
||||
and guarded by the `--soaktest` gate in the verify runbook.
|
||||
|
||||
## 4. Decisions to remember
|
||||
|
||||
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
|
||||
reconciliation cost when following upstream. All platforms now render through
|
||||
real Skia — mingwX64 via the bitsycore skiko fork (Route 1a; see
|
||||
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)) — so there is no
|
||||
fidelity tier below the JVM cross-check. The JVM Compose Desktop target
|
||||
(`:demo:run`) remains the fidelity/parity reference on any host.
|
||||
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
|
||||
Edit-to-compile becomes a manual vendor with a `// VENDOR-BASE:` header so the
|
||||
drift tripwire can track it. The litmus test for any divergence: "Is this what
|
||||
upstream does? If not, what real platform constraint forces the difference?"
|
||||
Valid answers name a constraint (e.g. no windowing/input toolkit in K/N ->
|
||||
SDL3). "It was easier" is not valid.
|
||||
- **Text stays the port's engine (B6.3 skipped).** Making Skia-leg text truly
|
||||
upstream (`SkiaParagraph`) is a font-subsystem replacement, not a canvas swap:
|
||||
two coexisting font-identity models (the port's name-to-bytes `IconFont` +
|
||||
no-op resolver vs upstream `PlatformFont`/`FontCache`/`FontCollection`), about
|
||||
13 skiko files to re-vendor, about 10 shared actuals to split, a
|
||||
`data.kres`-to-`FontCache` bridge, no green intermediate, and it discards the
|
||||
P3.1 metrics work. ROI is low: the Skia leg already measures text with skiko
|
||||
`Font` metrics (via `currentTextMeasurer` -> `SkiaTextRenderer`), which is how
|
||||
parity reached about 2%. Revisit only if complex-script/bidi fidelity becomes
|
||||
a hard requirement.
|
||||
- **Module split (`:ui-graphics` / `:ui-text`) is shelved as infeasible.** Not
|
||||
cosmetic churn as originally scoped: the `sdl3` cinterop is a shared substrate
|
||||
used by 11 graphics-side files AND 12 platform/windowing/node/resources files
|
||||
that stay in `:ui`. Kotlin/Native cannot cleanly share a cinterop klib across a
|
||||
module boundary, which is exactly why they were merged. A real split needs a
|
||||
new non-upstream `:ui-cinterop` base module plus cross-module cinterop
|
||||
api-exposure, which is bigger and more divergent than the "match upstream
|
||||
artifacts" goal it was meant to serve. The one keeper from the attempt:
|
||||
`RenderBackend.drawRoot` now takes `(Canvas) -> Unit`, decoupling the backends
|
||||
from `ComposeRootHost` (a genuine improvement, retained).
|
||||
- **Real Skia on Windows K/N shipped (Route 1a).** mingwX64 links the bitsycore
|
||||
skiko fork against `skiko-windows-x64.dll` (with an embedded GNU import lib),
|
||||
published to GitHub Packages and auto-provisioned by the bridge plugin. This
|
||||
replaced the SDL renderer as the Windows path. See
|
||||
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md) for the ABI details
|
||||
and the route trade-offs.
|
||||
|
||||
## 5. Convergence status
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Guardrails (parity gate, verify-mac runbook, drift + vendor-clean checks) | Done |
|
||||
| B2: Skia leg on upstream GraphicsLayer/GraphicsContext | Done |
|
||||
| B6.1: Skia leg on upstream `SkiaBackedCanvas` + paint/shader | Done |
|
||||
| B6.2: upstream `GraphicsLayer` + delete transient port cluster | Done |
|
||||
| B6.3: upstream text (`SkiaParagraph`) on the Skia leg | Skipped (decision) |
|
||||
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
|
||||
| P2.2: composition memory leak | Fixed + soak-gated |
|
||||
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
|
||||
| P3.1: text metrics parity (17% median -> 2%) | Done |
|
||||
| Module split | Shelved (infeasible as specified) |
|
||||
| Track A: real Skia on Windows (Route 1a) | Done |
|
||||
|
||||
## 6. Remaining and future work
|
||||
|
||||
Nothing here blocks day-to-day work. These are the open threads worth
|
||||
remembering.
|
||||
|
||||
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook cannot cover the
|
||||
shipped mingwX64 binary, the Windows-only `PrintWindow` probe, or the
|
||||
common-metadata publish job. Run these on a Windows host before any release.
|
||||
This is the only outstanding verification.
|
||||
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
|
||||
`v1.12.0-beta03+dev4483` (no clean beta03 tag exists yet, and it is not on
|
||||
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
|
||||
published) by a documented skew. When 1.12.0 stable ships: re-pin both refs to
|
||||
the clean tag, bump the `vComposeJvmVersion` forcing in demo/apidemo/
|
||||
material-symbols to close the skew, then cut the release. The concrete
|
||||
step-by-step (the version map, the ref-bump flow, and the release flow) lives
|
||||
in [TOOLING.md](TOOLING.md#versioning-and-releasing).
|
||||
- **Deferred engine deltas (D2-D6, all low ROI under G1).**
|
||||
- D2: split `GraphicsLayerOwnerLayer` per-leg to restore upstream's exact
|
||||
`setLightingInfo` shadow lighting. Feasible (the old RootNodeOwner-coupling
|
||||
blocker was outdated) but high blast radius for a marginal gain; elevation
|
||||
shadows already match JVM.
|
||||
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
|
||||
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
|
||||
matrix fn usable from the owner layer).
|
||||
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
|
||||
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
|
||||
accessibility or complex-script roadmap.
|
||||
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
|
||||
vendoring them saves nothing.
|
||||
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
|
||||
`SdlImageBitmap.close()` fully into cache eviction and the renderer
|
||||
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
|
||||
|
||||
## 7. Hard-won learnings (do not relearn)
|
||||
|
||||
- **Minimize-divergence is load-bearing.** A hand-written `notifyLayerIsDirty`
|
||||
that diverged from upstream `OwnedLayerManagerImpl` removed a layer from
|
||||
`dirtyLayers` mid-loop and crashed on any navigation. Screenshots missed it;
|
||||
the interaction probe caught it. Match upstream verbatim; do not hand-roll
|
||||
engine plumbing from a summary.
|
||||
- **The composition memory leak (root cause).** The port never called
|
||||
`OwnerSnapshotObserver.clearInvalidObservations()`, which upstream
|
||||
`RootNodeOwner` runs after every measure. Snapshot read-observations for
|
||||
scopes invalidated on dispose lingered forever, each pinning its observed
|
||||
object graph via a Kotlin/Native `ExternalRCRef` (a K/N-heap leak, vmmap
|
||||
Memory Tag 246). Most visible on ripple/indication draws. Fix:
|
||||
`ComposeRootHost.measureAndLayout()` sweeps `clearInvalidObservations()` after
|
||||
layout, plus `ComposeOwner.onDetach` clears the detached node's observations.
|
||||
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
|
||||
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
|
||||
budget heap tooling for this class.
|
||||
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
|
||||
animated/settling screens give false signals. Use render-to-quiescence +
|
||||
virtual frame time for parity, the probe for interaction/crash coverage.
|
||||
- **The profiler `present` phase is vsync-capped by the display refresh.**
|
||||
Profile on the target monitor before concluding a frame-rate gap.
|
||||
|
||||
## 8. Explicit non-goals
|
||||
|
||||
- **Dirty-region / partial present.** Upstream replays the whole scene; the win
|
||||
is not re-recording, not redrawing less.
|
||||
- **A custom `cacheKey` API.** Superseded by the real per-node display list.
|
||||
- **Vendoring `RootNodeOwner` / the `ComposeScene` stack.** Coupled to skiko's
|
||||
`SkiaLayer` and windowing; it fights the `:window` SDL loop. The port borrows
|
||||
only the layer engine.
|
||||
|
||||
## 9. Key files
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
|
||||
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
|
||||
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
|
||||
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
|
||||
hit-test, event dispatch, snapshot observer sweep.
|
||||
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
|
||||
`Owner` + `GraphicsLayerOwnerLayer` bridge.
|
||||
- `compose/ui/ui/src/nativeMain/.../ui/text/SdlParagraph.native.kt`: the bridged
|
||||
`Paragraph` (measurement, hit-test, line metrics, span painting).
|
||||
@@ -1,397 +0,0 @@
|
||||
# Skiko on mingwX64 — Feasibility & Effort Assessment
|
||||
|
||||
Investigation of the effort to run the real Skia leg (`org.jetbrains.skiko`)
|
||||
on the Kotlin/Native **mingwX64** target, under the relaxed constraint that a
|
||||
**DLL is acceptable** (the static / no-DLL invariant may be dropped). Companion
|
||||
to [RENDERER.md](RENDERER.md) §4 "Track A" — which this assessment **partly
|
||||
supersedes** (see [Corrections](#corrections-to-renderermd-4) below).
|
||||
|
||||
Pins at time of writing: skiko `0.150.1` (Skia m150), kotlin `2.4.0`,
|
||||
compose `1.12.0-beta02`.
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
Feasible — and **more tractable than RENDERER.md §4 claims**. Two of that
|
||||
section's load-bearing premises are now factually stale:
|
||||
|
||||
1. **"Building C++20 Skia to a GNU/mingw archive is an open-ended fork (only
|
||||
abandoned Mozilla-era precedent)."** — **False.** MSYS2 maintains
|
||||
`mingw-w64-skia` at **Skia milestone 143 (C++20)** for `mingw64` (GCC),
|
||||
`ucrt64`, and `clang64` (clang → `x86_64-w64-windows-gnu`), producing
|
||||
`libskia.dll` + a GNU import lib `libskia.dll.a`. Last rebuilt **2026-07-24**.
|
||||
The GNU-ABI recipe is ~8 small GN patches (the key one adds
|
||||
`is_mingw = is_win && (cxx=="g++"||cxx=="clang++")`).
|
||||
2. **"The only working route is a runtime DLL, which breaks the no-DLL
|
||||
invariant."** — The DLL route is real, but the reason it works is **not**
|
||||
what the section implies. See the crux below.
|
||||
|
||||
**Effort:** ~**1–2 weeks** to a proven spike + a CPU-raster Skia-on-Windows
|
||||
prototype; ~**1–2 months** to a tested, GPU-accelerated Windows Skia leg with
|
||||
published fork klibs and repo wiring — **plus** permanent
|
||||
`bitsycore/skiko`-fork maintenance (re-sync at every skiko bump). Difficulty:
|
||||
**research-grade / high**, because no working prior art exists (the two GitHub
|
||||
`skiko-mingw64` forks are vaporware — 0 commits ahead of upstream, no mingw
|
||||
source set). Confidence: **medium** (high on mechanism, medium on timeline).
|
||||
|
||||
---
|
||||
|
||||
## Spike result — EXECUTED, GREEN ✅ (2026-07-24)
|
||||
|
||||
The core de-risking experiment has been **run on this machine** and passed. The
|
||||
whole Route 1a link mechanism works on the exact target toolchain (Kotlin
|
||||
**2.4.0**, mingwX64, LLVM **21** `ld.lld`, `x86_64-pc-windows-gnu`).
|
||||
|
||||
**What was built** (`spike-skiko-mingw/` in this repo):
|
||||
- A tiny DLL reproducing skiko's binding shape — an **opaque handle over a real
|
||||
C++/STL object** (`std::string`), **alloc+free paired inside the DLL**, flat
|
||||
`extern "C"` surface. Compiled **MSVC-ABI via `clang-cl /LD /MT /EHsc`**
|
||||
(static CRT). Exports verified undecorated: `spike_make/size/free`.
|
||||
- A **GNU import library** (`libspike.dll.a`) generated with `dlltool`.
|
||||
- A **Kotlin/Native mingwX64 executable** binding it via cinterop (plain C
|
||||
header) + `linkerOpts("-L… -lspike")`.
|
||||
|
||||
**Results:**
|
||||
- `linkReleaseExecutableMingwX64` **linked clean** — **KT-65671 did NOT bite**
|
||||
(the DLL path puts zero C++ objects in the K/N link graph, so the
|
||||
`--allow-multiple-definition` / `.drectve __clang_call_terminate` failures
|
||||
never arise). LLVM 21 `ld.lld` is also far newer than the 1.9.x-era reports.
|
||||
- Runtime: `SPIKE-RESULT size=7 expected=7 ok=true`, exit 0 — the C++
|
||||
`std::string(7,'x').size()` round-trips through the opaque handle correctly.
|
||||
- `objdump -p` confirms the exe imports `spike.dll`, and `spike.dll` depends on
|
||||
**`KERNEL32.dll` only** → **self-contained, no VC++ redist needed**. The exe
|
||||
carries K/N's `msvcrt.dll` while the DLL carries its own static MSVC CRT —
|
||||
**two CRTs coexist in one process** with no issue (ownership paired inside the
|
||||
DLL), empirically confirming the CRT-boundary discipline.
|
||||
|
||||
**Implication:** the one load-bearing unknown (does the DLL route link on
|
||||
2.4.0?) is resolved **in favour**. Route 1a is de-risked at the mechanism level;
|
||||
remaining effort is engineering the real `skiko.dll` (compile skiko's shim +
|
||||
skia-pack MSVC Skia into one DLL) and repo wiring — not proving feasibility.
|
||||
Feasibility confidence is now **high**; residual medium confidence is on
|
||||
timeline only.
|
||||
|
||||
Reproduce: `./gradlew -p spike-skiko-mingw linkReleaseExecutableMingwX64`, then
|
||||
run the exe with `spike-skiko-mingw/native` on `PATH`.
|
||||
|
||||
---
|
||||
|
||||
## The crux — and why "just use a DLL" is only half the story
|
||||
|
||||
**The blocker is a C++ ABI mismatch at the binding boundary, and it is
|
||||
orthogonal to static-vs-dynamic linkage.**
|
||||
|
||||
- **K/N mingwX64 is GNU/Itanium ABI.** `konan.properties`:
|
||||
`targetTriple.mingw_x64 = x86_64-pc-windows-gnu`,
|
||||
`linker.mingw_x64 = ld.lld.exe`,
|
||||
`linkerKonanFlags.mingw_x64 = -static-libgcc -static-libstdc++ … -lwinpthread`
|
||||
— GCC libstdc++/libgcc, Itanium mangling (`_Z3fooi`).
|
||||
- **skia-pack's only Windows Skia is MSVC-ABI** — built with `clang-cl`,
|
||||
`is_trivial_abi=false`, shipped as MSVC `.lib`; C++ exports are MSVC-mangled
|
||||
(`?foo@@YAHH@Z`).
|
||||
|
||||
LLVM issue #60847 ("LLD cannot link against MSVC libraries in MinGW toolchain",
|
||||
closed *not planned*) confirms the two mangling schemes never reconcile.
|
||||
**Wrapping the same MSVC C++ Skia in a DLL does not change this** — a DLL's C++
|
||||
exports are still MSVC-mangled. So dynamic-vs-static is a red herring; **the C++
|
||||
ABI at whatever boundary the mangled C++ crosses is the wall.**
|
||||
|
||||
What makes it tractable is *where that boundary sits in skiko*:
|
||||
|
||||
- **skiko's Kotlin↔native boundary is already a flat, unmangled C symbol
|
||||
boundary.** Kotlin declares
|
||||
`@ExternalSymbolName("org_jetbrains_skia_Data__1nSize") external fun _nSize(…)`;
|
||||
`ExternalSymbolName` is a typealias for `kotlin.native.SymbolName`, which
|
||||
resolves to a *plain named symbol* at link. The generated `.def` is
|
||||
header-less (only `linkerOpts`). **No C++ type ever crosses into Kotlin.**
|
||||
- **The C++ ABI coupling lives one layer down**, at the *shim↔Skia* link:
|
||||
~78 `extern "C"` (`SKIKO_EXPORT`) `.cc` files under `src/nativeJsMain/cpp/`
|
||||
that `reinterpret_cast<SkData*>(ptr)->size()` etc. This shim **must share one
|
||||
C++ ABI with Skia** — today they're one GNU-ABI island fused via
|
||||
`-include-binary`.
|
||||
|
||||
**Consequence:** a DLL is viable **iff** the *entire* C++ island (shim + Skia)
|
||||
is one consistent ABI internally, and only the **flat extern-C export surface**
|
||||
crosses to K/N via a generated import library. On x86_64 there is a single
|
||||
Windows calling convention and extern-C exports are undecorated on both MSVC and
|
||||
mingw, so `ld.lld` links such a DLL via `gendef`+`dlltool` (or directly, LLD
|
||||
13+). **The repo already does exactly this for system DLLs** (`sdl3.def`:
|
||||
`linkerOpts.mingw_x64 = -lkernel32 -luser32 -lgdi32 …`).
|
||||
|
||||
Crucially the extern-C façade **already exists** (the `nativeJsMain/cpp` shim
|
||||
exports precisely the `org_jetbrains_skia_*` symbols the K/N side binds) — it is
|
||||
**reused, not authored anew**. The catch: no ownership / exceptions / STL may
|
||||
cross the C line — skiko is already handle/RefCnt-based, so this holds if
|
||||
authored carefully. (The JVM `jvmMain/cpp` bridge exports `Java_…` JNI symbols
|
||||
and is **not** reusable — the *native* shim is the right one.)
|
||||
|
||||
The non-negotiable that survives all of this: **upstream skiko ships no
|
||||
mingwX64 K/N target at all** (`throw GradleException("$os not yet supported")`
|
||||
for non-{Mac,iOS,tvOS,Linux}; "Windows" in skiko means the JVM/AWT clang-cl
|
||||
path). So this is a **from-scratch skiko build-system port**, not a flag flip.
|
||||
|
||||
---
|
||||
|
||||
## The dominant risk: KT-65671
|
||||
|
||||
`KT-65671` ("Kotlin/Native: Failed linking cinterop static library for
|
||||
mingwX64", **Open**, filed against 1.9.x, status on 2.4.0 unverified): K/N's
|
||||
mingw link step chokes when **fusing an external C++ static archive** —
|
||||
`ld.lld` rejects `--allow-multiple-definition`, and
|
||||
`-exclude-symbols:__clang_call_terminate` in the objects is rejected in
|
||||
`.drectve`. This is skiko's **exact static-fusion pattern**.
|
||||
|
||||
Key implication for route choice: a **pure extern-C DLL boundary sidesteps this
|
||||
bug class entirely** — no C++ objects enter the K/N link graph; only a flat
|
||||
import surface crosses. So, counter-intuitively, the **DLL route is the
|
||||
risk-*reducing* route** w.r.t. KT-65671, and it is exactly what the user is
|
||||
willing to accept.
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
|
||||
### ★ Recommended — DLL with a flat extern-C export, K/N binds via import lib
|
||||
|
||||
Put **all** the C++ (skiko's existing shim + Skia) inside one DLL; export only
|
||||
the flat `org_jetbrains_skia_*` symbols; bind from a new mingwX64 skiko target
|
||||
via a generated import lib. K/N resolves at link; the Windows loader binds
|
||||
`skiko.dll` at process start — **no dlopen/`staticLoad` code needed** (the
|
||||
shipped `staticLoad()` no-op is fine for the import-lib path).
|
||||
|
||||
Two sub-choices for the DLL internals (K/N neither knows nor cares which):
|
||||
|
||||
- **1a — MSVC internals (minimal fork).** Recompile skiko's `nativeJsMain/cpp`
|
||||
shim with `clang-cl` against **skia-pack's existing MSVC Windows `.lib`**
|
||||
(`windows-x64` archives are already published). **Zero Skia fork.** Net-new:
|
||||
add `__declspec(dllexport)` to `SKIKO_EXPORT` on this build, a mingw/DLL
|
||||
branch in skiko's build logic, import-lib generation. CRT discipline (MSVC CRT
|
||||
in the DLL vs K/N's static libgcc) is a correctness constraint on the C API
|
||||
(handles + primitives only), not a linkability blocker.
|
||||
- **1b — GNU internals.** Build the shim + Skia GNU-ABI (reuse MSYS2's
|
||||
`mingw-w64-skia` patch set) into the DLL. Slightly more Skia-side work, but a
|
||||
single toolchain end-to-end.
|
||||
|
||||
**Effort:** ~2–4 weeks once the spike is green. **Risk: medium** (sidesteps
|
||||
KT-65671). **"Just works" preserved:** none — ships `skiko.dll` (tens of MB)
|
||||
next to `data.kres` (accepted). **Sub-choice 1a is the least-fork option.**
|
||||
|
||||
### Route 2 — GNU-ABI static Skia + real mingwX64 skiko target (purist)
|
||||
|
||||
Fork skia-pack (or reuse MSYS2's patches) to emit **GNU-ABI static** Skia;
|
||||
fork skiko to add a mingwX64 target that recompiles the existing extern-C shim
|
||||
GNU-ABI and **static-fuses** exactly as macOS/Linux do today.
|
||||
|
||||
**Effort:** Skia build itself is now **days** (MSYS2 proves it). skiko port +
|
||||
link reconciliation ~3–6 weeks. **Risk: medium-high** — this route hits
|
||||
**KT-65671 head-on** (static C++ archive fusion). **Preserves the static /
|
||||
no-DLL invariant** — the only reason to prefer it, and the user said they don't
|
||||
need it.
|
||||
|
||||
### Route 3 — CPU-raster-only (a scope reduction, not an ABI shortcut)
|
||||
|
||||
The repo's `SkiaSurfaceBridge` (`Surface.makeRasterDirect` → `SDL_UpdateTexture`
|
||||
→ `SDL_RenderTexture`) needs **no GPU context** and compiles on mingwX64 the
|
||||
moment a skiko klib exists. Use it to **skip authoring the Windows GPU bridge**
|
||||
for a first cut. Still requires solving Route 1/2's binding problem first.
|
||||
**Saves ~1–2 weeks** of GPU-bridge work → the natural **milestone 1** of
|
||||
whichever route you pick.
|
||||
|
||||
### Baseline — do nothing (already shipping)
|
||||
|
||||
Windows renders via the from-scratch **SDL leg** (`SDL_RenderGeometry` +
|
||||
SDL3_ttf + FreeType); the **JVM parity target** is the full-fidelity Skia
|
||||
reference. The bar any Skia-on-Windows work must clear is "materially better
|
||||
than the SDL leg's fidelity, worth the DLL + maintenance cost."
|
||||
|
||||
---
|
||||
|
||||
## Recommended work breakdown (Route 1a, CPU-raster first)
|
||||
|
||||
**A. Skia — none (1a) / days (1b).**
|
||||
Route 1a reuses skia-pack's published `windows-x64` MSVC `.lib`. (1b: reuse the
|
||||
MSYS2 `mingw-w64-skia` PKGBUILD + patches at skiko's pinned milestone; backends
|
||||
CPU-raster + `skia_use_gl`, `skia_use_freetype`.)
|
||||
|
||||
**B. `bitsycore/skiko` fork — the bulk, medium risk.**
|
||||
1. Add `mingwX64()` + a `configureNativeTarget(OS.Windows, Arch.X64, …)` branch;
|
||||
replace the `throw GradleException("$os not yet supported")` / empty
|
||||
`linkerFlags` fall-throughs in `NativeTasksConfiguration.kt` with a Windows
|
||||
branch (PE/COFF, `llvm-ar`).
|
||||
2. Compile the **existing** `nativeJsMain/cpp` shim (not the JVM JNI bridge)
|
||||
into `skiko.dll`; make `SKIKO_EXPORT` export the flat symbols
|
||||
(`__declspec(dllexport)` or `--export-all-symbols`/def file).
|
||||
3. Generate the GNU import lib (`gendef` + `dlltool` → `libskiko.dll.a`).
|
||||
4. cinterop `.def` stays header-less (`linkerOpts`/`libraryPaths`), matching the
|
||||
repo's own idiom. Publish mingwX64 klibs + the DLL.
|
||||
|
||||
**C. Windows context actual — deferred behind milestone 1.**
|
||||
5. **Milestone 1:** wire only the CPU-raster `SkiaSurfaceBridge` (already
|
||||
Windows-ready).
|
||||
6. **Milestone 2:** author `SkiaD3D11Bridge` / `SkiaVulkanBridge` — extract HWND
|
||||
via `SDL_GetWindowProperties`, wrap `DirectContext.makeD3D11`/`makeVulkan`,
|
||||
mirror the Metal/GL bridges' per-frame acquire/present.
|
||||
|
||||
**D. This repo's wiring — small, low risk (integration points confirmed).**
|
||||
7. `compose/ui/ui/build.gradle.kts` — flip `isSkiaTarget("mingwX64")` to `true`
|
||||
(currently hardcoded `false`, ~line 58); create a `skikoRendererMingwMain`
|
||||
and `mingwX64Main.dependsOn(...)` it (today mingw attaches only to
|
||||
`sdlRendererMingwMain`, lines ~203–206; the skiko tree, lines ~220–235, is
|
||||
created only under `!useSdl3Everywhere` and never for mingw). Add the fork's
|
||||
coords to that source set.
|
||||
8. **No new expect/actual needed:** `createRenderBackend` /
|
||||
`rendererPreferredGpuMode` are `expect`s in `nativeMain` with `actual`s in
|
||||
`skikoRendererMain` and `sdlRendererMain`; attaching mingwX64 to the skiko
|
||||
source set makes the Skia actual resolve for Windows (one renderer source set
|
||||
per target — the existing invariant).
|
||||
9. Add a Windows GPU-bridge branch to `rendererPreferredGpuMode()`; verify with
|
||||
the demo/apidemo (`--gpu`, `--screenshot`) and the parity harness.
|
||||
|
||||
Repo surface to cover is narrow: **~67 `org.jetbrains.skia` classes + 5
|
||||
`org.jetbrains.skiko.node` APIs** (`RenderNode`, `RenderNodeContext`,
|
||||
`SkikoRenderDelegate`, `SystemTheme`, `currentSystemTheme`).
|
||||
|
||||
---
|
||||
|
||||
## The one spike to run first (~1–2 days)
|
||||
|
||||
Before any fork work, **empirically test the K/N-mingw link path**: build a
|
||||
trivial `extern "C"` C++ DLL (one function returning a handle + one that uses
|
||||
it) — or consume MSYS2's `mingw-w64-x86_64-skia` DLL + `libskia.dll.a`
|
||||
directly — from a throwaway mingwX64 K/N project via cinterop + import lib, and
|
||||
**run it**. This answers, in a day instead of a month:
|
||||
|
||||
- Does K/N mingw link + call a GNU-ABI DLL via an import lib at all?
|
||||
- Does **KT-65671** reproduce on Kotlin 2.4.0 (import-lib vs static fusion)?
|
||||
- DLL vs static-fusion behaviour on this toolchain.
|
||||
|
||||
If green, Routes 1/2 are de-risked. (Caveat: MSYS2 Skia exports Skia's *mangled
|
||||
C++* symbols, so a direct cinterop of it tests only the *link mechanism*, not
|
||||
the skiko API — for the API you still need the extern-C shim. The trivial-C-DLL
|
||||
variant is the cleaner mechanism test.)
|
||||
|
||||
---
|
||||
|
||||
## Biggest risks / unknowns
|
||||
|
||||
1. **KT-65671** — whether K/N's mingw link works on 2.4.0 for external C++;
|
||||
the DLL route sidesteps it, the static route hits it. *The single fact that
|
||||
swings the effort.*
|
||||
2. **Toolchain matching** — Konan bundles LLVM 16 clang + `ld.lld`; the C++20
|
||||
Skia + shim must be built by a toolchain whose objects/imports LLD accepts.
|
||||
3. **CRT / ownership discipline** across the C boundary (only matters for 1a).
|
||||
4. **Maintenance** — SKIKO-446 / SKIKO-611 are Open, unassigned → almost
|
||||
certainly a **permanent `bitsycore/skiko` fork** with per-bump re-sync tax.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
Pursue **Route 1a** (skiko fork producing a DLL with a flat extern-C export over
|
||||
skia-pack's existing MSVC Windows Skia), **scoped CPU-raster first** (Route 3
|
||||
milestone), GPU bridge second. It matches the user's accepted DLL tolerance,
|
||||
requires **zero Skia fork**, and **dodges KT-65671**. Keep Route 2 (static,
|
||||
GNU-ABI) in reserve only if reclaiming the no-DLL invariant later becomes
|
||||
valuable. **Run the MSYS2/trivial-DLL spike before committing to any fork.**
|
||||
|
||||
---
|
||||
|
||||
## Implementation status — WORKING END-TO-END ✅ (2026-07-24)
|
||||
|
||||
**`demo.exe` (mingwX64) renders the Material 3 Buttons screen through real Skia**
|
||||
and wrote a 1000×700 screenshot ("settled at frame 3"), using the CPU-raster
|
||||
`SkiaSurfaceBridge` backed by the forked `skiko-windows-x64.dll`. Route 1a is
|
||||
complete. Build/run:
|
||||
|
||||
```
|
||||
# fork (C:/Dev/skiko), one-time env: SKIKO_VSBT_PATH=<VS BuildTools dir>
|
||||
./gradlew :skiko:publishKotlinMultiplatformPublicationToMavenLocal \
|
||||
:skiko:publishMingwX64PublicationToMavenLocal -Pskiko.native.windows.enabled=true
|
||||
# compose repo
|
||||
./gradlew :demo:linkDebugExecutableMingwX64 -PwindowsSkia=true
|
||||
cp <fork>/skiko/build/out/link/Release-windows-native-x64/skiko-windows-x64.dll <demo exe dir>/
|
||||
<demo exe dir>/demo.exe --screenshot=out.bmp --screen=Buttons
|
||||
```
|
||||
|
||||
Fork fixes needed beyond the DLL build (skiko uses a CUSTOM hierarchy template,
|
||||
`applyDefaultHierarchyTemplate=false`):
|
||||
1. `sourceHierarchy.kt` — add `group("windows"){ withMingwX64() }` under `native`
|
||||
(else the mingwX64 klib compiles EMPTY — missing all of `org.jetbrains.skia`).
|
||||
2. `SkikoProjectContext` — add `supportNativeWindows` into `supportAnyNative` so
|
||||
the `@SymbolName` opt-in reaches the mingw compile.
|
||||
3. `Resources.native.kt` — `ftell` is 32-bit `Int` on Windows (LLP64); `==` fix.
|
||||
4. `windowsMain` actuals for `SkiaLayer` / `currentSystemTheme` (linux is a stub).
|
||||
5. Force-export the 2 ICU symbols (`uloc_getDefault_skiko`,
|
||||
`uloc_toLanguageTag_skiko`) that Kotlin binds via `@SymbolName`.
|
||||
|
||||
Compose-repo wiring (all behind `-PwindowsSkia=true`): `mavenLocal()` in
|
||||
settings, `isSkiaTarget("mingwX64")`, a separate `skikoRendererMingwMain` tree
|
||||
on the fork's **root** coord (`org.jetbrains.skiko:skiko:0.0.0-SNAPSHOT` — the
|
||||
platform artifact alone doesn't expose api-elements), `PlatformGpu` Windows →
|
||||
`Software`, and a one-line `PlatformGpu.mingw.kt`.
|
||||
|
||||
## Earlier milestone — fork BUILDS ✅ (2026-07-24)
|
||||
|
||||
Route 1a is not just designed — the `bitsycore/skiko` fork now **builds the
|
||||
native Windows Skia DLL + a mingwX64 klib** on this machine (`C:/Dev/skiko`,
|
||||
skiko `v0.150.1`).
|
||||
|
||||
Edits made (all in the fork):
|
||||
- `src/nativeJsMain/cpp/common.h` — `SKIKO_EXPORT` gains `__declspec(dllexport)`
|
||||
under a `SKIKO_WINDOWS_DLL` guard.
|
||||
- `skiko/build.gradle.kts` — `configureNativeTarget(OS.Windows, Arch.X64,
|
||||
mingwX64())`, gated behind `-Pskiko.native.windows.enabled=true`.
|
||||
- `buildSrc/.../NativeTasksConfiguration.kt` — a Windows compile branch
|
||||
(clang-cl + `-DSKIKO_WINDOWS_DLL`) and a new `configureWindowsNativeTarget`
|
||||
that links `skiko-windows-x64.dll` via `lld-link` (reusing the JVM template +
|
||||
`resolveBinaryInputs(…, TargetEnv.JVM, …)`), auto-generates the GNU import lib
|
||||
(`dumpbin`→`.def`→`dlltool`), and wires the K/N cinterop `linkerOpts`.
|
||||
|
||||
Results:
|
||||
- The native bridge (`nativeJsMain/cpp`) **compiled on Windows with clang-cl,
|
||||
zero source changes.** The only link fix was adding `d3d12.lib
|
||||
d3dcompiler.lib dxgi.lib` for Skia's `SK_DIRECT3D` backend.
|
||||
- Produced **`skiko-windows-x64.dll` (14.2 MB, 1005 exported
|
||||
`org_jetbrains_skia_*` symbols)**, `libskiko-windows-x64.dll.a` (GNU import
|
||||
lib), and the **mingwX64 klib** (`build/classes/kotlin/mingwX64/main/klib`).
|
||||
|
||||
Prerequisite for any skiko Windows build on this box:
|
||||
`SKIKO_VSBT_PATH='C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools'`
|
||||
(Gradle's VS locator doesn't auto-find VS 18 BuildTools).
|
||||
|
||||
Remaining: publish the fork to `mavenLocal` → a tiny K/N mingw consumer that
|
||||
calls a real Skia function (links the import lib, runs with the DLL on `PATH`)
|
||||
for the definitive end-to-end proof → then wire the compose repo (flip
|
||||
`isSkiaTarget("mingwX64")`, add `skikoRendererMingwMain`, ship the DLL next to
|
||||
the exe, CPU-raster `SkiaSurfaceBridge` first). One known TODO: the klib's
|
||||
`linkerOpts` use an absolute import-lib path (fine same-machine; make relative /
|
||||
bundled for distribution).
|
||||
|
||||
## Corrections to RENDERER.md §4
|
||||
|
||||
The shelving note should be updated — its two central technical claims are stale
|
||||
as of 2026-07:
|
||||
|
||||
- "GNU-ABI Skia is an open-ended fork / only abandoned Mozilla-era precedent" —
|
||||
**false**: MSYS2 maintains a modern (m143, C++20) GNU-ABI Skia, rebuilt
|
||||
routinely.
|
||||
- The accurate residual blocker is **the K/N-side link/binding port**
|
||||
(KT-65671 + authoring the mingwX64 skiko target), **not** building Skia, and
|
||||
the DLL route is *risk-reducing*, not merely "the only working route."
|
||||
|
||||
---
|
||||
|
||||
## Sources (primary, load-bearing)
|
||||
|
||||
- skiko v0.150.1: `skiko/build.gradle.kts`, `buildSrc/.../NativeTasksConfiguration.kt`,
|
||||
`src/commonMain/kotlin/org/jetbrains/skia/Data.kt`,
|
||||
`src/nativeMain/kotlin/org/jetbrains/skia/Actuals.native.kt`,
|
||||
`src/nativeJsMain/cpp/common.h`, `.../impl/Library.native.kt`
|
||||
- skia-pack `script/build.py`; skia.org build docs
|
||||
- Kotlin `konan.properties` (v2.2.0); kotlinlang native-c-interop / target-support
|
||||
- LLVM issue #60847; **KT-65671**; **SKIKO-446**, **SKIKO-611**
|
||||
- MSYS2 `mingw-w64-skia` (packages.msys2.org; MINGW-packages PKGBUILD + patches)
|
||||
- Vaporware forks: github.com/Cdm2883/skiko-mingw64, crowforkotlin/skiko-mingw64
|
||||
- Repo: `compose/ui/ui/build.gradle.kts`, the `SkiaMetalBridge` / `SkiaGLBridge`
|
||||
/ `SkiaSurfaceBridge` / `RenderBackendFactory` seam
|
||||
@@ -1,166 +0,0 @@
|
||||
# TODO.md
|
||||
|
||||
Everything the port currently leaves as a no-op, stub, hardcode, or partial
|
||||
implementation, plus the gaps worth closing before a stable 1.12 release. This
|
||||
is an audit of PROJECT code only (the `*.native.kt` / `com.compose.sdl.*`
|
||||
actuals); vendored upstream under `src/vendor/` is out of scope.
|
||||
|
||||
Severity is a rough guide, not a mandate:
|
||||
|
||||
- **Blocker** for stable: a capability a typical desktop app expects that is
|
||||
currently absent or broken.
|
||||
- **Nice-to-have**: real gap, but the app works without it.
|
||||
- **Cosmetic**: minor fidelity or edge-case behavior.
|
||||
|
||||
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
|
||||
(see [RENDERER.md](RENDERER.md)). There is now ONE renderer — **Skia**
|
||||
(macOS/Linux official Skiko; mingwX64 the bitsycore skiko fork), so Windows
|
||||
renders through real Skia. The priorities below are the cross-cutting platform
|
||||
gaps that affect every OS. JVM Compose Desktop remains a parity cross-check.
|
||||
|
||||
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
|
||||
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
|
||||
here.
|
||||
|
||||
## Completed
|
||||
|
||||
Platform / cross-cutting:
|
||||
|
||||
- **Locale / i18n** (`5d03711f`) — `Locale.current` / `LocaleList.current` read the
|
||||
OS locale via SDL, unlocking the 40+ Material 3 translations. The date/number
|
||||
format tail remains (section C).
|
||||
- **IME text input** (`500c49e4`) — a real `PlatformTextInputSession` routes SDL
|
||||
committed + composing text to the focused field (commit replaces the
|
||||
composition); `SDL_SetTextInputArea` positions the OS candidate window.
|
||||
- **Cursor icons** (`1fcc0828`) — `Modifier.pointerHoverIcon` drives the OS cursor
|
||||
via SDL (I-beam over text, hand over links).
|
||||
- **WindowInfo** (`bad23073`) — `isWindowFocused` + `containerSize` / `containerDpSize`
|
||||
report real values (were hardcoded `true` / `Zero`).
|
||||
- **ImageBitmap from bytes** — `ByteArray.decodeToImageBitmap()` /
|
||||
`createImageBitmap(bytes)` decodes encoded image bytes; was
|
||||
`UnsupportedOperationException`. Verified `demo --imagebytestest`.
|
||||
- **`FontFamily.Monospace`** — generic families map to `"generic:<name>"`;
|
||||
`registerGenericFonts` registers bundled NotoSansMono under `generic:monospace`
|
||||
(bundled only when the app references it). Verified `demo --fonttest`.
|
||||
|
||||
---
|
||||
|
||||
## Release-blocking shortlist for stable 1.12
|
||||
|
||||
The cross-cutting items to weigh first (all detailed below):
|
||||
|
||||
1. **No copy/paste context menu** (section B). Keyboard copy works; there is no
|
||||
right-click or selection toolbar (the desktop path is the right-click context
|
||||
menu, upstream TODO CMP-7819).
|
||||
2. **Accessibility is entirely absent** (section A). Decide whether stable
|
||||
requires any screen-reader support at all.
|
||||
3. Fonts (section D): compose-resources `Font(Res.font.x)` works, and
|
||||
`FontFamily.Monospace` now renders NotoSansMono. Remaining: `Serif`/`Cursive`
|
||||
generics (no bundled font) and the androidx `Font(bytes)`/`PlatformFontLoader`
|
||||
path. Nice-to-have, not a blocker.
|
||||
|
||||
---
|
||||
|
||||
## A. Accessibility (entirely absent)
|
||||
|
||||
No accessibility bridge exists. A real `SemanticsOwner` is built but never
|
||||
surfaced to any OS accessibility API (no NSAccessibility / UIA / AT-SPI, no SDL
|
||||
a11y). Screen readers get nothing.
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:340` · `semanticsOwner` is instantiated but never read; no traversal into a platform a11y tree. A real a11y controller would walk the semantics tree and map roles/labels/actions to a native AT bridge. **Blocker for a11y.**
|
||||
- `compose/ui/ui/src/nativeMain/.../platform/CompositionLocals.native.kt:31` · `LocalPlatformScreenReader` default throws (`error(...)`); never provided by any window. Screen-reader-active queries are unavailable. **Blocker for a11y.**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
|
||||
|
||||
## B. Text toolbar and context menus (copy / paste / select-all)
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. NOTE: the floating toolbar is gated on `isInTouchMode`, so on desktop (mouse) it never shows anyway — the desktop path is the right-click context menu below. **Nice-to-have (touch only).**
|
||||
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No right-click copy/select-all menu for `SelectionContainer`. **Blocker for desktop text UX.**
|
||||
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Blocker for desktop text UX.**
|
||||
|
||||
## C. Locale / i18n tail (needs locale-aware DATA, no ICU on K/N)
|
||||
|
||||
`Locale.current` / `LocaleList.current` are wired to the OS (see Completed). The
|
||||
rest needs month/weekday name tables and date/number patterns, which Kotlin/Native
|
||||
has no ICU for; SDL reports language + country only (no script subtag, no
|
||||
calendar/clock/number prefs). These require a bundled CLDR subset or a K/N i18n lib.
|
||||
|
||||
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`; needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. Inert until `PlatformDateFormat` is localized. **Nice-to-have (DatePicker/TimePicker).**
|
||||
- `compose/material3/.../internal/PlatformDateFormat.native.kt` · multiple hardcodes:
|
||||
- `:27` `weekdayNames` hardcoded English full/short names (DatePicker shows English day initials regardless of locale). **Blocker for localized DatePicker.**
|
||||
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker for localized DatePicker.**
|
||||
- `:25` `firstDayOfWeek` hardcoded `1` (Sunday); locale-dependent. **Nice-to-have.**
|
||||
- `:44` `parse()` only accepts ISO `yyyy-MM-dd`, ignoring pattern/locale. **Nice-to-have.**
|
||||
- `:62` `getDateInputFormat()` hardcoded `yyyy-MM-dd` / `'-'`. **Nice-to-have.**
|
||||
- `:65` `is24HourFormat()` hardcoded `true`. **Cosmetic.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/platform/NativeStringDelegate.native.kt:17` · `toUpperCase`/`toLowerCase` use locale-independent stdlib casing; the `locale` param is ignored (wrong for Turkish dotted/dotless i, etc.). **Cosmetic.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SdlParagraph.native.kt` `getBidiRunDirection` always returns `Ltr`; owners/DrawScopes default `LayoutDirection.Ltr`). No RTL shaping. **Nice-to-have.**
|
||||
- No `NumberFormat` / currency / decimal-grouping actual exists; numbers render via `toString()` with `.` decimal and no grouping regardless of locale. **Cosmetic.**
|
||||
|
||||
## D. Font resolution
|
||||
|
||||
**Custom fonts via compose-resources work.** `org.jetbrains.compose.resources.Font(Res.font.x)`
|
||||
loads the bytes and registers them with the project font registry (IconFont → NamedFont),
|
||||
so the standard CMP way to bundle a font renders correctly.
|
||||
|
||||
- `FontFamily.Serif` / `FontFamily.Cursive` still collapse to the default sans — no bundled
|
||||
serif/cursive font yet (`downloadNotoFonts` fetches Sans + SansMono only). Register one under
|
||||
`generic:serif` / `generic:cursive` the same way to enable them. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP; the androidx `PlatformFontLoader` path (`ResourceFont` / `Font(bytes)` / `LoadedFontFamily`) doesn't load. Use the working compose-resources `Font()` instead. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)` (the renderer resolves by name, so harmless). **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:69` · deprecated `createFontFamilyResolver(fontResourceLoader)` ignores the supplied loader. **Cosmetic.**
|
||||
|
||||
## E. Text rendering details
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../text/SdlParagraph.native.kt:376` · `drawNativeText` accepts and ignores text `Shadow`, stroke `DrawStyle`, and non-`SrcOver` blend, so those never render on text. **Cosmetic.**
|
||||
- `compose/foundation/.../text/StringHelpers.native.kt:31` and `compose/ui/ui/src/nativeMain/.../text/CharHelpers.native.kt:14` · `findPrecedingBreak`/`findFollowingBreak` (and `offsetByCodePoints`) do a codepoint/surrogate walk only, with no ICU grapheme clusters, so caret movement and backspace split combining marks and emoji ZWJ sequences. **Nice-to-have.**
|
||||
|
||||
## F. Drag and drop
|
||||
|
||||
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:38` · `requestDragAndDropTransfer` is a NOP and `isRequestDragAndDropTransferRequired` returns `false`; `Modifier.dragAndDropSource` compiles but no OS-level drag leaves the window. Needs per-OS native work (NSDraggingSession / DoDragDrop / XDND); SDL3 has no portable start-drag. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
|
||||
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
|
||||
|
||||
## G. Window and platform info
|
||||
|
||||
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:279` · `hapticFeedBack.performHapticFeedback` NOP (no desktop pointer haptics API). **Cosmetic.**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
|
||||
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
|
||||
|
||||
## H. Skia renderer anomalies
|
||||
|
||||
Skia is the renderer everywhere, so its bugs are rarely tracked here. This one
|
||||
surfaced while adding BlendMode coverage (`demo --blendtest`):
|
||||
|
||||
- `BlendMode.Multiply` renders wrong. A filled `drawRect` with opaque
|
||||
`Color.Cyan` over opaque `Color.Yellow` reads back `(0,0,255)` blue; the
|
||||
multiply formula can only yield `(0,255,0)` green for opaque cyan×yellow.
|
||||
`Plus` and `Modulate` composite correctly on the same path, so
|
||||
`paint.blendMode` IS applied — the fault is specific to `MULTIPLY` (likely a
|
||||
Metal-backend / premultiply interaction in the graphics-layer flatten, not
|
||||
the `BlendMode.toSkia()` map, which is correct). Needs isolating on the Skia
|
||||
draw path. **Nice-to-have.**
|
||||
|
||||
## J. Needs further review (2026-07 bug audit)
|
||||
|
||||
An audit (parallel passes over the renderer and the text/input/event actuals)
|
||||
surfaced these. The clear, contained bugs were fixed in the same pass; the items
|
||||
here are left open because they are either invasive, risky, or a judgement call.
|
||||
(The renderer-side fixes from that pass predate the single-Skia consolidation and
|
||||
are no longer tracked here.)
|
||||
|
||||
Fixed in the audit (for reference, not open work): `Locale.region` no longer
|
||||
returns the language for a single-subtag tag; and a `MOUSE_LEAVE` now clears
|
||||
hover so a widget doesn't stay highlighted after the cursor exits.
|
||||
|
||||
Open — platform / input:
|
||||
|
||||
- **Pointer coords are truncated to Int before DPR scaling** (`SDL3EventMapper.kt` ~78-97, `LegacyPointerEvent.kt` x/y are `Int`, consumed in `ComposeWindow.kt:756`). On a 2× display a click at logical (100.9, 50.4) becomes physical (200,100) instead of (~201,~100), so hit-testing / caret placement quantizes to 2-physical-px steps near glyph boundaries. Fix means widening the pointer event to `Float` through the pipeline — invasive, hence deferred. **Review.**
|
||||
- **A window created unfocused is still promoted to RESUMED** (`ComposeWindow.kt:575-576, 713-719`). `windowFocused`/`windowVisible` default `true`, so a second `Window {}` opened while another holds focus (or one opened minimized) reports `RESUMED` until SDL later delivers a focus/minimize event. Should query `SDL_GetWindowFlags` at creation. **Review.**
|
||||
- **IME text-input area is only pushed on the first `TEXT_EDITING`** (`ComposeWindow.kt:803`; `updateImeArea()` not called on focus gain / first `StartTextInput` / caret move). The first candidate popup can appear at (0,0) before correcting. The rect math itself is correct. **Review.**
|
||||
- **`Snapshot.sendApplyNotifications()` is called synchronously inside the global write observer** (`ComposeWindow.kt:143-146`). Compose Desktop's `GlobalSnapshotManager` deliberately defers it to a separate dispatch; calling it per-write is redundant (the main loop already calls it each frame) and risks re-entrancy during snapshot application. Verify against the runtime's guard before changing — behaviour-sensitive. **Review (risky).**
|
||||
+41
-8
@@ -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`.
|
||||
|
||||
@@ -21,6 +21,15 @@ python3 scripts/build-sdl/build-all.py sdl3 # rebuild the step
|
||||
Needs `git`, `cmake`, and Python 3 on every host (`ninja` is fetched when
|
||||
absent). Run it once per machine, or after bumping the pinned version.
|
||||
|
||||
The static lib is built **slim**: the port uses SDL only for video/window,
|
||||
events, clipboard, file dialogs, GL/Metal contexts, the CPU-raster `SDL_Render`
|
||||
blit, filesystem, cursor, locale, theme, and text input/IME. `build-all.py`
|
||||
disables every unused subsystem - audio, joystick, haptic, hidapi, sensor,
|
||||
power, camera, GPU, offscreen, virtual-joystick (all zero-reference), plus
|
||||
tests/examples and the Windows D3D12 driver. If a consumer app ever needs one of
|
||||
these (e.g. SDL audio), re-enable its `-DSDL_*` flag in `build-all.py` and
|
||||
rebuild - a build-time change, no code edit.
|
||||
|
||||
## Vendoring upstream Compose
|
||||
|
||||
Most `androidx.compose.*` code is copied byte for byte from
|
||||
@@ -110,6 +119,21 @@ CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo
|
||||
Note: `present` is vsync-blocking, so profile on the target refresh rate before
|
||||
drawing conclusions about a frame-rate gap.
|
||||
|
||||
### Text metrics diagnostic
|
||||
|
||||
Set `CDN_TEXT_METRICS=1` and run any app to dump, per built paragraph, the font
|
||||
scaler's `ascent`/`descent`/`leading`, the resolved `familyName` + `fontPx`, and
|
||||
the resulting paragraph line box (`paraHeight`, `line0[ascent/descent/baseline/
|
||||
height]`). Its job is the mac-vs-Windows vertical-spacing question (PLAN.md §1b):
|
||||
the SAME `NotoSans.ttf` bytes go through a different Skia `FontMgr` scaler per
|
||||
host (CoreText / fontconfig-FreeType / DirectWrite), which can pick different
|
||||
metric tables. Run it on native AND on the JVM parity app on the SAME host and
|
||||
diff the lines - Windows-native must match skiko-JVM-Windows.
|
||||
|
||||
```bash
|
||||
CDN_TEXT_METRICS=1 demo.kexe --screen=Buttons --screenshot=/tmp/b.bmp
|
||||
```
|
||||
|
||||
### Demo probes
|
||||
|
||||
`:demo` ships headless regression probes driven by CLI flags, used by the
|
||||
@@ -136,9 +160,9 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||
```
|
||||
|
||||
mingwX64 renders through Skia via the **bitsycore skiko fork**, consumed from
|
||||
GitHub Packages as `org.jetbrains.skiko:skiko:0.150.1-mingw.1` (macOS/Linux use
|
||||
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.
|
||||
|
||||
@@ -158,10 +182,10 @@ single file to edit; know which axis you are changing.
|
||||
|
||||
| Version | Where | Notes |
|
||||
|---------|-------|-------|
|
||||
| Project release version | The git tag `vX.Y.Z`. `PUBLISH_VERSION` (from the tag) feeds `vPublishVersion` in `build.gradle.kts`, which strips the leading `v`. Group is `com.bitsycore.compose.sdl`. | Set by the tag, not edited by hand. A non-publish build is `0.0.0-SNAPSHOT`. |
|
||||
| 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; mingwX64 uses the bitsycore fork (`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. Verify with a throwaway `skikoRendererMain` compile if unsure. |
|
||||
| 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. |
|
||||
| SDL3 | `scripts/build-sdl/build-sdl.properties`. | Rebuild `libs/` with `build-all.py` after any change. |
|
||||
| Bridge substituted version | Defaults to the bridge plugin's own published version; consumers override with the `composeDesktopNative.version` Gradle property. | The plugin publishes with the release tag, so a consumer on the matching plugin version resolves the right klibs automatically. |
|
||||
|
||||
@@ -187,16 +211,25 @@ Run this on each upstream bump; it is the flow that keeps the sync tax low.
|
||||
|
||||
1. Complete the ref-bump flow above and confirm it is green. For a STABLE
|
||||
release, `vComposeJvmVersion` should match the vendored ref (no skew).
|
||||
2. On a Windows host, run `gradlew :window:compileCommonMainKotlinMetadata`.
|
||||
2. On a Windows host, run `gradlew :desktop-native-window:compileCommonMainKotlinMetadata`.
|
||||
Only Windows compiles the full common-metadata variant table, and the root
|
||||
KotlinMultiplatform publications live there; a macOS-only publish leaves the
|
||||
roots without mingwX64 variants (this bit v0.1.15).
|
||||
3. `git tag vX.Y.Z && git push origin vX.Y.Z`. This triggers `.github/workflows/publish.yml`.
|
||||
3. Refresh the klib API baselines: on a Windows host (JDK 21, matching CI) run
|
||||
`./gradlew apiDump` and commit any diff. Not a hard gate yet - the API is
|
||||
pre-stable and `apiCheck` isn't wired - but regenerating each release keeps
|
||||
the baselines honest so the eventual gate is a no-op. `macosArm64` is inferred
|
||||
from the linux/mingw ABIs (accurate for the target-independent Compose
|
||||
surface); `:sdl-core` (cinterop) and `:material-symbols` (generated icon maps)
|
||||
are excluded via `apiValidation.ignoredProjects`. Best host: Windows - it
|
||||
builds the mingw slice (the fork's unique surface) for real and only infers
|
||||
macos; the macos ABI is target-independent for the tracked pure-Kotlin modules.
|
||||
4. `git tag vX.Y.Z && git push origin vX.Y.Z`. This triggers `.github/workflows/publish.yml`.
|
||||
|
||||
What the publish job does, so you can read a failure:
|
||||
|
||||
- Each host publishes only its own target's publications to GitHub Packages
|
||||
under `com.bitsycore.compose.sdl:*`. Windows additionally publishes the root
|
||||
under their per-area `com.bitsycore.compose.*` coords. Windows additionally publishes the root
|
||||
KotlinMultiplatform metadata, the `jvm` publication, and the bridge plugin
|
||||
(only Windows declares every target).
|
||||
- The `MODULES` list in the workflow must stay in sync with the modules in
|
||||
|
||||
@@ -81,7 +81,7 @@ kotlin {
|
||||
}
|
||||
nativeMain {
|
||||
dependencies {
|
||||
implementation(project(":window"))
|
||||
implementation(project(":desktop-native-window"))
|
||||
implementation(libs.ktor.client.curl)
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ran measurer.wrap() 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,25 +963,25 @@ 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. NativeTextMeasurer.wrap 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 SdlParagraph
|
||||
// does when it builds the body's real paragraph.
|
||||
// scale 12.sp through the current density here, matching what the paragraph
|
||||
// engine does when it builds the body's real paragraph.
|
||||
var vBodyWidthPx by remember { mutableStateOf(0) }
|
||||
val vFontPx = with(LocalDensity.current) { 12.sp.toPx().toInt() }
|
||||
// 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).
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,15 +55,15 @@ 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()
|
||||
|
||||
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int {
|
||||
if (inText.isEmpty()) return 1
|
||||
// inFontPx is already physical pixels, so measure at density 1 (fontSize.value
|
||||
// * density = inFontPx) and wrap at inMaxWidthPx px — same space the old
|
||||
// currentTextMeasurer.wrap used. lineCount comes from skiko's paragraph layout.
|
||||
// * density = inFontPx) and wrap at inMaxWidthPx px by building a Paragraph.
|
||||
// lineCount comes from skiko's paragraph layout.
|
||||
val vStyle = TextStyle(
|
||||
fontSize = inFontPx.sp,
|
||||
fontFamily = inFamilyName?.let { namedFontFamily(it) },
|
||||
|
||||
+62
-17
@@ -12,14 +12,49 @@ plugins {
|
||||
apiValidation {
|
||||
@OptIn(ExperimentalBCVApi::class)
|
||||
klib { enabled = true }
|
||||
ignoredProjects.addAll(listOf("demo", "apidemo"))
|
||||
// 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
|
||||
// hand-authored Compose surface, not generated/binding code.
|
||||
ignoredProjects.addAll(listOf("demo", "apidemo", "sdl-core", "material-symbols"))
|
||||
}
|
||||
|
||||
// Version is driven by PUBLISH_VERSION (set from the git tag in the publish
|
||||
// workflow, `v1.2.3` → `1.2.3`). Local dev / demo runs default to SNAPSHOT.
|
||||
val vPublishVersion = (System.getenv("PUBLISH_VERSION") ?: "0.0.0-SNAPSHOT").removePrefix("v")
|
||||
|
||||
// Published groups mirror the org.jetbrains.compose.<area> originals under the
|
||||
// com.bitsycore fork name, so each module is the obvious 1:1 fork of its upstream
|
||||
// coord (e.g. org.jetbrains.compose.ui:ui → com.bitsycore.compose.ui:ui). Modules
|
||||
// with no upstream area (project-only: :sdl-core, :material-symbols) fall through
|
||||
// to the default com.bitsycore.compose.sdl group; :desktop-native-window is the
|
||||
// headline app-shell artifact, published under com.bitsycore.compose.
|
||||
val kDefaultGroup = "com.bitsycore.compose.sdl"
|
||||
val kAreaGroups = mapOf(
|
||||
":ui" to "com.bitsycore.compose.ui",
|
||||
":ui-graphics" to "com.bitsycore.compose.ui",
|
||||
":ui-text" to "com.bitsycore.compose.ui",
|
||||
":ui-unit" to "com.bitsycore.compose.ui",
|
||||
":ui-geometry" to "com.bitsycore.compose.ui",
|
||||
":ui-util" to "com.bitsycore.compose.ui",
|
||||
":ui-backhandler" to "com.bitsycore.compose.ui",
|
||||
":ui-tooling-preview" to "com.bitsycore.compose.ui",
|
||||
":foundation" to "com.bitsycore.compose.foundation",
|
||||
":foundation-layout" to "com.bitsycore.compose.foundation",
|
||||
":animation" to "com.bitsycore.compose.animation",
|
||||
":animation-core" to "com.bitsycore.compose.animation",
|
||||
":animation-graphics" to "com.bitsycore.compose.animation",
|
||||
":material3" to "com.bitsycore.compose.material3",
|
||||
":material-ripple" to "com.bitsycore.compose.material",
|
||||
":components-resources" to "com.bitsycore.compose.components",
|
||||
":navigation3-ui" to "com.bitsycore.navigation3",
|
||||
":desktop-native-window" to "com.bitsycore.compose",
|
||||
)
|
||||
fun groupFor(path: String): String = kAreaGroups[path] ?: kDefaultGroup
|
||||
|
||||
allprojects {
|
||||
group = "com.bitsycore.compose.sdl"
|
||||
group = groupFor(path)
|
||||
version = vPublishVersion
|
||||
}
|
||||
|
||||
@@ -28,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
|
||||
@@ -37,18 +72,19 @@ allprojects {
|
||||
|
||||
val kAppModules = setOf(":demo", ":apidemo")
|
||||
val kPublishedLibs = setOf(
|
||||
":ui", ":ui-util", ":ui-geometry", ":ui-unit", ":ui-backhandler",
|
||||
":ui-tooling-preview",
|
||||
":sdl-core",
|
||||
":ui", ":ui-util", ":ui-geometry", ":ui-graphics", ":ui-text",
|
||||
":ui-unit", ":ui-backhandler", ":ui-tooling-preview",
|
||||
":animation-core", ":animation", ":animation-graphics",
|
||||
":foundation", ":foundation-layout",
|
||||
":material3", ":material-ripple",
|
||||
":window", ":material-symbols",
|
||||
":desktop-native-window", ":material-symbols",
|
||||
":navigation3-ui", ":components-resources",
|
||||
)
|
||||
|
||||
// -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=….
|
||||
@@ -76,7 +112,7 @@ subprojects {
|
||||
kPublishedLibs.forEach { modulePath ->
|
||||
val vArtifactId = modulePath.removePrefix(":")
|
||||
substitute(project(modulePath))
|
||||
.using(module("com.bitsycore.compose.sdl:$vArtifactId:$kConsumeVersion"))
|
||||
.using(module("${groupFor(modulePath)}:$vArtifactId:$kConsumeVersion"))
|
||||
.because("-PuseGithubPackages=true")
|
||||
}
|
||||
}
|
||||
@@ -105,11 +141,19 @@ subprojects {
|
||||
password = System.getenv("GITHUB_TOKEN")
|
||||
}
|
||||
}
|
||||
maven {
|
||||
name = "Bitsycore"
|
||||
url = uri("https://maven.bitsycore.com/releases")
|
||||
credentials {
|
||||
username = System.getenv("BITSYCORE_MAVEN_USER")
|
||||
password = System.getenv("BITSYCORE_MAVEN_TOKEN")
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -126,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")
|
||||
@@ -135,10 +179,11 @@ allprojects {
|
||||
if (vNativeTargetTokens.any { name.contains(it, ignoreCase = true) }) {
|
||||
resolutionStrategy.dependencySubstitution {
|
||||
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":ui"))
|
||||
// ui's split artifacts: ui-graphics + ui-text are merged INTO
|
||||
// :ui in the port; ui-unit / ui-geometry have their own modules.
|
||||
substitute(module("org.jetbrains.compose.ui:ui-graphics")).using(project(":ui"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-text")).using(project(":ui"))
|
||||
// ui-graphics / ui-text are their own modules (split out of :ui,
|
||||
// upstream layout). Declare each DIRECTLY so the app commonMain sees
|
||||
// them under the granular-metadata visibility rule.
|
||||
substitute(module("org.jetbrains.compose.ui:ui-graphics")).using(project(":ui-graphics"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-text")).using(project(":ui-text"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-unit")).using(project(":ui-unit"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-geometry")).using(project(":ui-geometry"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-util")).using(project(":ui-util"))
|
||||
@@ -149,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"))
|
||||
}
|
||||
}
|
||||
@@ -160,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
|
||||
@@ -49,9 +49,11 @@ class ComposeFontBundlingConfig {
|
||||
var bundleMaterialSymbols: Boolean = false
|
||||
|
||||
/**
|
||||
* hb-subset each bundled Material Symbols font to the glyphs the sources reference
|
||||
* (scripts/subset-material-symbols.py + hb-subset; full font when hb-subset is
|
||||
* absent). Needs [bundleMaterialSymbols]; still gated by -PsubsetIcons.
|
||||
* 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
|
||||
* bundles the full font if neither is available). Needs [bundleMaterialSymbols];
|
||||
* still gated by -PsubsetIcons.
|
||||
*/
|
||||
var enableIconSubsetting: Boolean = false
|
||||
}
|
||||
@@ -72,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.
|
||||
@@ -140,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())
|
||||
}
|
||||
}
|
||||
@@ -180,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.
|
||||
*/
|
||||
@@ -247,8 +249,10 @@ private fun Project.registerIconSubsetPipeline(inUsedStyles: List<String>): Map<
|
||||
}
|
||||
|
||||
/**
|
||||
* hb-subset inStyle's downloaded TTF to the codepoints in usage-codepoint.txt, into this
|
||||
* app's build/icons/. Bundles the full font instead when hb-subset is not on PATH.
|
||||
* Subset inStyle's downloaded TTF to the codepoints in usage-codepoint.txt, into this
|
||||
* app's build/icons/. Prefers hb-subset (harfbuzz); falls back to fontTools via Python
|
||||
* (variable-font-aware, auto-installed via pip) when hb-subset is absent; bundles the
|
||||
* full font only if neither subsetter is available.
|
||||
*/
|
||||
private fun Project.registerSubsetTask(
|
||||
inStyle: String,
|
||||
@@ -279,30 +283,67 @@ private fun Project.registerSubsetTask(
|
||||
vOut.parentFile.mkdirs()
|
||||
val vInputFile = vInputProvider.get().asFile
|
||||
val vBefore = vInputFile.length()
|
||||
// ProcessBuilder: project.exec isn't usable inside doLast on Gradle 9.
|
||||
// hb-subset is optional : without it, bundle the full font.
|
||||
val vProc = try {
|
||||
ProcessBuilder(
|
||||
"hb-subset",
|
||||
vInputFile.absolutePath,
|
||||
"-o", vOut.absolutePath,
|
||||
"--unicodes=$vUnicodes",
|
||||
).redirectErrorStream(true).start()
|
||||
} catch (_: java.io.IOException) {
|
||||
// project.exec isn't usable inside doLast on Gradle 9, so shell out via
|
||||
// ProcessBuilder. Returns (exitedZero, combinedOutput); a missing binary
|
||||
// (IOException) yields (false, message) so callers can fall through.
|
||||
fun runCmd(vararg inCmd: String): Pair<Boolean, String> =
|
||||
try {
|
||||
val vProc = ProcessBuilder(*inCmd).redirectErrorStream(true).start()
|
||||
val vText = vProc.inputStream.bufferedReader().readText()
|
||||
(vProc.waitFor() == 0) to vText
|
||||
} catch (e: java.io.IOException) {
|
||||
false to (e.message ?: "not found")
|
||||
}
|
||||
|
||||
fun logShrink(inTool: String) {
|
||||
val vAfter = vOut.length()
|
||||
val vPct = if (vBefore == 0L) 0 else (100 - 100 * vAfter / vBefore).coerceAtLeast(0)
|
||||
logger.lifecycle(
|
||||
"[subset $inStyle] ${vBefore / 1024}KB → ${vAfter / 1024}KB (-$vPct%) · " +
|
||||
"${vCodepoints.size} glyphs kept [$inTool]"
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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…")
|
||||
// --user (no root) → --break-system-packages (PEP 668) → plain.
|
||||
listOf(listOf("--user"), listOf("--break-system-packages"), emptyList<String>())
|
||||
.firstOrNull { vExtra ->
|
||||
runCmd(
|
||||
vPython, "-m", "pip", "install", "-q", "--disable-pip-version-check",
|
||||
*vExtra.toTypedArray(), "fonttools"
|
||||
).first
|
||||
}
|
||||
}
|
||||
val (vFtOk, vFtOut) = runCmd(
|
||||
vPython, "-m", "fontTools.subset", vInputFile.absolutePath,
|
||||
"--unicodes=$vUnicodes", "--output-file=${vOut.absolutePath}"
|
||||
)
|
||||
if (vFtOk && vOut.length() > 0L) { logShrink("fontTools"); return@doLast }
|
||||
logger.warn("[subset $inStyle] fontTools subset failed :\n$vFtOut")
|
||||
}
|
||||
|
||||
// 3. Last resort : the full font - correct, just larger.
|
||||
vInputFile.copyTo(vOut, overwrite = true)
|
||||
logger.warn(
|
||||
"[subset $inStyle] hb-subset not found on PATH : bundling the full font " +
|
||||
"(${vBefore / 1024}KB). Install harfbuzz to shrink it (brew install harfbuzz / " +
|
||||
"pacman -S mingw-w64-x86_64-harfbuzz / apt install harfbuzz-utils)."
|
||||
"[subset $inStyle] no working subsetter found (hb-subset or python3+fontTools) : " +
|
||||
"bundling the full font (${vBefore / 1024}KB). Install harfbuzz " +
|
||||
"(brew install harfbuzz / apt install harfbuzz-utils / " +
|
||||
"pacman -S mingw-w64-x86_64-harfbuzz) or put python3 on PATH."
|
||||
)
|
||||
return@doLast
|
||||
}
|
||||
val vOutput = vProc.inputStream.bufferedReader().readText()
|
||||
val vCode = vProc.waitFor()
|
||||
if (vCode != 0) throw GradleException("hb-subset failed (exit $vCode):\n$vOutput")
|
||||
val vAfter = vOut.length()
|
||||
val vPct = if (vBefore == 0L) 0 else ((100 - 100 * vAfter / vBefore)).coerceAtLeast(0)
|
||||
logger.lifecycle("[subset $inStyle] ${vBefore / 1024}KB → ${vAfter / 1024}KB (-$vPct%) · ${vCodepoints.size} glyphs kept")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.compose.sdl:components-resources>
|
||||
// Library unique name: <com.bitsycore.compose.components:components-resources>
|
||||
open annotation class org.jetbrains.compose.resources/ExperimentalResourceApi : kotlin/Annotation { // org.jetbrains.compose.resources/ExperimentalResourceApi|null[0]
|
||||
constructor <init>() // org.jetbrains.compose.resources/ExperimentalResourceApi.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
@@ -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,11 +40,11 @@ kotlin {
|
||||
}
|
||||
nativeMain {
|
||||
kotlin.srcDir("src/vendor/native/kotlin")
|
||||
// The Font / Image resource actuals — pure project code that delegates
|
||||
// to :ui's IconFont / NamedFont / decodeEncodedImageBitmap (the Skia
|
||||
// decoder). Renderer-agnostic and skiko-free, so a single native set
|
||||
// covers every target. (Dir name is historical — .sdl.kt.)
|
||||
kotlin.srcDir("src/sdlRendererMain/kotlin")
|
||||
// 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
|
||||
// every target.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# 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),
|
||||
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser)
|
||||
# src/sdlRendererMain — the Font/Image resource actuals: renderer-agnostic
|
||||
# project code (image decode via :ui's Skia decoder, Font
|
||||
# via the project font registry). Dir name is historical;
|
||||
# the build attaches it to nativeMain.
|
||||
# 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
|
||||
# decoder, Font via the project font registry)
|
||||
# Re-sync: python scripts/compose-fork/sync.py components/resources/library
|
||||
|
||||
SET_REPO=https://github.com/JetBrains/compose-multiplatform@<COMPOSE_REF>
|
||||
@@ -44,20 +43,20 @@ 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/sdlRendererMain/kotlin/,
|
||||
# attached to nativeMain) use pure project APIs (IconFont / NamedFont /
|
||||
# decodeEncodedImageBitmap) instead, so opt the upstream Skia-bound files out.
|
||||
# doesn't expose. The module's own Font/Image actuals (src/nativeMain/kotlin/)
|
||||
# use pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap)
|
||||
# instead, so opt the upstream Skia-bound files out.
|
||||
!skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt
|
||||
!skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
|
||||
!skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt
|
||||
|
||||
+5
-5
@@ -15,16 +15,16 @@ import com.compose.sdl.icons.IconFont
|
||||
import com.compose.sdl.text.NamedFont
|
||||
|
||||
// ==================
|
||||
// MARK: Font actuals — SDL renderer
|
||||
// MARK: Font actuals - Skia text stack via the project font registry
|
||||
// ==================
|
||||
|
||||
/** The SDL text stack resolves fonts by FAMILY NAME through the project font
|
||||
registry (IconFont.registerIcon — the general byte-font table, not just
|
||||
/** The Skia text stack resolves fonts by FAMILY NAME through the project font
|
||||
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 the renderer's axis support. */
|
||||
variation axes apply at draw time via Skia's axis support. */
|
||||
|
||||
private val emptyFont: Font = NamedFont("res-font:<loading>")
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
@file:OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
|
||||
|
||||
package org.jetbrains.compose.resources
|
||||
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.compose.sdl.graphics.decodeEncodedImageBitmap
|
||||
import com.compose.sdl.graphics.decodeSvgAt
|
||||
import com.compose.sdl.graphics.svgIntrinsicSize
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// ==================
|
||||
// MARK: Image actuals - Skia decode via the :ui-graphics hook
|
||||
// ==================
|
||||
|
||||
/** Decoding goes through the :ui-graphics Skia hook the SDL backend registers
|
||||
at init (decodeEncodedImageBitmap). resourceDensity/targetDensity are ignored:
|
||||
under this port's Option-B density flow layout runs in physical pixels and
|
||||
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?")
|
||||
|
||||
/** SVG element = the raw document bytes; rendering is size-driven (see SvgPainter). */
|
||||
internal actual class SvgElement(val bytes: ByteArray)
|
||||
|
||||
internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
|
||||
|
||||
/** Resolution-independent SVG painter: reports the document's intrinsic size for
|
||||
layout, but RE-RASTERISES the vector at the actual draw size each time the draw
|
||||
size changes (1-entry size cache), so a scaled-up SVG stays crisp instead of
|
||||
upscaling one intrinsic-size bitmap. Mirrors upstream desktop's SVGPainter. */
|
||||
internal actual fun SvgElement.toSvgPainter(density: Density): Painter = SvgPainter(bytes)
|
||||
|
||||
private class SvgPainter(private val bytes: ByteArray) : Painter() {
|
||||
|
||||
override val intrinsicSize: Size = svgIntrinsicSize(bytes) ?: Size.Unspecified
|
||||
|
||||
private var fAlpha: Float = 1f
|
||||
private var fColorFilter: ColorFilter? = null
|
||||
private var fCacheKey: Long = -1L
|
||||
private var fCacheBitmap: ImageBitmap? = null
|
||||
|
||||
override fun applyAlpha(alpha: Float): Boolean { fAlpha = alpha; return true }
|
||||
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { fColorFilter = colorFilter; return true }
|
||||
|
||||
private fun rasterFor(inWidthPx: Int, inHeightPx: Int): ImageBitmap? {
|
||||
val vKey = (inWidthPx.toLong() shl 32) or inHeightPx.toLong()
|
||||
if (vKey == fCacheKey && fCacheBitmap != null) return fCacheBitmap
|
||||
val vBitmap = decodeSvgAt(bytes, inWidthPx, inHeightPx) ?: return null
|
||||
fCacheKey = vKey
|
||||
fCacheBitmap = vBitmap
|
||||
return vBitmap
|
||||
}
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
val vWpx = size.width.roundToInt()
|
||||
val vHpx = size.height.roundToInt()
|
||||
if (vWpx <= 0 || vHpx <= 0) return
|
||||
val vBitmap = rasterFor(vWpx, vHpx) ?: return
|
||||
drawImage(
|
||||
vBitmap,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(vBitmap.width, vBitmap.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(vWpx, vHpx),
|
||||
alpha = fAlpha,
|
||||
colorFilter = fColorFilter,
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
-35
@@ -4,49 +4,27 @@ package org.jetbrains.compose.resources
|
||||
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.get
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.pointed
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.toKString
|
||||
import kotlinx.cinterop.IntVar
|
||||
import kotlinx.cinterop.value
|
||||
import sdl3.SDL_GetPreferredLocales
|
||||
import sdl3.SDL_GetSystemTheme
|
||||
import sdl3.SDL_SystemTheme
|
||||
import sdl3.SDL_free
|
||||
import com.compose.sdl.res.preferredLocaleProvider
|
||||
import com.compose.sdl.res.systemThemeIsDarkProvider
|
||||
|
||||
// ==================
|
||||
// MARK: ResourceEnvironment — SDL actual
|
||||
// MARK: ResourceEnvironment - platform-env-seam actual
|
||||
// ==================
|
||||
|
||||
/** Non-composable system environment for qualifier resolution (values-fr,
|
||||
drawable-dark, …): locale from SDL_GetPreferredLocales, theme from
|
||||
SDL_GetSystemTheme. 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 / isSystemInDarkTheme from the composition. */
|
||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
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
|
||||
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 /
|
||||
isSystemInDarkTheme from the composition. */
|
||||
internal actual fun getSystemEnvironment(): ResourceEnvironment {
|
||||
var vLanguage = ""
|
||||
var vRegion = ""
|
||||
memScoped {
|
||||
val vCount = alloc<IntVar>()
|
||||
val vLocales = SDL_GetPreferredLocales(vCount.ptr)
|
||||
if (vLocales != null && vCount.value > 0) {
|
||||
val vFirst = vLocales[0]?.pointed
|
||||
vLanguage = vFirst?.language?.toKString() ?: ""
|
||||
vRegion = vFirst?.country?.toKString() ?: ""
|
||||
SDL_free(vLocales)
|
||||
}
|
||||
}
|
||||
val vDark = SDL_GetSystemTheme() == SDL_SystemTheme.SDL_SYSTEM_THEME_DARK
|
||||
val (vLanguage, vRegion) = preferredLocaleProvider?.invoke() ?: ("" to "")
|
||||
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),
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+9
-9
@@ -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) {
|
||||
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
@file:OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
|
||||
|
||||
package org.jetbrains.compose.resources
|
||||
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.compose.sdl.graphics.decodeEncodedImageBitmap
|
||||
|
||||
// ==================
|
||||
// MARK: Image actuals — SDL renderer
|
||||
// ==================
|
||||
|
||||
/** Decoding goes through the :ui hook the SDL backend registers at init
|
||||
(SDL3_image, IMG auto-detect). resourceDensity/targetDensity are ignored:
|
||||
under this port's Option-B density flow layout runs in physical pixels and
|
||||
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?")
|
||||
|
||||
/** SVG on SDL: SDL3_image rasterises SVG at its intrinsic size (IMG_LoadSVG),
|
||||
so the "element" is just the raw bytes and the painter is the rasterised
|
||||
bitmap. No DOM, no vector scaling — prefer XML vector drawables for
|
||||
resolution-independent art on this renderer. */
|
||||
internal actual class SvgElement(val bytes: ByteArray)
|
||||
|
||||
internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
|
||||
|
||||
internal actual fun SvgElement.toSvgPainter(density: Density): Painter =
|
||||
BitmapPainter(
|
||||
decodeEncodedImageBitmap(bytes)
|
||||
?: error("SVG rasterisation failed — is the render backend initialised before painterResource ran?")
|
||||
)
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.compose.sdl:animation-core>
|
||||
// Library unique name: <com.bitsycore.compose.animation:animation-core>
|
||||
open annotation class androidx.compose.animation.core/ExperimentalAnimationSpecApi : kotlin/Annotation { // androidx.compose.animation.core/ExperimentalAnimationSpecApi|null[0]
|
||||
constructor <init>() // androidx.compose.animation.core/ExperimentalAnimationSpecApi.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
@@ -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 :core so :core stays about renderer+ui glue only.
|
||||
// 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.
|
||||
@@ -16,8 +16,7 @@ plugins {
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
}
|
||||
|
||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
||||
val vHostSupportsMingw: Boolean by rootProject.extra
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.compose.sdl:animation-graphics>
|
||||
// Library unique name: <com.bitsycore.compose.animation:animation-graphics>
|
||||
open annotation class androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi : kotlin/Annotation { // androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi|null[0]
|
||||
constructor <init>() // androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -13,8 +13,7 @@ plugins {
|
||||
alias(libs.plugins.kotlin.plugin.compose)
|
||||
}
|
||||
|
||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
||||
val vHostSupportsMingw: Boolean by rootProject.extra
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.compose.sdl:animation>
|
||||
// Library unique name: <com.bitsycore.compose.animation:animation>
|
||||
open annotation class androidx.compose.animation/ExperimentalAnimationApi : kotlin/Annotation { // androidx.compose.animation/ExperimentalAnimationApi|null[0]
|
||||
constructor <init>() // androidx.compose.animation/ExperimentalAnimationApi.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -13,8 +13,7 @@ plugins {
|
||||
alias(libs.plugins.kotlin.plugin.compose)
|
||||
}
|
||||
|
||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
||||
val vHostSupportsMingw: Boolean by rootProject.extra
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Klib ABI Dump
|
||||
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||
// Rendering settings:
|
||||
// - Signature version: 2
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.compose:desktop-native-window>
|
||||
abstract interface com.compose.sdl/ApplicationScope { // com.compose.sdl/ApplicationScope|null[0]
|
||||
abstract fun exitApplication() // com.compose.sdl/ApplicationScope.exitApplication|exitApplication(){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl/AppWindowIcon { // com.compose.sdl/AppWindowIcon|null[0]
|
||||
constructor <init>(kotlin.collections/List<kotlin/String>, kotlin.collections/List<kotlin/String> = ...) // com.compose.sdl/AppWindowIcon.<init>|<init>(kotlin.collections.List<kotlin.String>;kotlin.collections.List<kotlin.String>){}[0]
|
||||
|
||||
final val dark // com.compose.sdl/AppWindowIcon.dark|{}dark[0]
|
||||
final fun <get-dark>(): kotlin.collections/List<kotlin/String> // com.compose.sdl/AppWindowIcon.dark.<get-dark>|<get-dark>(){}[0]
|
||||
final val light // com.compose.sdl/AppWindowIcon.light|{}light[0]
|
||||
final fun <get-light>(): kotlin.collections/List<kotlin/String> // com.compose.sdl/AppWindowIcon.light.<get-light>|<get-light>(){}[0]
|
||||
}
|
||||
|
||||
final val com.compose.sdl/com_compose_sdl_AppWindowIcon$stableprop // com.compose.sdl/com_compose_sdl_AppWindowIcon$stableprop|#static{}com_compose_sdl_AppWindowIcon$stableprop[0]
|
||||
|
||||
final var com.compose.sdl/disableInfiniteAnimations // com.compose.sdl/disableInfiniteAnimations|{}disableInfiniteAnimations[0]
|
||||
final fun <get-disableInfiniteAnimations>(): kotlin/Boolean // com.compose.sdl/disableInfiniteAnimations.<get-disableInfiniteAnimations>|<get-disableInfiniteAnimations>(){}[0]
|
||||
final fun <set-disableInfiniteAnimations>(kotlin/Boolean) // com.compose.sdl/disableInfiniteAnimations.<set-disableInfiniteAnimations>|<set-disableInfiniteAnimations>(kotlin.Boolean){}[0]
|
||||
final var com.compose.sdl/useVirtualFrameTime // com.compose.sdl/useVirtualFrameTime|{}useVirtualFrameTime[0]
|
||||
final fun <get-useVirtualFrameTime>(): kotlin/Boolean // com.compose.sdl/useVirtualFrameTime.<get-useVirtualFrameTime>|<get-useVirtualFrameTime>(){}[0]
|
||||
final fun <set-useVirtualFrameTime>(kotlin/Boolean) // com.compose.sdl/useVirtualFrameTime.<set-useVirtualFrameTime>|<set-useVirtualFrameTime>(kotlin.Boolean){}[0]
|
||||
|
||||
final fun (com.compose.sdl/ApplicationScope).com.compose.sdl/Window(kotlin/Function0<kotlin/Unit>, kotlin/String?, kotlin/Int, kotlin/Int, com.compose.sdl/GpuMode?, com.compose.sdl/AppWindowIcon?, kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>?, kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl/Window|Window@com.compose.sdl.ApplicationScope(kotlin.Function0<kotlin.Unit>;kotlin.String?;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode?;com.compose.sdl.AppWindowIcon?;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_AppWindowIcon$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_AppWindowIcon$stableprop_getter|com_compose_sdl_AppWindowIcon$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/nativeComposeApp(kotlin/Function3<com.compose.sdl/ApplicationScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.sdl/nativeComposeApp|nativeComposeApp(kotlin.Function3<com.compose.sdl.ApplicationScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
|
||||
final fun com.compose.sdl/nativeComposeWindow(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., com.compose.sdl/AppWindowIcon? = ..., kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>? = ..., kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.sdl/nativeComposeWindow|nativeComposeWindow(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;com.compose.sdl.AppWindowIcon?;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
|
||||
final fun com.compose.sdl/windowHasInvalidations(): kotlin/Boolean // com.compose.sdl/windowHasInvalidations|windowHasInvalidations(){}[0]
|
||||
+11
-11
@@ -1,8 +1,8 @@
|
||||
// :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 :core via source-set wiring
|
||||
// (skikoRendererMain vs sdlRendererMain) — this module just calls
|
||||
// `createRenderBackend(...)` and `rendererPreferredGpuMode()` from :core and
|
||||
// Renderer selection happens entirely inside :ui via source-set wiring
|
||||
// (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,25 +23,25 @@ kotlin {
|
||||
|
||||
applyDefaultHierarchyTemplate()
|
||||
|
||||
// sdl3.* types are api-exposed via :ui's cinterop klib — no separate
|
||||
// sdl3 cinterop here.
|
||||
// sdl3.* types are api-exposed via :ui → :sdl-core's cinterop klib - no
|
||||
// separate sdl3 cinterop here.
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// api so apps depending on :window also get the compose re-impl,
|
||||
// Res/resources, GpuMode, and the renderer pipeline from :core, plus
|
||||
// api so apps depending on :desktop-native-window also get the compose re-impl,
|
||||
// Res/resources, GpuMode, and the renderer pipeline from :ui, plus
|
||||
// the foundation / animation-core / animation modules that were split
|
||||
// out of :core (upstream Compose layout).
|
||||
// out of :ui (upstream Compose layout).
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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>,
|
||||
+94
-236
@@ -22,7 +22,6 @@ import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.PointerButton
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.lifecycle.enableSavedStateHandles
|
||||
import com.compose.sdl.node.ComposeRootHost
|
||||
import com.compose.sdl.res.currentImageLoader
|
||||
import com.compose.sdl.window.LocalPopupHost
|
||||
@@ -35,7 +34,11 @@ import kotlinx.cinterop.toKString
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.COpaquePointer
|
||||
import kotlinx.cinterop.pointed
|
||||
import sdl3.SDL_Delay
|
||||
import sdl3.SDL_GetCurrentDisplayMode
|
||||
import sdl3.SDL_GetDisplayForWindow
|
||||
import sdl3.SDL_GetPerformanceCounter
|
||||
import sdl3.SDL_GetPerformanceFrequency
|
||||
import sdl3.SDL_GetTicks
|
||||
@@ -45,7 +48,7 @@ import sdl3.SDL_SetWindowTitle
|
||||
import sdl3.SDL_WaitEventTimeout
|
||||
|
||||
// ==================
|
||||
// MARK: Application entry — nativeComposeApp + Window()
|
||||
// MARK: Application entry - nativeComposeApp + Window()
|
||||
// ==================
|
||||
|
||||
/**
|
||||
@@ -57,14 +60,14 @@ 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
|
||||
routes events per SDL window id.
|
||||
|
||||
Per-window pumping order matters: the render-bridge globals
|
||||
(currentTextMeasurer / currentImageLoader / viewport) are per-renderer, so the
|
||||
(currentImageLoader / viewport) are per-renderer, so the
|
||||
loop installs a window's globals before recomposing / laying out / drawing it.
|
||||
|
||||
`nativeComposeWindow(...)` remains as the single-window wrapper (all demo
|
||||
@@ -79,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(
|
||||
@@ -132,18 +135,22 @@ 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)
|
||||
val appScope = ApplicationScopeImpl(runtime)
|
||||
|
||||
val snapshotHandle = Snapshot.registerGlobalWriteObserver {
|
||||
// Only SCHEDULE a frame here; the apply is coalesced to once per loop
|
||||
// iteration (top of loop + before each pump + before layout in
|
||||
// renderFrame) instead of walking every snapshot observer on every
|
||||
// individual state write. Mirrors upstream GlobalSnapshotManager,
|
||||
// which schedules rather than applying inline.
|
||||
runtime.markAllNeedFrame()
|
||||
Snapshot.sendApplyNotifications()
|
||||
}
|
||||
|
||||
appComposition.setContent { appScope.content() }
|
||||
@@ -157,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) {
|
||||
@@ -193,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()
|
||||
@@ -216,6 +223,10 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
// Per-window pump + render.
|
||||
var vAnyRendered = false
|
||||
var vAllVsync = true
|
||||
// Non-vsync fallback cap: the shortest frame interval among the
|
||||
// rendered non-vsync windows' displays (60Hz default). Only used when
|
||||
// no window is vsync-paced (Software renderer / vsync unavailable).
|
||||
var vFallbackDelayMs = 16u
|
||||
val vAppPending = appRecomposer.hasPendingWork
|
||||
for (vW in runtime.windows.toList()) {
|
||||
vW.installGlobals()
|
||||
@@ -228,7 +239,10 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
if (vW.shouldRender()) {
|
||||
vW.renderFrame()
|
||||
vAnyRendered = true
|
||||
if (!vW.backend.vsyncEnabled) vAllVsync = false
|
||||
if (!vW.backend.vsyncEnabled) {
|
||||
vAllVsync = false
|
||||
vFallbackDelayMs = minOf(vFallbackDelayMs, displayFrameDelayMs(vW.backend.window))
|
||||
}
|
||||
}
|
||||
FrameProfiler.phase("render")
|
||||
}
|
||||
@@ -236,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
|
||||
@@ -246,18 +260,25 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
// ============
|
||||
// Pace / idle-skip.
|
||||
if (vAnyRendered) {
|
||||
SDL_Delay(if (vAllVsync) 1u else 16u)
|
||||
SDL_Delay(if (vAllVsync) 1u else vFallbackDelayMs)
|
||||
} else if (!vAppPending) {
|
||||
// Nothing invalidated anywhere: block for events instead of
|
||||
// spinning. Keep FPS windows anchored to active periods.
|
||||
SDL_WaitEventTimeout(null, 10)
|
||||
// spinning. A real input/redraw event wakes this immediately; the
|
||||
// timeout only bounds how often async work (dispatcher/timers) is
|
||||
// re-checked while truly idle, so keep it coarse to cut wakeups.
|
||||
SDL_WaitEventTimeout(null, 100)
|
||||
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
|
||||
// until the composition settles into a renderable state.
|
||||
SDL_Delay(1u)
|
||||
}
|
||||
|
||||
// ============
|
||||
// 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
|
||||
@@ -290,92 +311,19 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
SDL_Quit()
|
||||
}
|
||||
|
||||
/** CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
|
||||
A named-phase SINGLETON so both the main loop (events / app / pump / render)
|
||||
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
|
||||
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
|
||||
the elapsed since the last mark/phase to that name. Measure first, optimize
|
||||
second — see ROADMAP.md. */
|
||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
internal object FrameProfiler {
|
||||
// null until first checked; then true/false for the run's lifetime.
|
||||
private var fEnabled: Boolean? = null
|
||||
// Output file — resolved once from CDN_PROFILE. Writing to a file (not
|
||||
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
|
||||
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
|
||||
// the cwd; CDN_PROFILE=<path> → that path.
|
||||
private var fPath: String = "cdn_profile.log"
|
||||
val enabled: Boolean
|
||||
get() = fEnabled ?: run {
|
||||
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
|
||||
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
|
||||
(vEnv != null).also { fEnabled = it }
|
||||
}
|
||||
|
||||
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
|
||||
// Insertion-ordered so the printed line follows the call order.
|
||||
private val fSum = LinkedHashMap<String, Double>()
|
||||
private val fMax = LinkedHashMap<String, Double>()
|
||||
private var fFrames = 0
|
||||
private var fLastPrintMs = SDL_GetTicks()
|
||||
private var fMark = 0uL
|
||||
|
||||
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
|
||||
|
||||
fun phase(inName: String) {
|
||||
if (!enabled) return
|
||||
val vNow = SDL_GetPerformanceCounter()
|
||||
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
|
||||
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
|
||||
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
|
||||
fMark = vNow
|
||||
}
|
||||
|
||||
fun frameDone(inRendered: Boolean) {
|
||||
if (!enabled) return
|
||||
if (inRendered) fFrames++
|
||||
val vNowMs = SDL_GetTicks()
|
||||
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
|
||||
val vParts = fSum.keys.map { vName ->
|
||||
val vAvg = (fSum[vName] ?: 0.0) / fFrames
|
||||
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
|
||||
}
|
||||
// Per-frame draw-work averages (see DrawStats): what's inside `draw`.
|
||||
val vStats = com.compose.sdl.graphics.DrawStats
|
||||
val vDraw = "geo=${vStats.geometrySubmits / fFrames} verts=${vStats.vertices / fFrames} " +
|
||||
"masks=${vStats.maskRealizations / fFrames} text=${vStats.textDraws / fFrames} img=${vStats.imageBlits / fFrames}"
|
||||
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " | per-frame " + vDraw + "\n"
|
||||
val vFile = platform.posix.fopen(fPath, "a")
|
||||
if (vFile != null) {
|
||||
platform.posix.fputs(vLine, vFile)
|
||||
platform.posix.fclose(vFile)
|
||||
}
|
||||
vStats.reset()
|
||||
fSum.clear(); fMax.clear()
|
||||
fFrames = 0
|
||||
fLastPrintMs = vNowMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CDN_FORCERENDER=1 forces every frame to render so sustained steady-state
|
||||
timings can be measured on otherwise-idle screens (see TOOLING.md, frame
|
||||
profiler). */
|
||||
private val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
|
||||
|
||||
// ==================
|
||||
// MARK: Probe / screenshot support (render-to-quiescence)
|
||||
// ==================
|
||||
|
||||
/** Set BEFORE nativeComposeApp/nativeComposeWindow: every window's composition then runs
|
||||
under an InfiniteAnimationPolicy that CANCELS infinite animations, so
|
||||
rememberInfiniteTransition & co. freeze at their initial value — the same mechanism
|
||||
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)")
|
||||
@@ -383,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. */
|
||||
@@ -399,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
|
||||
|
||||
@@ -419,7 +367,19 @@ fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: f
|
||||
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
|
||||
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
|
||||
|
||||
/** Single-window compatibility wrapper — the pre-multi-window entry point.
|
||||
/** Frame interval (ms) of the window's current display, for the non-vsync
|
||||
fallback pacing (SDL_Delay). Falls back to 16ms (~60Hz) when the mode can't
|
||||
be read, so a 144Hz panel on a non-vsync path isn't capped to 60. */
|
||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
private fun displayFrameDelayMs(window: COpaquePointer?): UInt {
|
||||
val vWindow = window ?: return 16u
|
||||
val vDisplay = SDL_GetDisplayForWindow(vWindow.reinterpret())
|
||||
val vMode = SDL_GetCurrentDisplayMode(vDisplay) ?: return 16u
|
||||
val vHz = vMode.pointed.refresh_rate
|
||||
return if (vHz > 0f) (1000f / vHz).toUInt().coerceAtLeast(1u) else 16u
|
||||
}
|
||||
|
||||
/** Single-window compatibility wrapper - the pre-multi-window entry point.
|
||||
Closing the window exits the app, exactly as before. */
|
||||
fun nativeComposeWindow(
|
||||
title: String = "ComposeNativeSDL3",
|
||||
@@ -453,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 {
|
||||
@@ -481,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
|
||||
@@ -506,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) {}
|
||||
@@ -517,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(
|
||||
@@ -577,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
|
||||
@@ -603,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
|
||||
}
|
||||
@@ -638,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)
|
||||
}
|
||||
@@ -651,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)
|
||||
@@ -662,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
|
||||
@@ -686,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,
|
||||
@@ -703,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 {},
|
||||
@@ -721,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
|
||||
@@ -750,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
|
||||
@@ -776,6 +736,12 @@ internal class WindowInstance(
|
||||
// Register bundled generic fonts (FontFamily.Monospace → NotoSansMono) once
|
||||
// data.kres is loadable; idempotent, no-op if the font isn't bundled.
|
||||
com.compose.sdl.text.registerGenericFonts()
|
||||
// Position the OS IME candidate window the moment a field in THIS window
|
||||
// gains focus, not only on the first TEXT_EDITING event (repointed per
|
||||
// window here so multi-window focus targets the right SDL window).
|
||||
com.compose.sdl.text.input.ImeBridge.onSessionActiveChange = { active ->
|
||||
if (active) updateImeArea()
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
@@ -794,11 +760,11 @@ 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.toFloat() * vDpr
|
||||
val vPy = inEvent.event.y.toFloat() * vDpr
|
||||
val vPx = inEvent.event.x * vDpr
|
||||
val vPy = inEvent.event.y * vDpr
|
||||
if (inEvent.event.type == PointerEventType.Press) {
|
||||
popupHost?.notifyOutsidePress(vPx.toInt(), vPy.toInt())
|
||||
}
|
||||
@@ -823,7 +789,7 @@ internal class WindowInstance(
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
val vDpr = backend.pixelDensity
|
||||
host.onWheel(inEvent.x.toFloat() * vDpr, inEvent.y.toFloat() * vDpr, inEvent.deltaX, inEvent.deltaY, SDL_GetTicks().toLong())
|
||||
host.onWheel(inEvent.x * vDpr, inEvent.y * vDpr, inEvent.deltaX, inEvent.deltaY, SDL_GetTicks().toLong())
|
||||
}
|
||||
|
||||
fun onKeyEvent(inEvent: AppEvent.Key) {
|
||||
@@ -840,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)) {
|
||||
@@ -899,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()
|
||||
@@ -923,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 ||
|
||||
@@ -936,11 +902,16 @@ internal class WindowInstance(
|
||||
|
||||
backend.updateWindowSize()
|
||||
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
|
||||
// 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.
|
||||
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).
|
||||
@@ -967,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()
|
||||
@@ -1014,118 +985,5 @@ internal class WindowInstance(
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: WindowArchitectureOwner
|
||||
// ==================
|
||||
|
||||
/** Per-window architecture-components owner, modeled on upstream desktop's
|
||||
DefaultArchitectureComponentsOwner (compose/ui skikoMain
|
||||
PlatformOwnerProvider.skiko.kt): one object implements LifecycleOwner +
|
||||
ViewModelStoreOwner + SavedStateRegistryOwner (+ the SavedState-aware
|
||||
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) —
|
||||
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). */
|
||||
private class WindowArchitectureOwner :
|
||||
androidx.lifecycle.LifecycleOwner,
|
||||
androidx.lifecycle.ViewModelStoreOwner,
|
||||
androidx.lifecycle.HasDefaultViewModelProviderFactory,
|
||||
androidx.savedstate.SavedStateRegistryOwner {
|
||||
|
||||
override val lifecycle = androidx.lifecycle.LifecycleRegistry.createUnsafe(this)
|
||||
override val viewModelStore = androidx.lifecycle.ViewModelStore()
|
||||
|
||||
private val savedStateController = androidx.savedstate.SavedStateRegistryController.create(this)
|
||||
override val savedStateRegistry: androidx.savedstate.SavedStateRegistry
|
||||
get() = savedStateController.savedStateRegistry
|
||||
|
||||
override val defaultViewModelProviderFactory = androidx.lifecycle.SavedStateViewModelFactory()
|
||||
override val defaultViewModelCreationExtras: androidx.lifecycle.viewmodel.CreationExtras
|
||||
get() = androidx.lifecycle.viewmodel.MutableCreationExtras().also {
|
||||
it[androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY] = this
|
||||
it[androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY] = this
|
||||
}
|
||||
|
||||
init {
|
||||
savedStateController.performAttach()
|
||||
savedStateController.performRestore(null)
|
||||
// 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
|
||||
// runs enableSavedStateHandles() during composition (nav3's decorators,
|
||||
// rememberViewModelStoreOwner, …) requires INITIALIZED/CREATED, and
|
||||
// upstream desktop windows likewise compose first and resume after.
|
||||
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
/** Focus/visibility-driven state (see WindowInstance.onActivationEvent).
|
||||
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 &&
|
||||
lifecycle.currentState != inState
|
||||
) {
|
||||
lifecycle.currentState = inState
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.DESTROYED
|
||||
viewModelStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: Input helpers
|
||||
// ==================
|
||||
|
||||
/** Escape → back: port of upstream desktop's BackNavigationEventInput. An
|
||||
unconsumed Escape KeyDown completes a back navigation on the dispatcher
|
||||
this input is registered with (BackHandler / PredictiveBackHandler
|
||||
consumers: m3 SearchBar collapse, dialog dismissal, …). */
|
||||
private class BackNavigationInput : androidx.navigationevent.NavigationEventInput() {
|
||||
fun onKeyEvent(inEvent: androidx.compose.ui.input.key.KeyEvent): Boolean {
|
||||
if (inEvent.type == androidx.compose.ui.input.key.KeyEventType.KeyDown &&
|
||||
inEvent.key == androidx.compose.ui.input.key.Key.Escape
|
||||
) {
|
||||
dispatchOnBackCompleted()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-dispatches committed text (SDL TEXT_INPUT) as one synthetic typed
|
||||
KeyDown per Unicode codepoint (surrogate pairs folded). Key.Unknown +
|
||||
codePoint + no modifiers matches both vendored text stacks' isTypedEvent
|
||||
criteria; the SDL key mapper leaves codePoint = 0 on real key events, so
|
||||
the physical KeyDown and the synthetic one can never double-insert. */
|
||||
private fun dispatchTypedText(inHost: ComposeRootHost, inText: String) {
|
||||
var vI = 0
|
||||
while (vI < inText.length) {
|
||||
val vHigh = inText[vI]
|
||||
val vCodepoint: Int
|
||||
if (vHigh.isHighSurrogate() && vI + 1 < inText.length && inText[vI + 1].isLowSurrogate()) {
|
||||
vCodepoint = 0x10000 + ((vHigh.code - 0xD800) shl 10) + (inText[vI + 1].code - 0xDC00)
|
||||
vI += 2
|
||||
} else {
|
||||
vCodepoint = vHigh.code
|
||||
vI += 1
|
||||
}
|
||||
inHost.dispatchKeyEvent(
|
||||
androidx.compose.ui.input.key.KeyEvent(
|
||||
key = androidx.compose.ui.input.key.Key.Unknown,
|
||||
type = androidx.compose.ui.input.key.KeyEventType.KeyDown,
|
||||
codePoint = vCodepoint,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
// WindowArchitectureOwner -> WindowArchitectureOwner.kt
|
||||
// BackNavigationInput / dispatchTypedText -> WindowInputHelpers.kt
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.compose.sdl
|
||||
|
||||
import kotlinx.cinterop.toKString
|
||||
import sdl3.SDL_GetPerformanceCounter
|
||||
import sdl3.SDL_GetPerformanceFrequency
|
||||
import sdl3.SDL_GetTicks
|
||||
|
||||
// ==================
|
||||
// MARK: FrameProfiler
|
||||
// ==================
|
||||
|
||||
/** CDN_PROFILE=1 - per-phase timings, printed every ~2s of rendered frames.
|
||||
A named-phase SINGLETON so both the main loop (events / app / pump / render)
|
||||
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
|
||||
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
|
||||
the elapsed since the last mark/phase to that name. Measure first, optimize
|
||||
second - see ROADMAP.md. */
|
||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
internal object FrameProfiler {
|
||||
// null until first checked; then true/false for the run's lifetime.
|
||||
private var fEnabled: Boolean? = null
|
||||
// Output file - resolved once from CDN_PROFILE. Writing to a file (not
|
||||
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
|
||||
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
|
||||
// the cwd; CDN_PROFILE=<path> → that path.
|
||||
private var fPath: String = "cdn_profile.log"
|
||||
val enabled: Boolean
|
||||
get() = fEnabled ?: run {
|
||||
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
|
||||
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
|
||||
(vEnv != null).also { fEnabled = it }
|
||||
}
|
||||
|
||||
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
|
||||
// Insertion-ordered so the printed line follows the call order.
|
||||
private val fSum = LinkedHashMap<String, Double>()
|
||||
private val fMax = LinkedHashMap<String, Double>()
|
||||
private var fFrames = 0
|
||||
private var fLastPrintMs = SDL_GetTicks()
|
||||
private var fMark = 0uL
|
||||
|
||||
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
|
||||
|
||||
fun phase(inName: String) {
|
||||
if (!enabled) return
|
||||
val vNow = SDL_GetPerformanceCounter()
|
||||
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
|
||||
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
|
||||
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
|
||||
fMark = vNow
|
||||
}
|
||||
|
||||
fun frameDone(inRendered: Boolean) {
|
||||
if (!enabled) return
|
||||
if (inRendered) fFrames++
|
||||
val vNowMs = SDL_GetTicks()
|
||||
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
|
||||
val vParts = fSum.keys.map { vName ->
|
||||
val vAvg = (fSum[vName] ?: 0.0) / fFrames
|
||||
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
|
||||
}
|
||||
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + "\n"
|
||||
val vFile = platform.posix.fopen(fPath, "a")
|
||||
if (vFile != null) {
|
||||
platform.posix.fputs(vLine, vFile)
|
||||
platform.posix.fclose(vFile)
|
||||
}
|
||||
fSum.clear(); fMax.clear()
|
||||
fFrames = 0
|
||||
fLastPrintMs = vNowMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CDN_FORCERENDER=1 forces every frame to render so sustained steady-state
|
||||
timings can be measured on otherwise-idle screens (see TOOLING.md, frame
|
||||
profiler). */
|
||||
internal val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
+4
-4
@@ -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()
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.compose.sdl
|
||||
|
||||
import androidx.lifecycle.enableSavedStateHandles
|
||||
|
||||
// ==================
|
||||
// MARK: WindowArchitectureOwner
|
||||
// ==================
|
||||
|
||||
/** Per-window architecture-components owner, modeled on upstream desktop's
|
||||
DefaultArchitectureComponentsOwner (compose/ui skikoMain
|
||||
PlatformOwnerProvider.skiko.kt): one object implements LifecycleOwner +
|
||||
ViewModelStoreOwner + SavedStateRegistryOwner (+ the SavedState-aware
|
||||
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) -
|
||||
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). */
|
||||
internal class WindowArchitectureOwner :
|
||||
androidx.lifecycle.LifecycleOwner,
|
||||
androidx.lifecycle.ViewModelStoreOwner,
|
||||
androidx.lifecycle.HasDefaultViewModelProviderFactory,
|
||||
androidx.savedstate.SavedStateRegistryOwner {
|
||||
|
||||
override val lifecycle = androidx.lifecycle.LifecycleRegistry.createUnsafe(this)
|
||||
override val viewModelStore = androidx.lifecycle.ViewModelStore()
|
||||
|
||||
private val savedStateController = androidx.savedstate.SavedStateRegistryController.create(this)
|
||||
override val savedStateRegistry: androidx.savedstate.SavedStateRegistry
|
||||
get() = savedStateController.savedStateRegistry
|
||||
|
||||
override val defaultViewModelProviderFactory = androidx.lifecycle.SavedStateViewModelFactory()
|
||||
override val defaultViewModelCreationExtras: androidx.lifecycle.viewmodel.CreationExtras
|
||||
get() = androidx.lifecycle.viewmodel.MutableCreationExtras().also {
|
||||
it[androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY] = this
|
||||
it[androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY] = this
|
||||
}
|
||||
|
||||
init {
|
||||
savedStateController.performAttach()
|
||||
savedStateController.performRestore(null)
|
||||
// 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
|
||||
// runs enableSavedStateHandles() during composition (nav3's decorators,
|
||||
// rememberViewModelStoreOwner, …) requires INITIALIZED/CREATED, and
|
||||
// upstream desktop windows likewise compose first and resume after.
|
||||
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
/** Focus/visibility-driven state (see WindowInstance.onActivationEvent).
|
||||
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 &&
|
||||
lifecycle.currentState != inState
|
||||
) {
|
||||
lifecycle.currentState = inState
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.DESTROYED
|
||||
viewModelStore.clear()
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
@file:OptIn(androidx.compose.ui.InternalComposeUiApi::class)
|
||||
|
||||
package com.compose.sdl
|
||||
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import com.compose.sdl.node.ComposeRootHost
|
||||
|
||||
// ==================
|
||||
// MARK: Input helpers
|
||||
// ==================
|
||||
|
||||
/** Escape → back: port of upstream desktop's BackNavigationEventInput. An
|
||||
unconsumed Escape KeyDown completes a back navigation on the dispatcher
|
||||
this input is registered with (BackHandler / PredictiveBackHandler
|
||||
consumers: m3 SearchBar collapse, dialog dismissal, …). */
|
||||
internal class BackNavigationInput : androidx.navigationevent.NavigationEventInput() {
|
||||
fun onKeyEvent(inEvent: androidx.compose.ui.input.key.KeyEvent): Boolean {
|
||||
if (inEvent.type == androidx.compose.ui.input.key.KeyEventType.KeyDown &&
|
||||
inEvent.key == androidx.compose.ui.input.key.Key.Escape
|
||||
) {
|
||||
dispatchOnBackCompleted()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-dispatches committed text (SDL TEXT_INPUT) as one synthetic typed
|
||||
KeyDown per Unicode codepoint (surrogate pairs folded). Key.Unknown +
|
||||
codePoint + no modifiers matches both vendored text stacks' isTypedEvent
|
||||
criteria; the SDL key mapper leaves codePoint = 0 on real key events, so
|
||||
the physical KeyDown and the synthetic one can never double-insert. */
|
||||
internal fun dispatchTypedText(inHost: ComposeRootHost, inText: String) {
|
||||
var vI = 0
|
||||
while (vI < inText.length) {
|
||||
val vHigh = inText[vI]
|
||||
val vCodepoint: Int
|
||||
if (vHigh.isHighSurrogate() && vI + 1 < inText.length && inText[vI + 1].isLowSurrogate()) {
|
||||
vCodepoint = 0x10000 + ((vHigh.code - 0xD800) shl 10) + (inText[vI + 1].code - 0xDC00)
|
||||
vI += 2
|
||||
} else {
|
||||
vCodepoint = vHigh.code
|
||||
vI += 1
|
||||
}
|
||||
inHost.dispatchKeyEvent(
|
||||
androidx.compose.ui.input.key.KeyEvent(
|
||||
key = androidx.compose.ui.input.key.Key.Unknown,
|
||||
type = androidx.compose.ui.input.key.KeyEventType.KeyDown,
|
||||
codePoint = vCodepoint,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.compose.sdl:foundation-layout>
|
||||
// Library unique name: <com.bitsycore.compose.foundation:foundation-layout>
|
||||
open annotation class androidx.compose.foundation.layout/ExperimentalFlexBoxApi : kotlin/Annotation { // androidx.compose.foundation.layout/ExperimentalFlexBoxApi|null[0]
|
||||
constructor <init>() // androidx.compose.foundation.layout/ExperimentalFlexBoxApi.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -13,8 +13,7 @@ plugins {
|
||||
alias(libs.plugins.kotlin.plugin.compose)
|
||||
}
|
||||
|
||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
||||
val vHostSupportsMingw: Boolean by rootProject.extra
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.compose.sdl:foundation>
|
||||
// Library unique name: <com.bitsycore.compose.foundation:foundation>
|
||||
open annotation class androidx.compose.foundation.lazy.grid/LazyGridScopeMarker : kotlin/Annotation { // androidx.compose.foundation.lazy.grid/LazyGridScopeMarker|null[0]
|
||||
constructor <init>() // androidx.compose.foundation.lazy.grid/LazyGridScopeMarker.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
||||
|
||||
// :foundation — androidx.compose.foundation.* AND androidx.compose.animation.*
|
||||
// (non-core) vendored VERBATIM from upstream. Sits on :core (which still owns
|
||||
// androidx.compose.ui.*, both renderers, cinterops) + :animation-core.
|
||||
// :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.
|
||||
//
|
||||
// Why the two upstream modules (`:animation` and `:foundation`) are combined
|
||||
// here: upstream splits `foundation-layout` off from `foundation`, then has
|
||||
@@ -14,15 +14,15 @@ 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
|
||||
// stub returning false. The upstream skiko/sdl per-renderer actuals were dropped
|
||||
// when :foundation was split from :core: expect/actual must live in the same
|
||||
// module, and duplicating the skikoRenderer/sdlRenderer source-set hierarchy on
|
||||
// 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
|
||||
// :foundation just to read `LocalSystemTheme` on the Skia path was more build
|
||||
// wiring than is worth it for one function no in-repo app calls.
|
||||
//
|
||||
@@ -34,8 +34,7 @@ plugins {
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
}
|
||||
|
||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
||||
val vHostSupportsMingw: Boolean by rootProject.extra
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64()
|
||||
|
||||
@@ -517,6 +517,11 @@ skikoMain/kotlin/ -> src/vendor/native/kotlin/
|
||||
!skikoMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/foundation/Clickable.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/foundation/DarkTheme.skiko.kt
|
||||
# Scrollbar.skiko.kt + v2/Scrollbar.skiko.kt are MANUALLY VENDORED to
|
||||
# src/nativeMain/.../foundation/{,v2}/ (VENDOR-BASE headers there): the flat
|
||||
# native source set can't hold the v2 `expect fun runBlockingIfPossible` and its
|
||||
# actual together, so it's inlined to runBlocking. Refused here so sync doesn't
|
||||
# shadow the hand-vendored copies.
|
||||
!skikoMain/kotlin/androidx/compose/foundation/Scrollbar.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
-342
@@ -1,342 +0,0 @@
|
||||
package com.compose.sdl.scrollbar
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// ==================
|
||||
// MARK: Scrollbar (Compose Desktop-style)
|
||||
// ==================
|
||||
|
||||
/** 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
|
||||
contentAlignment; the fillMaxSize content fills the rest:
|
||||
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
|
||||
val state = rememberScrollState()
|
||||
Column(Modifier.fillMaxSize().verticalScroll(state)) { ... }
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(state),
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
|
||||
Always visible (no auto-hide); the thumb tweens unhoverColor -> hoverColor on
|
||||
hover over hoverDurationMillis, matching upstream. Drag the thumb to scroll;
|
||||
click the track to page. */
|
||||
|
||||
@Immutable
|
||||
class ScrollbarStyle(
|
||||
val minimalHeight: Dp,
|
||||
val thickness: Dp,
|
||||
val shape: Shape,
|
||||
val hoverDurationMillis: Int,
|
||||
val unhoverColor: Color,
|
||||
val hoverColor: Color,
|
||||
)
|
||||
|
||||
/** Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
|
||||
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
|
||||
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
|
||||
minimalHeight = 16.dp,
|
||||
thickness = 8.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
hoverDurationMillis = 300,
|
||||
unhoverColor = Color.Black.copy(alpha = 0.12f),
|
||||
hoverColor = Color.Black.copy(alpha = 0.50f),
|
||||
)
|
||||
|
||||
val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
|
||||
|
||||
// ==================
|
||||
// MARK: ScrollbarAdapter
|
||||
// ==================
|
||||
|
||||
/** Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
|
||||
contentSize / viewportSize are along the scroll axis; the thumb is sized by
|
||||
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
|
||||
interface ScrollbarAdapter {
|
||||
val scrollOffset: Double
|
||||
val contentSize: Double
|
||||
val viewportSize: Double
|
||||
suspend fun scrollTo(scrollOffset: Double)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberScrollbarAdapter(scrollState: ScrollState): ScrollbarAdapter =
|
||||
remember(scrollState) { ScrollStateScrollbarAdapter(scrollState) }
|
||||
|
||||
private class ScrollStateScrollbarAdapter(private val state: ScrollState) : ScrollbarAdapter {
|
||||
override val scrollOffset: Double get() = state.value.toDouble()
|
||||
override val contentSize: Double get() = state.contentSize.toDouble()
|
||||
override val viewportSize: Double get() = state.viewportSize.toDouble()
|
||||
override suspend fun scrollTo(scrollOffset: Double) {
|
||||
// Scrollbar thumb drag → instant scroll-to. dispatchRawDelta takes a
|
||||
// delta in pixels; convert the target offset into a delta from where
|
||||
// we are now (clamping at the scroll edges is the dispatchRawDelta
|
||||
// contract, so we don't have to coerce here).
|
||||
state.dispatchRawDelta((scrollOffset - state.value.toDouble()).toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
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. */
|
||||
@Composable
|
||||
fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
|
||||
remember(scrollState) { LazyListScrollbarAdapter(scrollState) }
|
||||
|
||||
private class LazyListScrollbarAdapter(private val state: LazyListState) : ScrollbarAdapter {
|
||||
// Mean main-axis size of the currently-visible items, in px (>= 1 to avoid /0).
|
||||
private fun averageItemSize(): Double {
|
||||
val vVisible = state.layoutInfo.visibleItemsInfo
|
||||
if (vVisible.isEmpty()) return 0.0
|
||||
val vSpan = (vVisible.last().offset + vVisible.last().size) - vVisible.first().offset
|
||||
return (vSpan.toDouble() / vVisible.size).coerceAtLeast(1.0)
|
||||
}
|
||||
|
||||
override val scrollOffset: Double
|
||||
get() = averageItemSize() * state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset
|
||||
|
||||
override val contentSize: Double
|
||||
get() = averageItemSize() * state.layoutInfo.totalItemsCount
|
||||
|
||||
override val viewportSize: Double
|
||||
get() = (state.layoutInfo.viewportEndOffset - state.layoutInfo.viewportStartOffset).toDouble()
|
||||
|
||||
override suspend fun scrollTo(scrollOffset: Double) {
|
||||
val vDistance = scrollOffset - this.scrollOffset
|
||||
// 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
|
||||
// sidesteps the estimate entirely. This is what Compose Desktop's own
|
||||
// LazyListScrollbarAdapter does, and for the same reason.
|
||||
if (abs(vDistance) <= viewportSize) {
|
||||
state.scrollBy(vDistance.toFloat())
|
||||
return
|
||||
}
|
||||
// Far jump (track-click paging past a viewport): no exact geometry to lean
|
||||
// on, so estimate an index/offset from the average and snap to it.
|
||||
val vAvg = averageItemSize()
|
||||
if (vAvg <= 0.0) return
|
||||
val vTarget = scrollOffset.coerceAtLeast(0.0)
|
||||
val vTotal = state.layoutInfo.totalItemsCount
|
||||
val vIndex = (vTarget / vAvg).toInt().coerceIn(0, (vTotal - 1).coerceAtLeast(0))
|
||||
val vItemOffset = (vTarget - vIndex * vAvg).toInt().coerceAtLeast(0)
|
||||
state.scrollToItem(vIndex, vItemOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: VerticalScrollbar / HorizontalScrollbar
|
||||
// ==================
|
||||
|
||||
@Composable
|
||||
fun VerticalScrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier = Modifier,
|
||||
reverseLayout: Boolean = false,
|
||||
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = true)
|
||||
|
||||
@Composable
|
||||
fun HorizontalScrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier = Modifier,
|
||||
reverseLayout: Boolean = false,
|
||||
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
|
||||
|
||||
/** Shared impl. The root Box is the track (fixed thickness on the cross axis,
|
||||
filling the main axis from the caller's modifier); a child Box is the thumb,
|
||||
positioned with an offset. Drag + track-click are handled on the track in
|
||||
track-relative coordinates (a stable origin), so the grab point stays under
|
||||
the pointer with no feedback from the thumb moving. interactionSource is
|
||||
accepted for API parity; hover here is driven by Modifier.hoverable. */
|
||||
@Composable
|
||||
private fun Scrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier,
|
||||
reverseLayout: Boolean,
|
||||
style: ScrollbarStyle,
|
||||
@Suppress("UNUSED_PARAMETER") interactionSource: MutableInteractionSource,
|
||||
isVertical: Boolean,
|
||||
) {
|
||||
val vScope = rememberCoroutineScope()
|
||||
var vTrackPx by remember { mutableStateOf(0) }
|
||||
val vHoverSource = remember { MutableInteractionSource() }
|
||||
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
|
||||
// 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.
|
||||
// Route pixel-sized modifiers through the current density so `.dp` and the
|
||||
// pixel value stay in the same coord space.
|
||||
val vDensity = LocalDensity.current
|
||||
|
||||
val vContent = adapter.contentSize
|
||||
val vViewport = adapter.viewportSize
|
||||
val vOffset = adapter.scrollOffset
|
||||
val vMaxScroll = vContent - vViewport
|
||||
val vScrollable = vMaxScroll > 0.5 && vViewport > 0.0
|
||||
|
||||
val vColor by animateColorAsState(
|
||||
targetValue = if (vHovered || vDraggingThumb) style.hoverColor else style.unhoverColor,
|
||||
animationSpec = tween(style.hoverDurationMillis),
|
||||
)
|
||||
|
||||
val vRoot = (if (isVertical) modifier.width(style.thickness) else modifier.height(style.thickness))
|
||||
.onSizeChanged { vTrackPx = if (isVertical) it.height else it.width }
|
||||
.hoverable(vHoverSource)
|
||||
|
||||
Box(modifier = vRoot) {
|
||||
if (!vScrollable || vTrackPx <= 0) return@Box
|
||||
|
||||
// ============
|
||||
// Thumb metrics (px)
|
||||
val vMinLen = style.minimalHeight.value.toInt()
|
||||
val vThumbLen = (vTrackPx * (vViewport / vContent)).roundToInt()
|
||||
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||
val vTravel = vTrackPx - vThumbLen
|
||||
val vRawPos = if (vTravel <= 0) 0 else (vTravel * (vOffset / vMaxScroll)).roundToInt()
|
||||
val vThumbPos = (if (reverseLayout) vTravel - vRawPos else vRawPos).coerceIn(0, vTravel)
|
||||
|
||||
val vThumbLenDp = with(vDensity) { vThumbLen.toDp() }
|
||||
val vThumbMod = (
|
||||
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
|
||||
else Modifier.offset { IntOffset(vThumbPos, 0) }.height(style.thickness).width(vThumbLenDp)
|
||||
)
|
||||
.clip(style.shape)
|
||||
.background(vColor)
|
||||
|
||||
Box(modifier = vThumbMod)
|
||||
|
||||
// ============
|
||||
// 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
|
||||
// wheel-scroll still hit-tests against the thumb's current position.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchTrackSize(isVertical, with(vDensity) { vTrackPx.toDp() }, style.thickness)
|
||||
.pointerInput(isVertical, adapter, vTrackPx, reverseLayout) {
|
||||
awaitEachGesture {
|
||||
val vDown = awaitFirstDown(requireUnconsumed = false)
|
||||
vDown.consume()
|
||||
|
||||
// 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
|
||||
val vMaxScrollPx = vContentPx - vViewportPx
|
||||
if (vMaxScrollPx <= 0.0 || vTrackPx <= 0) return@awaitEachGesture
|
||||
val vMinLen = style.minimalHeight.value.toInt()
|
||||
val vLen = (vTrackPx * (vViewportPx / vContentPx)).roundToInt()
|
||||
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||
val vTravelPx = vTrackPx - vLen
|
||||
val vRaw = if (vTravelPx <= 0) 0
|
||||
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
|
||||
val vPos = (if (reverseLayout) vTravelPx - vRaw else vRaw).coerceIn(0, vTravelPx)
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
fun scrollToTrackPos(inThumbTop: Int) {
|
||||
if (vTravelPx <= 0) return
|
||||
val vLiveMax = adapter.contentSize - adapter.viewportSize
|
||||
if (vLiveMax <= 0.0) return
|
||||
val vClamped = inThumbTop.coerceIn(0, vTravelPx)
|
||||
val vFrac = (if (reverseLayout) vTravelPx - vClamped else vClamped).toDouble() / vTravelPx
|
||||
vScope.launch { adapter.scrollTo(vFrac * vLiveMax) }
|
||||
}
|
||||
|
||||
val vPress = (if (isVertical) vDown.position.y else vDown.position.x).toInt()
|
||||
val vGrab: Int
|
||||
if (vPress in vPos..(vPos + vLen)) {
|
||||
vDraggingThumb = true
|
||||
vGrab = vPress - vPos
|
||||
} else {
|
||||
// Page toward the click by one viewport.
|
||||
vDraggingThumb = false
|
||||
vGrab = 0
|
||||
val vDir = if (vPress < vPos) -1.0 else 1.0
|
||||
vScope.launch { adapter.scrollTo(adapter.scrollOffset + vDir * vViewportPx) }
|
||||
}
|
||||
|
||||
// Follow the pressed pointer to release, consuming every
|
||||
// change so the interaction can't leak. Only a thumb grab
|
||||
// drags-to-scroll; a track press just consumes.
|
||||
do {
|
||||
val vEvent = awaitPointerEvent()
|
||||
val vChange = vEvent.changes.firstOrNull { it.id == vDown.id } ?: break
|
||||
if (vDraggingThumb) {
|
||||
val vCur = (if (isVertical) vChange.position.y else vChange.position.x).toInt()
|
||||
scrollToTrackPos(vCur - vGrab)
|
||||
}
|
||||
vChange.consume()
|
||||
} while (vEvent.changes.any { it.id == vDown.id && it.pressed })
|
||||
|
||||
vDraggingThumb = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
|
||||
comes from the caller pre-converted via LocalDensity (Option-B: layout
|
||||
coords are physical px, so px-to-dp goes through the current density). */
|
||||
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
|
||||
if (inVertical) this.width(inThickness).height(inTrackDp)
|
||||
else this.height(inThickness).width(inTrackDp)
|
||||
+2
-2
@@ -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 /
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
+1
-1
@@ -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
-3
@@ -2,9 +2,10 @@ package androidx.compose.foundation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import sdl3.SDL_GetSystemTheme
|
||||
import sdl3.SDL_SystemTheme
|
||||
import com.compose.sdl.res.systemThemeIsDarkProvider
|
||||
|
||||
// System dark-theme flag via the platform-env seam (installed by the SDL layer),
|
||||
// so :foundation carries no dependency on the sdl3 cinterop.
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
internal actual fun _isSystemInDarkTheme(): Boolean = SDL_GetSystemTheme() == SDL_SystemTheme.SDL_SYSTEM_THEME_DARK
|
||||
internal actual fun _isSystemInDarkTheme(): Boolean = systemThemeIsDarkProvider?.invoke() ?: false
|
||||
|
||||
+1
-1
@@ -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
|
||||
// ==================
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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.)
|
||||
|
||||
+1191
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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 {
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*/
|
||||
|
||||
+2
-2
@@ -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).
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+1
-1
@@ -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.
|
||||
*/
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*/
|
||||
|
||||
+2
-2
@@ -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`.
|
||||
*/
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user