Compare commits

..
21 Commits
Author SHA1 Message Date
Bitsy 8f96ba9478 docs(roadmap): record demo 70fps cause (draw-bound sidebar re-tessellation)
Investigated the demo-70fps / apidemo-144fps question with CDN_PROFILE:
both apps use vsync(1), so it's NOT a vsync-mode difference. The full
demo is DRAW-bound (draw ~34ms cold / ~14ms steady, present ~1.3ms) —
it re-tessellates the whole always-present sidebar (30+ rows of text +
icons) every rendered frame, capping ~70 fps. apidemo's lighter chrome
fits the 6.9ms/144Hz budget and hits the vsync ceiling. '70≈144/2' is
coincidence. Concrete motivation for retained layers / dirty regions.
2026-07-14 18:43:23 +02:00
Bitsy 5a9e7b0f7f docs(roadmap): mark layer rotation verified-working; warn item-4 list is stale
Layer rotation renders correctly (demo GraphicsLayer Rotation-live +
Combined rows) — like gradients, the claimed gap was already fixed.
Added a note to verify item-4 claims against the parity heatmaps before
acting, since the list predates several fixes.
2026-07-14 18:28:55 +02:00
Bitsy 5dfd9200f8 fix(sdl): grid-mesh gradient fills so radial/sweep aren't flat
The brush samplers (linear/radial/sweep with stops) were fully
implemented, but fills sampled the brush only at the shape's CORNERS and
let SDL interpolate. A square's four corners are equidistant from its
centre → identical radial/sweep t → the fill rendered as ONE flat colour
(the demo Brushes radial was solid green: center==corner pixel). Linear
survived because corners differ along the axis.

Gradient fills (brush !is SolidColor) now tessellate rect and round-rect
bodies into an ~8px-cell grid (emitRectMesh, adaptive 1..24 cells/axis),
so the sampler is evaluated across the interior and SDL interpolates
per-cell. Solid fills keep the single-quad fast path. Round-rect corner
arcs already fan from their centre.

Verified: demo Brushes radial now shows centre→edge falloff matching the
JVM render (was flat); parity Brushes 22.75%→21.72% (swatches are a
small pixel fraction; the rest is font drift).
2026-07-14 18:27:07 +02:00
Bitsy 8c7168a1a9 feat(window): profiler render sub-phases + file output; reprioritize roadmap from data
FrameProfiler is now a named-phase singleton writing to a FILE
(CDN_PROFILE=1 -> cdn_profile.log, or =<path>) not stdout — so
GUI-subsystem apps (the demo links --subsystem,windows) profile too.
renderFrame splits into layout / draw / present sub-phases.

Measured: light screen = 75 Hz, present ~10ms (vsync wait), draw ~1.4ms
(GPU-idle); heavy 84-bubble sheet ~32ms = DRAW, not masks. ROADMAP item
2 re-ordered: dirty-region rendering + retained layers promoted (the
real lever); clip-target right-sizing demoted to memory-only/low.
2026-07-14 18:16:27 +02:00
Bitsy 377f3aa775 docs: consolidated tooling index in CLAUDE.md + simple redirecting table in README
CLAUDE.md gains a '## Tooling — what to reach for' section: a when-to-use
table covering build-sdl, compose-fork sync, compose-coverage,
material-symbols, parity, probe, the CDN_PROFILE profiler, the bridge
plugin and the demo CLI probes — each pointing at its own README. The
parity/profiler/probe entries are expanded inline (parity keeps the
'what the %differ means' detail). README gets a simpler table redirecting
to each tool's README + the bridge/CLAUDE detail.
2026-07-14 17:18:11 +02:00
Bitsy 662975c802 feat(probe): reusable native-window input+capture driver (ROADMAP item 5)
scripts/probe/ — launch a native app, send WINDOW-CLIENT-relative input
(click/hover/hold, fractional coords addressed by process name so it
ignores window position/focus), capture via PrintWindow (works occluded).
The packaged form of the ad-hoc rigs that reproduced the square-on-click
and TLS-chain bugs. Verified: demo --screen=Shapes hover+capture yields
real 1000x700 content.
2026-07-14 17:15:19 +02:00
Bitsy 3b420a7bad docs(CLAUDE): document the parity harness + how to read the %differ
Explains running scripts/parity, the one-process JVM / per-screen native
mechanics, the build/parity outputs, and — the load-bearing part — what
the number means: it's a font-drift-inflated ranking signal, not a
pass/fail score. Ghosted text + dark shapes = normal; a solid bright
block or one-sided shape = regression. Baseline gradient ~2%..32%; known
SDL gaps (solid gradients, effects) inflate specific screens.
2026-07-14 17:09:52 +02:00
Bitsy 2dad6c9bcc fix(parity): use Path.replace so re-runs overwrite prior native PNGs on Windows
Path.rename raised FileExistsError mid-sweep when a screen's
<Name>.native.png already existed from an earlier run (Windows rename
won't overwrite). replace() is an atomic overwrite everywhere.

Full 56-screen sweep now completes: smooth 2%%(Counter)..32%%(Tabs)
distribution, no catastrophic outlier — confirms this session's renderer
changes (lazy clips, batch flush, TLS fix) left no screen blank or
structurally broken. Text density + known SDL parity gaps (solid
gradients, effect differences) explain the top of the ranking.
2026-07-14 17:08:44 +02:00
Bitsy 723f80c8af feat(parity): native-vs-jvm screenshot diff harness (ROADMAP item 5)
A regression net built on the demo's dual-stack design: the same
commonMain screens render on native (SDL/Skia) and on upstream Compose
Desktop (jvm), and scripts/parity/parity.py pixel-diffs them per screen.

- MainJvm gains a headless --screenshot-all=<dir> mode: renders every
  registered screen via ImageComposeScene (density 1, same
  verticalScroll+padding wrapper as MainNative's --screen path) to PNGs
  in ONE jvm process.
- parity.py drives the native exe per screen (--screen/--screenshot),
  diffs against the jvm PNGs with a per-channel tolerance, and writes
  <pct>_<Name>_diff.png (amplified heatmap) + _compare.png (side by
  side) to build/parity/ (gitignored), ranked worst-first by the pct
  filename prefix + report.txt.

The ranking is the signal, not the absolute %: fonts differ between
stacks (text ghosts in the heatmap), so a screen jumping well above its
neighbours/history is the regression. Verified on Buttons/Shapes/Colors
(~16/14/4% — all font drift, structure aligned).
2026-07-14 17:06:08 +02:00
Bitsy 450247175f feat(sdl): deterministic native-resource lifecycle — release queue + bitmap cleaner
ROADMAP.md item 1. SdlImageBitmap textures were NEVER freed (manual SDL
memory the GC nudge can't touch) — the lazy-texture rework made it worse
since the render-target path also leaked. Now:

- NativeReleaseQueue (:ui): thread-safe queue of release actions drained
  on the MAIN thread each frame by the window loop. SDL calls aren't
  thread-safe and both disposal triggers are off-main (a Cleaner runs on
  a GC worker; close() can be reached from other dispatchers), so both
  just ENQUEUE.
- SdlImageBitmap: holds its texture/surface in a by-ref holder with a
  Cleaner that enqueues destruction (backstop) + an explicit close()
  (prompt path). Idempotent — first to run nulls the handles.
- ComposeWindow drains the queue between frames and once at teardown.

This makes the periodic GC.collect() nudge a safeguard rather than the
disposal mechanism (issue #2). Verified end-to-end in the example:
a direct enqueue and a real Cleaner->enqueue->drain cycle both fire.
close()-on-eviction wiring is a documented follow-up (caches hold the
ImageBitmap interface, not the concrete type).
2026-07-14 16:55:33 +02:00
Bitsy 5d6f772a1c fix(sdl): lazily-realized clips use push-time region, not the narrowed current clip
The TLS-chain regression (only the first card + nothing below it, until
a scroll shifted things): realizePendingClips re-intersected each
pending clip's region with the CURRENT fClip. A lazy clip can realize
arbitrarily deep inside descendants whose own clips have since narrowed
fClip — so an OUTER clip (the dialog's rounded Surface) realized with
the innermost descendant's tiny rect as its region. Its offscreen then
composited back only that sliver; every sibling card + the button
drawn into the same offscreen fell outside the blit and vanished.

Each pending clip now realizes with its OWN push-time region
(enclosing-clip-at-push ∩ bbox, already captured in PendingRoundClip),
and the real (already-narrowed) drawing clip is saved across the
realize loop and restored after, so draws into the innermost target
stay correctly clipped.

Reproduced with an outer rounded Surface + height-bounded verticalScroll
of rounded+bordered cards + a button (the TLS dialog's exact shape):
first-card-only before, full list + button after. Demo scroll/card
screens and bubble-wrap unaffected.
2026-07-14 16:37:45 +02:00
Bitsy 575c19f921 feat(window): CDN_PROFILE=1 per-phase frame profiler
Prints avg/max ms per main-loop phase (events / app pump / window pump /
render) every ~2s of rendered frames, via SDL performance counters.
Bubble-wrap sheet under hover: render=32/41ms dominates a 39ms frame —
confirms the renderer, not composition, is the 75Hz bottleneck
(ROADMAP.md item 2).
2026-07-14 16:19:56 +02:00
Bitsy 328663b9e7 docs: renderer roadmap — lifecycle, performance, parity, tooling 2026-07-14 16:14:54 +02:00
Bitsy c9ddd3488d fix(sdl): auto-flush the vertex batch only on triangle boundaries
SDL_RenderGeometry renders floor(count/3) triangles and silently drops
the tail. writeVertex auto-flushed the batch whenever it was full — at
ANY vertex boundary — so a triangle straddling the flush lost its tail
vertices AND left orphan vertices at the head of the next batch,
misaligning every triangle after it: ~350° pac-man circles and glitch
shapes that shifted with every redraw once a frame's geometry outgrew
one batch. Never seen before because clip layers flushed constantly;
bubble-wrap's new unclipped drawBehind sheet (84 bubbles, one giant
batch, ~4 overflows per frame) exposed it — most of the sheet simply
vanished.

Flush now only fires at multiples of 3, and kBatchCapacity drops
8192 → 8190 (a multiple of 3, so the cap lands on a triangle boundary
and the buffer can never overrun).
2026-07-14 16:08:07 +02:00
Bitsy 9958386792 fix(bridge): data.kres zips must not race executable declaration
The resources packaging pre-scanned link-task names in afterEvaluate —
but executables can be declared AFTER that runs, notably by the
bridge's own compose.desktop.native { entryPoint } DSL (also
afterEvaluate, registered later). The scan found no link tasks and
silently packaged nothing: 'data.kres not found' at startup. The old
single-module example masked this with stale build output; a fresh
multi-module layout exposed it.

Zips are now pre-registered lazily for every desktop target/variant and
wired via tasks.matching{}.configureEach — order-independent, and an
unrealised task is free when the target has no executable. (Registering
the zip reactively from inside the link task's configuration callback
is forbidden by Gradle — 'cannot be executed in the current context'.)
Content wiring moved into the zip's own configuration action, which
runs at realisation when the Compose plugin's prepare tasks and the
final compose.resources config exist.

Verified on the restructured multi-module bubble-wrap (shared KMP
library with the new com.android.kotlin.multiplatform.library DSL +
androidApp): packageDebugComposeResourcesMingwX64 runs, data.kres lands
next to shared.exe with the drawable + converted .cvr strings, app
boots with resources loading.
2026-07-14 14:39:29 +02:00
Bitsy f5fae6d32e docs(bridge): document settings-wide vs per-module application
Both worked since the plugin is Plugin<Any> branching on Settings vs
Project, but the module-level path was undocumented and untested —
verified now with the example applied compose-plugin style. Also spells
out that a pluginManagement plugins{} entry only pins the version while
the settings-level plugins{} block is what applies build-wide.
2026-07-14 13:08:39 +02:00
Bitsy b94f1f7bbb feat(bridge): compose.desktop.native { entryPoint } — application{} for native
The native counterpart of compose.desktop { application { mainClass } }:
the bridge grafts a 'native' extension onto the Compose plugin's desktop
extension (extensions are ExtensionAware — no compile-time dependency)
and, in afterEvaluate, declares binaries.executable(entryPoint) on every
Kotlin/Native DESKTOP target (mingw/linux/osx families). Targets that
already declare an executable are skipped, so manual configuration wins.

KGP access is reflective for the same reason as the resources packaging:
settings-applied plugins live in a parent classloader that cannot see
project-plugin types.

Replaces the last piece of consumer boilerplate — bubble-wrap's build
shrinks to targets + dependencies + the two entry points, verified
end-to-end (executable declared by the DSL links, bundles data.kres,
boots).
2026-07-14 13:04:38 +02:00
Bitsy ffbb87eddd perf(sdl): containment-gate text draws under pending clips
Text runs no longer force mask realization unconditionally: like other
draws they pass a containment test against the pending rounded clips
(with a 2px margin for glyph overhang / AA bleed). Small centred labels
inside circle-clipped nodes — a popped bubble's tiny text — previously
cost a full offscreen mask pass each, per frame.

Bubble-wrap probe (84 bubbles, half popped, continuous animation):
23 FPS before the lazy-clip series, 39 after it.
2026-07-14 12:47:18 +02:00
Bitsy 63fad4083f fix(sdl): lazily-realized clip masks belong to the clip's save-frame
The press artifact ('square on click, hover fine'): a press draws TWO
overhanging circles — the state layer, then the ripple animation — each
inside its own inner clipRect{} save-frame. The first one realized the
pending mask INSIDE the ripple's inner frame, whose saved clipLayers
count predated the realization, so that frame's restore() composited
and closed the mask early; the second circle then drew with no pending
and no mask — a faint square filling the clip's rect region. Hover only
draws one circle, hence always looked correct.

A lazily-realized mask is now attributed to the save-frame that PUSHED
the clip: PendingRoundClip records fStack.size at push, and realization
bumps the clipLayers threshold of every save-state entered since, so
only the owning frame's restore composites the layer — matching where
the eager implementation would have opened it.

Reproduced and verified with an automated press-hold rig (locate bubble
by colour, hold, capture mid-hold): square before, round after; eager-
forced realization used as the discriminating experiment.
2026-07-14 12:44:15 +02:00
Bitsy 872d740ce7 perf(sdl): lazy rounded clips — record, prove-safe, or realize on demand
Opening an offscreen mask per rounded clip is the most expensive thing
the SDL canvas does (two render-target switches + clear + corner cut +
feather + composite) — and Compose's dominant idiom
clip(shape).background(color) never needs one. clipRoundRect now only
RECORDS the clip (the rect part applies immediately); each draw either
proves itself safe or realizes the mask on demand:

- full-cover solid rect over the innermost pending clip (the background
  idiom) -> emitted directly as an antialiased rounded-rect fill,
- content whose device aabb passes a 4-corner ellipse containment test
  against every pending outline -> drawn as-is under the rect clip,
- anything genuinely crossing an arc (overhanging text, expanding
  ripples, difference clips, shadows) -> realizes the offscreen masks
  (outermost first) exactly as before.

save/restore tracks pending depth; never-realized pendings pop for
free. Measured on the bubble-wrap sheet (84 circle-clipped bubbles +
highlight dots, continuous animation): 23 -> 37 FPS.

Known open issue under investigation: a press on a bubble can show a
square state-layer/ripple artifact (hover is correct) — suspected
custom-blend fallback in the mask feather pass; follow-up commit.
2026-07-14 12:27:58 +02:00
Bitsy 4c2d46429d fix(publish): Windows owns the root metadata modules — 0.1.15 roots had no mingwX64 variants
The KotlinMultiplatform (root) publications ran on the macOS job, and a
macOS host never declares the mingwX64 target (vHostSupportsMingw) — so
every published root .module carried variants for linux/macos/jvm but
NOT mingw_x64. Windows consumers of the bridge resolved the root fine
and then found 'No matching variant' for every coordinate: v0.1.15 (and
every earlier release) is unusable from the published root modules on
Windows. Local mavenLocal flows never caught it because this repo's dev
box IS Windows, whose roots include all targets.

Two changes:
- createRenderBackend / rendererPreferredGpuMode become a real
  expect/actual seam (:ui nativeMain expect, actuals in each renderer
  source set). They were duplicate no-expect declarations — fine
  per-target, but shared nativeMain METADATA couldn't see them when a
  host's targets span both renderers, which is exactly what blocked
  :window's metadata compilation on Windows hosts.
- The publish workflow moves the KotlinMultiplatform + Jvm + bridge
  plugin publications to the WINDOWS job (the only host declaring every
  target) and updates the delete-then-retry ownership map to match.

Verified on Windows: repo-wide common+native metadata sweep green for
all library modules, full publishKotlinMultiplatformPublicationToMavenLocal
(root .module variants now cover linux_x64/linux_arm64/macos_arm64/
mingw_x64/jvm), demo links + boots, and the bubble-wrap consumer example
resolves and links mingwX64 through the bridge against the local roots.
2026-07-14 10:34:50 +02:00
25 changed files with 1350 additions and 88 deletions
+17 -16
View File
@@ -18,9 +18,8 @@ jobs:
strategy:
fail-fast: false
# Each host runs only the publish tasks Gradle actually generated for it.
# macOS also publishes the shared kotlinMultiplatform metadata module.
# No library module declares jvm() today — apps do their own JVM parity
# build against upstream Compose, so JVM isn't part of the publication.
# 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,
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
# skipped for the app build because SDL3 static libs on the ubuntu runner
@@ -31,16 +30,8 @@ jobs:
host: macos
target: MacosArm64
exe_ext: kexe
# jvm publication: only :material-symbols declares a jvm() target
# (its common API renders via upstream Compose on JVM) — publish
# its host-independent jar from the same host as the metadata.
# :compose-desktop-native-bridge (the consumer-side bridge Gradle plugin +
# its plugin marker) is host-independent too — same job.
tasks: >-
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
publishMacosArm64PublicationToGitHubPackagesRepository
publishJvmPublicationToGitHubPackagesRepository
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
- runner: ubuntu-22.04
host: linux
target: LinuxX64
@@ -52,8 +43,18 @@ jobs:
host: windows
target: MingwX64
exe_ext: exe
# 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
# 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.
tasks: >-
publishMingwX64PublicationToGitHubPackagesRepository
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
publishJvmPublicationToGitHubPackagesRepository
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
runs-on: ${{ matrix.runner }}
@@ -148,8 +149,8 @@ jobs:
# already completed; you can't tell from the 409 alone. The fix: between
# attempts, DELETE this host's package versions for the current release
# and re-upload from a clean slate. Each host owns DISJOINT package
# names (roots / -macosarm64 / -jvm / bridge on macOS, -mingwx64 on
# Windows, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
# a 403 on delete just degrades to the old blind-retry behaviour.
- name: Publish to GitHub Packages
@@ -161,9 +162,9 @@ jobs:
# 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"
case "${{ matrix.host }}" in
macos) SUFFIXES="_ROOT_ -macosarm64" ;;
macos) SUFFIXES="-macosarm64" ;;
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
windows) SUFFIXES="-mingwx64" ;;
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
esac
host_packages() {
for m in $MODULES; do
@@ -171,7 +172,7 @@ jobs:
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
done
done
if [ "${{ matrix.host }}" = "macos" ]; then
if [ "${{ matrix.host }}" = "windows" ]; then
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
+92 -6
View File
@@ -275,11 +275,15 @@ commonMain
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
```
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are declared identically
in both `skikoRendererMain` and `sdlRendererMain`. `:window` calls them straight
from `:ui` — no `expect`/`actual`, no factory layer — and the right impl
resolves because **only one of the two renderer source sets is attached to a
given target**. Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
`:ui`'s nativeMain with `actual`s in BOTH `skikoRendererMain` and
`sdlRendererMain` — unambiguous because **only one of the two renderer source
sets is attached to a given target**. (They used to be plain duplicate
declarations with no expect; that compiled per-target but shared nativeMain
METADATA couldn't see them on a host whose targets span both renderers, which
blocked the WINDOWS host from producing :window's KotlinMultiplatform
publication — and Windows must publish the root modules, see the publish
workflow.) Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
**not even created**, so Gradle has nothing to warn about and Skiko is never
pulled in.
@@ -454,6 +458,85 @@ build tell you what broke.
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
ship drop-in equivalents for our anchor / scrim patterns).
## Tooling — what's available and when to reach for it
Index of the repo's tooling. Each entry says when to use it and points at its
own README for detail; the parity/profiler/probe entries are expanded below
since they're newer.
| Tool | Reach for it when | Detail |
|------|-------------------|--------|
| `python scripts/build-sdl/build-all.py` | building/refreshing the static SDL3/TTF/image/FreeType libs under `libs/` (once per host, or after bumping `build-sdl.properties`) | "Building" above |
| `scripts/compose-fork/sync.sh` | re-syncing vendored upstream `androidx.compose.*` after a fresh checkout or a `compose.properties` ref bump; `format-manifest.py` to re-align a `compose-fork.txt` | "Vendor sync workflow" above + `scripts/compose-fork/README.md` |
| `./gradlew apiDump && python scripts/compose-coverage.py` | measuring how much upstream public API the port actually covers, per module (`--missing <module>` lists uncovered decls) | "Vendoring" above |
| `scripts/generate-material-symbols.py` / `subset-material-symbols.py` | regenerating the Material Symbols codepoints, or hb-subsetting bundled icon fonts to used glyphs (the latter runs automatically in app Zip tasks under `-PsubsetIcons`) | — |
| **`scripts/parity/parity.py`** | after ANY renderer/layout change: catch a screen that visually diverged native-vs-JVM (missing content, wrong shape/colour, broken clip) | `scripts/parity/README.md` + below |
| **`scripts/probe/probe.py`** | reproducing a specific interaction bug (click/hover/hold at a point) or grabbing one screen's pixels deterministically | `scripts/probe/README.md` + below |
| **`CDN_PROFILE=1 <app>`** | finding where a slow frame goes (per-phase main-loop timings) before optimizing | below |
| bridge plugin (`com.bitsycore.compose-desktop-native.bridge`) | consuming the published klibs from a third-party app | `gradle-plugin/compose-desktop-native-bridge/README.md` |
| `demo --screen=<Name>` / `--screenshot=` / `--nav3test` / `--backtest` / `--multiwintest` | driving one screen headless, or the regression probes for nav3 / predictive-back / multi-window | `demo/src/nativeMain/kotlin/MainNative.kt` |
Whole-project renderer-touching change → run **parity** (broad net). Chasing
one reported interaction → **probe** (targeted). Slow → **profiler** first,
optimize second. See `ROADMAP.md` for the renderer work these support.
### Frame profiler — `CDN_PROFILE=1`
Set the env var and run any native app; every ~2 s of rendered frames it prints
avg/max ms per main-loop phase (`events` / `app` pump / `pump` per-window /
`render`). Implemented in `ComposeWindow.kt` via SDL performance counters. Use
it to confirm WHERE time goes before touching draw code — e.g. it showed
`render` is ~32 ms of a 39 ms bubble-wrap frame, i.e. the renderer, not
composition, is the bottleneck.
### Interaction probe — `scripts/probe/`
Launches a native app, sends **window-client-relative** input (click / hover /
hold, fractional coords addressed by process name so it ignores window
position/focus) and captures the client area via `PrintWindow` (works even
when occluded). The packaged form of the rigs that reproduced the
square-on-click and TLS-chain bugs. Windows-only. See its README.
### Parity harness — native-vs-JVM screenshot diff (`scripts/parity/`)
`:demo` renders the **same commonMain screens** on two stacks: native
(SDL/Skia, Kotlin/Native) and a `jvm()` target on upstream Compose Desktop.
`scripts/parity/parity.py` screenshots every screen on both and pixel-diffs
them, so a screen that visually diverges is a **port regression** (missing
content, wrong shape/colour, broken clip). Several past renderer regressions
would have been caught here.
```bash
python scripts/parity/parity.py # all screens (builds first)
python scripts/parity/parity.py Buttons Shapes # a subset
python scripts/parity/parity.py --no-build # reuse the last renders
```
Mechanics: the JVM leg renders all screens headlessly via `ImageComposeScene`
in ONE process (`:demo:run --args=--screenshot-all=<dir>`, wired in
`MainJvm.kt`); the native leg launches the exe once per screen
(`--screen=<Name> --screenshot=<x>.bmp`). Output lands in **`build/parity/`
(gitignored)**: `<pct>_<Name>_diff.png` (amplified difference heatmap),
`<pct>_<Name>_compare.png` (native ∣ jvm ∣ diff, side by side), and
`report.txt` ranked worst-first. Windows-only for the native leg today; needs
Pillow. See `scripts/parity/README.md`.
**What the number means — read this before trusting it.** The `%differ` is the
fraction of pixels whose per-channel difference exceeds a tolerance. It is NOT
a pass/fail score and pixel-perfection is not the goal: the two stacks use
different default fonts, so **every screen carries a steady baseline
difference** — in the heatmap, text shows as a faint *doubled ghost* from
slightly different line metrics/baselines. A healthy full sweep is a smooth
gradient (~2% for a sparse screen like Counter, up to ~32% for a text-dense
one like Tabs). Known SDL parity gaps also inflate specific screens
predictably (Brushes ~23% — gradients render solid on SDL; Shadows / Canvas /
GraphicsLayer — effect differences). **The signal is the RANKING and the
delta from a screen's own history**, not the absolute value: a text-light
screen suddenly reading 60%, or a screen jumping far above its neighbours, is
the bug. In a `_diff.png`: ghosted/doubled text + dark shapes = normal font
drift; a **solid bright block, or a shape present on only one side** = a real
regression — open the `_compare.png` to see which stack is wrong.
## Conventions
Kotlin standard style — plain `camelCase` for parameters, local variables,
@@ -512,7 +595,10 @@ that should surface in tooling.
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
while per-target compilation is fine). Declare EVERY artifact the common
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
own bridge rule. Note only the macOS publish job compiles common metadata —
own bridge rule. Note only the WINDOWS publish job compiles common metadata
(it owns the root KotlinMultiplatform publications — the only host that
declares every target, so only its .module files carry the full variant
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
- **`Path()` in commonMain returns different actuals per renderer** —
the Skia renderer produces a `SkiaBackedPath` (wraps
+18
View File
@@ -261,6 +261,24 @@ RESUMED, unfocused → STARTED, minimized → CREATED), `ViewModelStoreOwner`
ViewModels scope per nav entry, per window, or anywhere in between — same
semantics as Android.
## Tooling
Helper scripts live under `scripts/`; each has its own README with detail.
| Tool | Use it to | More |
|------|-----------|------|
| `scripts/build-sdl/build-all.py` | build the static SDL3 / TTF / image / FreeType libs | see **Building** above |
| `scripts/compose-fork/sync.sh` | re-sync the vendored upstream Compose sources | [README](scripts/compose-fork/README.md) |
| `scripts/parity/parity.py` | diff every demo screen native-vs-JVM to catch render regressions | [README](scripts/parity/README.md) |
| `scripts/probe/probe.py` | drive a native window (click/hover/hold) + screenshot it | [README](scripts/probe/README.md) |
| `CDN_PROFILE=1 <app>` | print per-phase frame timings to find slow frames | — |
| `python scripts/compose-coverage.py` | measure API coverage vs upstream (after `./gradlew apiDump`) | — |
Building on the port from another project? Use the bridge plugin —
[gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md).
Contributor-facing detail (including how to read the parity `%differ`) is in
[CLAUDE.md](CLAUDE.md#tooling--whats-available-and-when-to-reach-for-it).
## License
[MIT](LICENSE.md).
+105
View File
@@ -0,0 +1,105 @@
# Renderer roadmap — fixes, performance, memory
Grounded in the bubble-wrap findings (2026-07): lazy clips took the stress
sheet from 23 → 39 FPS, but 75 Hz needs structural work; issue #2 showed that
Cleaner-managed native memory starves when the Kotlin heap is quiet.
## 1. Deterministic native-resource lifecycle
Ownership closes resources the moment they're finished; GC stays as the
safeguard, not the mechanism.
- [x] **`NativeReleaseQueue`** — SDL calls are main-thread-only, so nothing may
destroy directly from a Cleaner or worker: release actions ENQUEUE, the main
loop DRAINS between frames. Cleaners become safe from any thread because
they only enqueue. Verified end-to-end (direct enqueue + a real
Cleaner→enqueue→drain cycle).
- [x] **`SdlImageBitmap` textures** — previously never destroyed (manual SDL
memory, invisible to the GC nudge). A holder + Cleaner now enqueues
`SDL_DestroyTexture` / `SDL_DestroySurface` for the texture, render target
and never-realized decoded surface; explicit `close()` frees promptly.
- [ ] **Wire `close()` into cache eviction** — the Cleaner is the current
release path for decoded/vector bitmaps (correct but GC-timed). Call
`SdlImageBitmap.close()` on image-cache eviction / `removeMemoryResource`
for prompt release (needs a cross-module hook — the caches hold the
`ImageBitmap` interface, not the concrete type).
- [ ] **Cache-eviction closes (rest)** — every image/text cache closes what it
evicts (Sdl3ImageCache already does; sweep the rest, both renderers).
- [ ] **Renderer `destroy()` chain** — window close synchronously frees every
pool (clip targets already do; text caches, image caches, shadow cache,
typefaces to follow).
- [ ] **`SkiaImageBitmap.close()`** (surface + snapshot) + close-on-eviction in
the Skia caches. *Skia source sets build only on macOS/Linux — needs a mac
or CI leg to verify.*
- [ ] **Demote the GC nudge** — once ownership does the work, stretch the 10s
interval / gate it on churn; it remains the backstop.
Verification: `demo --leaktest`-style probe (cycle screens N times, print
RSS; expect a plateau without GC sawtooth).
## 2. SDL renderer performance (the 39 → 75 Hz path)
- [x] **Frame profiler** — `CDN_PROFILE=1` env flag prints per-phase timings
(events / app pump / window pump / render / present) every ~2 s. Measure
first, optimize second.
- [ ] **Dirty-region rendering** — NEXT, promoted by profiling: the app
re-tessellates the whole window on any invalidation. Accumulate damage from
invalidated layers and scissor the redraw. Biggest lever for heavy scenes.
Evidence (CDN_PROFILE): the full demo is DRAW-bound — draw ~34ms cold /
~14ms steady, present only ~1.3ms — because it re-tessellates the whole
always-present sidebar (30+ text rows + icons) every rendered frame. That
~14ms caps it near 70 fps; apidemo's lighter chrome fits the 6.9ms budget
and reaches the 144 Hz vsync ceiling. The "70 ≈ 144/2" look is coincidence,
NOT a vsync-half artifact — both use vsync(1). Retained layers / dirty
regions would stop re-tessellating the static sidebar.
- [ ] **Retained layer textures** — cache a layer's rendered output keyed by
its draw content (RenderNode-style); static subtrees stop re-tessellating.
- [ ] **Right-size clip scratch targets** (LOW — profiling showed masks aren't
a hot path after lazy clips): size scratch textures to the clip bbox.
Memory, not frame time.
- [ ] **Glyph atlas** — text draws currently break geometry batches (z-order
flush per run); an atlas texture lets glyphs ride the vertex batches.
## 3. Skia renderer
- [ ] Bounded `SkiaImageCache` (LRU + eviction close) — unbounded HashMap today.
- [ ] Eager-close audit of the text pipeline (mirror of the SDL sweep).
- [ ] `saveLayer` huge-bounds clamp (GPU offscreen memory spikes — see
CLAUDE.md pitfall).
## 4. Correctness / parity gaps (future bug reports waiting)
Ordered by likelihood of a user hitting them. NOTE: this list predates several
fixes — verify a claim against the parity heatmaps before acting (gradients and
rotation both turned out already-working). In a parity `_diff.png`: a solid
bright block = real gap; ghosted text = font drift.
- [x] **Gradient brushes on SDL** — samplers were already implemented; the real
bug was UNDER-SAMPLING: fills sampled only corners, so radial/sweep on a
rect/round-rect rendered FLAT (a square's corners are equidistant from centre
→ identical t). Gradient fills now grid-mesh the interior (emitRectMesh);
radial/sweep match upstream. (Solid fills unchanged — one quad.)
- [x] **Layer rotation** — verified WORKING (demo GraphicsLayer "Rotation
(live)" + "Combined" rotate correctly); stale claim removed.
- [ ] **Real `saveLayer` alpha on SDL** — overlapping content composites at
paint level; needs an offscreen (the clip-target pool can serve it).
- [ ] **`clipPath` generic shapes** — bbox fallback clips square.
## 5. Permanent tooling
- [x] **Parity harness** — scripts/parity/: renders every :demo screen native +
JVM from the same commonMain composables, pixel-diffs, ranks by %differ,
emits per-screen diff heatmaps + side-by-side compares (pct in filename).
build/parity/ (gitignored). See scripts/parity/README.md.
- [x] Promote the press/hover automation rig into `scripts/probe/`:
window-CLIENT-relative input (click/hover/hold, fractional coords, process-
addressed) + PrintWindow capture (works occluded). See scripts/probe/README.md.
## 6. Long-term
- [ ] **SDL_GPU render backend** — real stencil clipping (no masks at all),
pipelined batching, shader gradients. Weeks of work across three
platforms; items 2–4 stay useful beneath it.
Suggested order: profiler → lifecycle queue + leak closes → clip-target
right-sizing → gradients → dirty regions → the rest by demand.
@@ -32,8 +32,11 @@ import com.compose.sdl.window.createPopupHostState
import kotlinx.coroutines.*
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.cinterop.toKString
import kotlinx.cinterop.reinterpret
import sdl3.SDL_Delay
import sdl3.SDL_GetPerformanceCounter
import sdl3.SDL_GetPerformanceFrequency
import sdl3.SDL_GetTicks
import sdl3.SDL_Quit
import sdl3.SDL_SetWindowTitle
@@ -149,6 +152,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
var vGcLastTicks = SDL_GetTicks()
var vRenderedSinceGc = false
while (!runtime.exitRequested) {
FrameProfiler.mark()
Snapshot.sendApplyNotifications()
// ============
@@ -176,6 +180,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
}
mainDispatcher.drainPending()
FrameProfiler.phase("events")
// ============
// App composition pump — Window()s may appear / disappear here.
@@ -195,6 +200,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// Exit when the last window is gone (after at least one existed).
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
if (runtime.exitRequested) break
FrameProfiler.phase("app")
// ============
// Per-window pump + render.
@@ -208,13 +214,24 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
Snapshot.sendApplyNotifications()
vW.frameClock.sendFrame()
yield()
FrameProfiler.phase("pump")
if (vW.shouldRender()) {
vW.renderFrame()
vAnyRendered = true
if (!vW.backend.vsyncEnabled) vAllVsync = false
}
FrameProfiler.phase("render")
}
runtime.reapDestroyed()
FrameProfiler.frameDone(vAnyRendered)
// ============
// 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
// backstop (ROADMAP.md item 1).
com.compose.sdl.graphics.NativeReleaseQueue.drain()
// ============
// Pace / idle-skip.
@@ -250,6 +267,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
snapshotHandle.dispose()
for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW)
runtime.reapDestroyed()
com.compose.sdl.graphics.NativeReleaseQueue.drain()
appComposition.dispose()
appRecomposer.cancel()
appRecomposeJob.cancelAndJoin()
@@ -262,6 +280,69 @@ 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"
}
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
}
}
}
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
their native memory (see the main loop's native-memory nudge). */
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
@@ -675,6 +756,7 @@ internal class WindowInstance(
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
host.measureAndLayout()
FrameProfiler.phase(" layout")
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
if (hasMousePos) {
@@ -687,7 +769,9 @@ internal class WindowInstance(
// Probe consumers end the app when their scenario completes.
facade.close()
}
FrameProfiler.phase(" draw")
vRender.endFrame()
FrameProfiler.phase(" present")
frameIndex++
// FPS — refreshed ~once a second, per window.
@@ -0,0 +1,24 @@
package com.compose.sdl
// ==================
// MARK: Renderer entry points (expect)
// ==================
/* The renderer seam :window builds against. Each renderer source set
(skikoRendererMain / sdlRendererMain) supplies the actuals; exactly one of
the two is attached to any given target, so resolution stays unambiguous.
These were originally declared identically in both renderer source sets
with NO expect — fine per-target, but shared nativeMain METADATA (=: what
:window's KotlinMultiplatform publication compiles against) could not see
them on a host where the attached targets span BOTH renderers (Windows:
skikoRenderer for macos/linux + sdlRenderer for mingw). The expect makes
nativeMain metadata self-contained, which lets the WINDOWS publish job —
the only host that declares every target — produce the root modules. */
/* Create the render backend for the selected [GpuMode]; null when the
backend can't initialise (caller falls back / reports). */
expect fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend?
/* The renderer module's per-OS default GpuMode (used for GpuMode.Auto). */
expect fun rendererPreferredGpuMode(): GpuMode
@@ -0,0 +1,55 @@
package com.compose.sdl.graphics
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
// ==================
// MARK: NativeReleaseQueue — main-thread-deferred native resource disposal
// ==================
/**
* A queue of native-resource release actions drained on the MAIN THREAD once
* per frame by the window loop (see ComposeWindow). It exists because the two
* ways a renderer resource dies are both off the main thread, while the calls
* that free them (SDL_DestroyTexture, and any renderer API) are main-thread-only:
*
* - a Kotlin/Native `Cleaner` runs on a GC worker thread, and
* - explicit `close()` can be reached from composition/effect code on other
* dispatchers.
*
* Both simply ENQUEUE here; the main loop runs the actions between frames. This
* turns the GC (issue #2: Cleaner-managed native memory starving because the
* quiet Kotlin heap rarely triggers a collection) from the disposal MECHANISM
* into a mere safeguard — ownership closes resources promptly, the Cleaner is
* the backstop for whatever leaks a `close()`.
*
* Actions must be idempotent-safe at the source (guard against a resource being
* both explicitly closed and later GC-cleaned — see SdlImageBitmap's holder).
*/
object NativeReleaseQueue {
private val fLock = SynchronizedObject()
private var fPending = ArrayList<() -> Unit>()
/** Enqueue a release action. Safe to call from any thread. */
fun enqueue(action: () -> Unit) {
synchronized(fLock) { fPending.add(action) }
}
/**
* Run and clear every queued action. MAIN THREAD ONLY — the actions call
* renderer APIs that aren't thread-safe. Returns the number drained.
*/
fun drain(): Int {
val vBatch = synchronized(fLock) {
if (fPending.isEmpty()) return 0
val vTaken = fPending
fPending = ArrayList()
vTaken
}
for (vAction in vBatch) {
runCatching { vAction() }
}
return vBatch.size
}
}
@@ -6,7 +6,7 @@ import kotlin.experimental.ExperimentalNativeApi
// MARK: SDL3 renderer per-OS default
// ==================
fun rendererPreferredGpuMode(): GpuMode {
actual fun rendererPreferredGpuMode(): GpuMode {
@OptIn(ExperimentalNativeApi::class)
return when (Platform.osFamily) {
OsFamily.MACOSX -> GpuMode.Sdl3.Metal
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.sdl.Sdl3RenderBackend
renderer modules expose createRenderBackend / rendererPreferredGpuMode
with identical signatures in this package, and the build includes exactly
one of them per target. Rejects Skia.* since this module has no Skiko. */
fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
actual fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
if (vResolved is GpuMode.Skia) {
error("$vResolved isn't available in this build — Skiko isn't linked. " +
@@ -128,7 +128,7 @@ internal class Sdl3Canvas(
// offscreen clip opened inside this save frame (see fClipLayers below).
private data class State(
val a: Float, val b: Float, val c: Float, val d: Float, val e: Float, val f: Float,
val clip: IntArray?, val alpha: Float, val clipLayers: Int,
val clip: IntArray?, val alpha: Float, val clipLayers: Int, val pendingClips: Int,
)
private val fStack = ArrayDeque<State>()
@@ -148,9 +148,138 @@ internal class Sdl3Canvas(
)
private val fClipLayers = ArrayDeque<OffscreenClip>()
// ============
// Lazy rounded clips. Opening an offscreen mask per clip is the SINGLE
// most expensive thing this canvas does (two render-target switches +
// clear + corner cut + feather + composite) - and the dominant Compose
// idiom `Modifier.clip(CircleShape).background(...)` never needs one:
// the background is a full-cover rect drawable AS the rounded shape, and
// content wholly inside the rounded region needs no mask at all. So
// clipRoundRect only RECORDS the clip; each draw either proves itself
// safe (containment / cover) or realizes the offscreen on demand.
private class PendingRoundClip(
val deviceRound: RoundRect, // device-space outline (clamped radii)
val localRound: RoundRect, // as passed in (for cover -> shape-fill emission)
val region: IntArray, // fClip intersect bbox at push time
val bbox: IntArray, // device bbox of the outline
val prevClip: IntArray?, // fClip before this clip was pushed
// Affine snapshot at push - cover -> fill re-emits in LOCAL space and
// must bail if the transform moved since.
val ma: Float, val mb: Float, val mc: Float, val md: Float, val me: Float, val mf: Float,
// fStack.size when the clip was pushed: a lazily-realized mask must be
// composited by the save-frame that OWNS the clip, not by whatever
// inner frame happened to trigger realization (a press draws the state
// layer and the ripple in separate inner clipRect{} frames - the first
// realize must not let the inner restore close the mask, or the second
// draw runs unmasked: the "square on click" artifact).
val saveDepth: Int,
)
private val fPendingClips = ArrayDeque<PendingRoundClip>()
/* Point-in-round-rect (device space) - inside the rect and, within a
corner cut square, inside its ellipse. */
private fun roundRectContains(inRR: RoundRect, inX: Float, inY: Float): Boolean {
if (inX < inRR.left || inX > inRR.right || inY < inRR.top || inY > inRR.bottom) return false
fun corner(inRx: Float, inRy: Float, inDx: Float, inDy: Float): Boolean {
if (inRx <= 0f || inRy <= 0f) return true
val vNx = inDx / inRx; val vNy = inDy / inRy
return vNx * vNx + vNy * vNy <= 1f
}
val vTl = inRR.topLeftCornerRadius; val vTr = inRR.topRightCornerRadius
val vBr = inRR.bottomRightCornerRadius; val vBl = inRR.bottomLeftCornerRadius
if (inX < inRR.left + vTl.x && inY < inRR.top + vTl.y)
return corner(vTl.x, vTl.y, inX - (inRR.left + vTl.x), inY - (inRR.top + vTl.y))
if (inX > inRR.right - vTr.x && inY < inRR.top + vTr.y)
return corner(vTr.x, vTr.y, inX - (inRR.right - vTr.x), inY - (inRR.top + vTr.y))
if (inX > inRR.right - vBr.x && inY > inRR.bottom - vBr.y)
return corner(vBr.x, vBr.y, inX - (inRR.right - vBr.x), inY - (inRR.bottom - vBr.y))
if (inX < inRR.left + vBl.x && inY > inRR.bottom - vBl.y)
return corner(vBl.x, vBl.y, inX - (inRR.left + vBl.x), inY - (inRR.bottom - vBl.y))
return true
}
/* A device-space aabb is inside the round rect iff its four corners are. */
private fun roundRectContainsAabb(inRR: RoundRect, inL: Float, inT: Float, inR: Float, inB: Float): Boolean =
roundRectContains(inRR, inL, inT) && roundRectContains(inRR, inR, inT) &&
roundRectContains(inRR, inR, inB) && roundRectContains(inRR, inL, inB)
/* Device aabb of a LOCAL rect under the current affine. */
private fun deviceAabb(inL: Float, inT: Float, inR: Float, inB: Float): FloatArray {
val vX0 = mapX(inL, inT); val vX1 = mapX(inR, inT); val vX2 = mapX(inR, inB); val vX3 = mapX(inL, inB)
val vY0 = mapY(inL, inT); val vY1 = mapY(inR, inT); val vY2 = mapY(inR, inB); val vY3 = mapY(inL, inB)
return floatArrayOf(
minOf(vX0, vX1, vX2, vX3), minOf(vY0, vY1, vY2, vY3),
maxOf(vX0, vX1, vX2, vX3), maxOf(vY0, vY1, vY2, vY3),
)
}
/* If the LOCAL-space aabb stays inside every pending rounded clip, drawing
it under the already-active rect clip is correct - no mask needed.
Otherwise realize the pending masks before the draw proceeds. */
private fun admitDraw(inL: Float, inT: Float, inR: Float, inB: Float) {
if (fPendingClips.isEmpty()) return
val vBox = deviceAabb(inL, inT, inR, inB)
for (vPending in fPendingClips) {
if (!roundRectContainsAabb(vPending.deviceRound, vBox[0], vBox[1], vBox[2], vBox[3])) {
realizePendingClips()
return
}
}
}
/* Convert pending clips into real offscreen mask layers (outermost first).
Each clip realizes with its OWN push-time region (pending.region =
enclosing-clip-at-push ∩ bbox), NOT the current fClip: a lazily-realized
clip may fire arbitrarily deep inside descendants whose own clips have
since narrowed fClip. Re-intersecting with that narrow clip shrank an
outer clip's offscreen to the innermost descendant's rect, so its
composite blitted back only that sliver and every sibling drawn into the
same offscreen was discarded (the TLS-chain regression: only the first
card + nothing below it, until a scroll shifted which card triggered
realization). The real (already-narrowed) drawing clip is preserved
across the loop and restored at the end, so subsequent draws into the
innermost target stay correctly clipped. */
private fun realizePendingClips() {
if (fPendingClips.isEmpty()) return
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
val vDrawClip = fClip
while (fPendingClips.isNotEmpty()) {
val vPending = fPendingClips.removeFirst()
fScope.flush()
val vRegion = vPending.region
val vTarget = fClipTargets?.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
if (vTarget == null || vRegion[2] <= vRegion[0] || vRegion[3] <= vRegion[1]) {
// Degrade to the rect clip that is already active.
continue
}
val vPrevTarget = SDL_GetRenderTarget(vRenderer)
SDL_SetRenderTarget(vRenderer, vTarget.reinterpret())
fClip = vRegion
applyClip()
clearRegion(vRegion)
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound))
// Attribute the realized mask to the save-frame that pushed the
// clip: inner frames entered since must NOT composite it on their
// restore - bump their thresholds past the new layer.
for (vI in vPending.saveDepth until fStack.size) {
val vState = fStack[vI]
if (vState.clipLayers < fClipLayers.size) {
fStack[vI] = vState.copy(clipLayers = fClipLayers.size)
}
}
}
// Restore the real drawing clip (already narrowed by inner clipRects /
// scroll bounds) for draws into the now-active innermost target.
fClip = vDrawClip
applyClip()
}
// Flushes any pending batched geometry to SDL, then frees the scope's
// native buffer + clears the SDL clip. Call once per frame after the draw.
fun finish() {
fPendingClips.clear()
fScope.release()
// Safety: an unbalanced save/restore must never leave the renderer pointed
// at a scratch target when the frame is presented.
@@ -168,11 +297,14 @@ internal class Sdl3Canvas(
// Offscreen canvas: the outermost save (CanvasDrawScope brackets its draw with
// save/restore) switches the SDL render target to the ImageBitmap's texture.
if (fOffscreenTexture != null && fStack.isEmpty()) beginOffscreen()
fStack.addLast(State(fMa, fMb, fMc, fMd, fMe, fMf, fClip, fAlpha, fClipLayers.size))
fStack.addLast(State(fMa, fMb, fMc, fMd, fMe, fMf, fClip, fAlpha, fClipLayers.size, fPendingClips.size))
}
override fun restore() {
val vPrev = fStack.removeLastOrNull() ?: return
// Pending (never-realized) rounded clips opened inside this save frame:
// nothing was drawn through an offscreen for them - just drop them.
while (fPendingClips.size > vPrev.pendingClips) fPendingClips.removeLast()
// Composite + pop any offscreen rounded clips opened inside this save frame
// before restoring the plain state (each composite restores render target
// and SDL clip to what it was when the clip was opened).
@@ -269,6 +401,7 @@ internal class Sdl3Canvas(
// treating Difference as intersect clipped the border TO the notch instead
// (a floating line under the label, no outline anywhere else).
private fun clipRectDifference(inL: Float, inT: Float, inR: Float, inB: Float) {
realizePendingClips()
val vDiff = mapRectAABB(inL, inT, inR, inB)
val vRegion = fClip ?: intArrayOf(0, 0, fSize.width.toInt(), fSize.height.toInt())
if (fClipTargets == null || fSize.width < 1f || fSize.height < 1f ||
@@ -375,20 +508,18 @@ internal class Sdl3Canvas(
applyClip()
return
}
val vTarget = fClipTargets.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
if (vTarget == null) {
// Couldn't allocate a scratch target — degrade to a rectangular clip.
clipRect(inRoundRect.left, inRoundRect.top, inRoundRect.right, inRoundRect.bottom)
return
}
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
val vPrevTarget = SDL_GetRenderTarget(vRenderer)
// LAZY: record only — the rect part of the clip applies immediately,
// the offscreen mask is opened by the first draw that actually needs
// it (admitDraw / realizePendingClips). The common clip+background
// idiom never does.
val vPrevClip = fClip
SDL_SetRenderTarget(vRenderer, vTarget.reinterpret())
fClip = vRegion
applyClip()
clearRegion(vRegion)
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPrevClip, vRegion, vBbox, vDeviceRound))
fPendingClips.addLast(PendingRoundClip(
vDeviceRound, inRoundRect, vRegion, vBbox, vPrevClip,
fMa, fMb, fMc, fMd, fMe, fMf,
fStack.size,
))
}
// Pops the top offscreen clip: flush its subtree, cut the rounded corners out
@@ -573,6 +704,7 @@ internal class Sdl3Canvas(
inAmbientColor: androidx.compose.ui.graphics.Color,
inSpotColor: androidx.compose.ui.graphics.Color,
) {
realizePendingClips()
if (inElevationPx <= 0f) return
// Generic paths (CutCornerShape, GenericShape) don't 9-slice — blur the
@@ -707,6 +839,7 @@ internal class Sdl3Canvas(
// before re-rasterising a vector). Write transparent directly instead of the
// paint colour.
if (paint.blendMode == BlendMode.Clear) {
realizePendingClips()
fScope.flush()
clearRegion(intArrayOf(
mapX(left, top).toInt(), mapY(left, top).toInt(),
@@ -714,21 +847,75 @@ internal class Sdl3Canvas(
))
return
}
if (fPendingClips.isNotEmpty() && tryDrawRectUnderPendingClips(left, top, right, bottom, paint)) return
prep().rectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
}
/* The `clip(shape).background(color)` idiom: a FILL rect that fully covers
the innermost pending clip becomes a direct rounded-shape fill (with the
scope's fringe AA — no offscreen mask at all). A rect fully INSIDE every
pending clip draws as-is. Returns true when the draw has been emitted. */
private fun tryDrawRectUnderPendingClips(inL: Float, inT: Float, inR: Float, inB: Float, inPaint: Paint): Boolean {
val vBox = deviceAabb(inL, inT, inR, inB)
val vInner = fPendingClips.last()
// Cover case: fill paint, no stroke, rect covers the whole clip outline,
// affine unchanged since the clip was pushed (local-space re-emission),
// uniform corner radii (the scope's roundRectCore takes one radius), and
// the clip outline itself contained in every OUTER pending clip.
val vLr = vInner.localRound
val vUniform = vLr.topLeftCornerRadius.x == vLr.topLeftCornerRadius.y &&
vLr.topLeftCornerRadius == vLr.topRightCornerRadius &&
vLr.topLeftCornerRadius == vLr.bottomRightCornerRadius &&
vLr.topLeftCornerRadius == vLr.bottomLeftCornerRadius
val vAffineUnchanged = vInner.ma == fMa && vInner.mb == fMb && vInner.mc == fMc &&
vInner.md == fMd && vInner.me == fMe && vInner.mf == fMf
if (inPaint.style == PaintingStyle.Fill && inPaint.blendMode == BlendMode.SrcOver &&
vUniform && vAffineUnchanged &&
vBox[0] <= vInner.bbox[0] && vBox[1] <= vInner.bbox[1] &&
vBox[2] >= vInner.bbox[2] && vBox[3] >= vInner.bbox[3]
) {
var vOutersContain = true
for (vPending in fPendingClips) {
if (vPending === vInner) continue
if (!roundRectContainsAabb(vPending.deviceRound, vInner.deviceRound.left, vInner.deviceRound.top, vInner.deviceRound.right, vInner.deviceRound.bottom)) {
vOutersContain = false; break
}
}
if (vOutersContain) {
prep().roundRectCore(
brushFor(inPaint),
Offset(vLr.left, vLr.top), Size(vLr.width, vLr.height),
vLr.topLeftCornerRadius.x,
(inPaint.alpha * fAlpha), Fill,
)
return true
}
}
// Containment: inside every pending outline → the active rect clip is enough.
for (vPending in fPendingClips) {
if (!roundRectContainsAabb(vPending.deviceRound, vBox[0], vBox[1], vBox[2], vBox[3])) {
realizePendingClips()
return false
}
}
return false
}
override fun drawRoundRect(
left: Float, top: Float, right: Float, bottom: Float,
radiusX: Float, radiusY: Float, paint: Paint,
) {
admitDraw(left, top, right, bottom)
prep().roundRectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), radiusX, (paint.alpha * fAlpha), styleFor(paint))
}
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
admitDraw(left, top, right, bottom)
prep().ovalCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
}
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
admitDraw(center.x - radius, center.y - radius, center.x + radius, center.y + radius)
prep().circleCore(brushFor(paint), radius, center, (paint.alpha * fAlpha), styleFor(paint))
}
@@ -736,14 +923,17 @@ internal class Sdl3Canvas(
left: Float, top: Float, right: Float, bottom: Float,
startAngle: Float, sweepAngle: Float, useCenter: Boolean, paint: Paint,
) {
realizePendingClips()
prep().arcCore(brushFor(paint), startAngle, sweepAngle, useCenter, Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
}
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
realizePendingClips()
prep().lineCore(brushFor(paint), p1, p2, paint.strokeWidth, paint.strokeCap, (paint.alpha * fAlpha))
}
override fun drawPath(path: ComposePath, paint: Paint) {
realizePendingClips()
prep().pathCore(path, brushFor(paint), (paint.alpha * fAlpha), styleFor(paint))
}
@@ -768,6 +958,11 @@ internal class Sdl3Canvas(
inBaseItalic: Boolean,
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
) {
// Small centred labels (a popped bubble's "pop") usually sit fully
// inside a rounded clip — gate on containment like other draws, with
// a 2px margin for glyph overhang / AA bleed, instead of paying a
// mask pass per text run.
admitDraw(inX - 2f, inY - 2f, inX + inBoxWidth + 2f, inY + inBoxHeight + 2f)
fScope.flush()
// Paragraph-level decoration bits forwarded to every wrapped line.
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
@@ -876,6 +1071,7 @@ internal class Sdl3Canvas(
inContentScale: androidx.compose.ui.layout.ContentScale,
inAlpha: Float,
) {
admitDraw(inX, inY, inX + inWidth, inY + inHeight)
fScope.flush()
// Map the origin through the affine and scale the size by the matrix's axis
// magnitudes (rotation contributes 1, so it only repositions the blit — the
@@ -894,6 +1090,7 @@ internal class Sdl3Canvas(
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
val vBmp = image as? SdlImageBitmap ?: return
admitDraw(topLeftOffset.x, topLeftOffset.y, topLeftOffset.x + vBmp.width, topLeftOffset.y + vBmp.height)
drawImageRect(
image,
androidx.compose.ui.unit.IntOffset.Zero,
@@ -916,6 +1113,7 @@ internal class Sdl3Canvas(
dstSize: androidx.compose.ui.unit.IntSize,
paint: Paint,
) {
realizePendingClips()
val vTex = (image as? SdlImageBitmap)?.texture ?: return
// Commit pending frame geometry and re-assert this canvas's target + clip
// (an offscreen render just borrowed the render target).
@@ -243,7 +243,10 @@ internal class Sdl3DrawScope(
val vB = vT + size.height
when (style) {
Fill -> {
emitQuad(vL, vT, vR, vT, vR, vB, vL, vB, vSampler)
// Gradient fills need interior samples (see emitRectMesh); solid
// fills are a single flat quad.
if (brush is SolidColor) emitQuad(vL, vT, vR, vT, vR, vB, vL, vB, vSampler)
else emitRectMesh(vL, vT, vR, vB, vSampler)
// Axis-aligned rects are pixel-crisp and need no AA. When the affine
// rotates/shears them, the edges become diagonal — feather each edge
// outward (normals in local space; the matrix orients them on screen).
@@ -524,20 +527,30 @@ internal class Sdl3DrawScope(
val vY = fOriginY + topLeft.y
val vW = size.width
val vH = size.height
// Gradient fills sample per-vertex, so the interior must be meshed
// (corner-only sampling renders radial/sweep flat — see emitRectMesh);
// solid fills stay single quads.
val vGrad = brush !is SolidColor
if (vR <= 0f) {
// Trivial: just two triangles.
emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
if (vGrad) emitRectMesh(vX, vY, vX + vW, vY + vH, vSampler)
else emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
return
}
// Body in 3 strips: middle (full width × inner height), top edge,
// bottom edge — plus the 4 corner arcs.
if (style == Fill) {
if (vGrad) {
emitRectMesh(vX, vY + vR, vX + vW, vY + vH - vR, vSampler) // middle
emitRectMesh(vX + vR, vY, vX + vW - vR, vY + vR, vSampler) // top edge
emitRectMesh(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH, vSampler) // bottom edge
} else {
// Middle strip
emitQuad(vX, vY + vR, vX + vW, vY + vR, vX + vW, vY + vH - vR, vX, vY + vH - vR, vSampler)
// Top edge (between left+right corners)
emitQuad(vX + vR, vY, vX + vW - vR, vY, vX + vW - vR, vY + vR, vX + vR, vY + vR, vSampler)
// Bottom edge
emitQuad(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH - vR, vX + vW - vR, vY + vH, vX + vR, vY + vH, vSampler)
}
// 4 corner fills — segment count adapts to the corner radius.
val vSeg = arcSegments(90f, vR)
emitFilledArc(vX + vR, vY + vR, vR, vR, 180f, 90f, false, vSeg, vSampler)
@@ -854,6 +867,34 @@ internal class Sdl3DrawScope(
emitTri(ax, ay, cx, cy, dx, dy, inSampler)
}
// Axis-aligned rect subdivided into a grid, so a per-vertex sampler is
// evaluated across the INTERIOR, not just the 4 corners. Corner-only
// sampling renders radial/sweep gradients flat (a square's corners are all
// equidistant from its centre → identical t → solid fill) and clips
// non-uniform multi-stop linear ramps. SDL interpolates linearly within
// each cell, so ~16 cells/axis tracks the gradient curve closely. Solid
// fills never come here (they use emitQuad — one quad).
private fun emitRectMesh(
inL: Float, inT: Float, inR: Float, inB: Float, inSampler: Sampler,
) {
// ~one cell per 8 device px per axis (1..24), so thin edge strips stay
// cheap while large fills get enough interior samples for the gradient.
val vCols = (((inR - inL) / 8f).toInt()).coerceIn(1, 24)
val vRows = (((inB - inT) / 8f).toInt()).coerceIn(1, 24)
val vW = (inR - inL) / vCols
val vH = (inB - inT) / vRows
for (vRow in 0 until vRows) {
val vY0 = inT + vRow * vH
val vY1 = if (vRow == vRows - 1) inB else vY0 + vH
for (vCol in 0 until vCols) {
val vX0 = inL + vCol * vW
val vX1 = if (vCol == vCols - 1) inR else vX0 + vW
emitTri(vX0, vY0, vX1, vY0, vX1, vY1, inSampler)
emitTri(vX0, vY0, vX1, vY1, vX0, vY1, inSampler)
}
}
}
private fun emitTri(
ax: Float, ay: Float, bx: Float, by: Float, cx: Float, cy: Float,
inSampler: Sampler,
@@ -893,7 +934,13 @@ internal class Sdl3DrawScope(
// through the current affine so scale/rotate reach the GPU; the colour is
// sampled at the pre-transform point so a gradient rides its shape.
private fun writeVertex(inX: Float, inY: Float, inColor: ComposeColor, inAlphaScale: Float = 1f) {
if (fBatchCount >= kBatchCapacity) flush()
// Auto-flush ONLY at a triangle boundary: SDL_RenderGeometry renders
// floor(count/3) triangles and silently drops the tail, so flushing
// mid-triangle tears it AND misaligns every following triangle in the
// new batch (seen as ~350-degree pac-man circles and glitch shapes
// once a frame's geometry outgrew one batch - e.g. 84 unclipped
// drawBehind bubbles). kBatchCapacity is a multiple of 3.
if (fBatchCount >= kBatchCapacity && fBatchCount % 3 == 0) flush()
val vBase = fBatchCount * kFloatsPerVertex
fVertexData[vBase + 0] = fMa * inX + fMc * inY + fMe
fVertexData[vBase + 1] = fMb * inX + fMd * inY + fMf
@@ -914,11 +961,13 @@ private const val kAaFeather: Float = 1.0f
private const val kAaHalf: Float = kAaFeather * 0.5f
// ============
// Batch capacity — 8192 vertices = ~2730 triangles per submission. At
// 64 segments per full circle that's room for ~21 full-circle filled
// shapes per Canvas{} before any flush. Bigger gives fewer GPU
// submissions; smaller saves RAM. ~128 KB at 16 bytes per SDL_Vertex.
private const val kBatchCapacity: Int = 8192
// Batch capacity — 8190 vertices = 2730 triangles per submission (kept a
// MULTIPLE OF 3: the auto-flush in writeVertex only fires on triangle
// boundaries, so the cap must land on one). At 64 segments per full circle
// that's room for ~21 full-circle filled shapes per Canvas{} before any
// flush. Bigger gives fewer GPU submissions; smaller saves RAM. ~128 KB at
// 16 bytes per SDL_Vertex.
private const val kBatchCapacity: Int = 8190
// Floats per SDL_Vertex: position(x,y) + color(r,g,b,a) + tex_coord(x,y), tightly
// packed. Used to stage vertices in a Kotlin FloatArray and memcpy them across.
@@ -29,6 +29,34 @@ internal var currentMainCanvas: Sdl3Canvas? = null
// An ImageBitmap backed by an SDL render-target texture. The vector rasterises
// into the texture (via a Canvas returned by the factory) and Sdl3Canvas.drawImageRect
// blits it back — with the Icon tint applied through SDL_SetTextureColorMod.
// Mutable native handles held BY REFERENCE so a Cleaner can free them without
// capturing the (collectable) SdlImageBitmap. Both the explicit close() and the
// GC Cleaner enqueue release of whatever is still here — the first to run nulls
// the fields, so the other is a no-op (they can't overlap: close() only runs
// while the bitmap is reachable, the Cleaner only after it isn't).
@OptIn(ExperimentalForeignApi::class)
private class SdlBitmapHandles(
var texture: COpaquePointer?,
var surface: CPointer<SDL_Surface>?,
)
/* Enqueue destruction of the handles' current contents on the main thread
(SDL calls aren't thread-safe; the Cleaner runs on a GC worker). Idempotent:
clears the fields so a second call frees nothing. Top-level so the Cleaner
block never captures the bitmap. */
@OptIn(ExperimentalForeignApi::class)
private fun enqueueRelease(inHandles: SdlBitmapHandles) {
val vTex = inHandles.texture
val vSurf = inHandles.surface
if (vTex == null && vSurf == null) return
inHandles.texture = null
inHandles.surface = null
com.compose.sdl.graphics.NativeReleaseQueue.enqueue {
if (vSurf != null) SDL_DestroySurface(vSurf)
if (vTex != null) SDL_DestroyTexture(vTex.reinterpret())
}
}
@OptIn(ExperimentalForeignApi::class)
internal class SdlImageBitmap(
private val fRenderer: COpaquePointer,
@@ -41,19 +69,28 @@ internal class SdlImageBitmap(
// Sdl3EncodedImageDecoder); converted to a texture on the first draw.
// null → create a render TARGET for the vector-rasterisation path below
// (that path always constructs on the main thread).
private var fDecodedSurface: CPointer<SDL_Surface>? = null,
fDecodedSurface: CPointer<SDL_Surface>? = null,
) : ImageBitmap {
// RGBA render-target texture, premultiplied blend for compositing back (content
// is drawn over a transparent clear with ordinary BLEND, leaving premultiplied
// colours — see Sdl3ClipTargets for the same reasoning).
private var fTexture: COpaquePointer? = if (fDecodedSurface != null) null else SDL_CreateTexture(
private val fHandles = SdlBitmapHandles(
texture = if (fDecodedSurface != null) null else SDL_CreateTexture(
fRenderer.reinterpret(),
SDL_PIXELFORMAT_RGBA32,
SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET,
maxOf(1, width),
maxOf(1, height),
)?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED) }
)?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED) },
surface = fDecodedSurface,
)
// GC backstop: if nothing calls close(), free the native handles when the
// bitmap is collected (deferred to the main thread via the queue). Ownership
// via close() is the primary path; this catches leaks.
@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
private val fCleaner = kotlin.native.ref.createCleaner(fHandles) { enqueueRelease(it) }
// SDL renderer calls are NOT thread-safe, and the resources pipeline
// decodes on Dispatchers.Default workers — so the decode path hands over a
@@ -63,15 +100,20 @@ internal class SdlImageBitmap(
// render-target path above.
val texture: COpaquePointer?
get() {
fDecodedSurface?.let { vSurface ->
fTexture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface)
fHandles.surface?.let { vSurface ->
fHandles.texture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface)
?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND) }
SDL_DestroySurface(vSurface)
fDecodedSurface = null
fHandles.surface = null
}
return fTexture
return fHandles.texture
}
/* Free the texture/surface now (deferred to the main thread). Call on
cache eviction / removeMemoryResource so native memory releases promptly
instead of waiting for a GC. The Cleaner remains as a backstop. */
fun close() = enqueueRelease(fHandles)
override fun readPixels(
buffer: IntArray,
startX: Int,
@@ -9,7 +9,7 @@ import kotlin.experimental.ExperimentalNativeApi
internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge?
fun rendererPreferredGpuMode(): GpuMode {
actual fun rendererPreferredGpuMode(): GpuMode {
@OptIn(ExperimentalNativeApi::class)
return when (Platform.osFamily) {
OsFamily.MACOSX -> GpuMode.Skia.Metal
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.skia.SkiaRenderBackend
SDL3 module's createRenderBackend / rendererPreferredGpuMode in the same
package; exactly one renderer module is included per target. Rejects
Sdl3.* since this module has no SDL3_ttf / SDL3_image. */
fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
actual fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
if (vResolved is GpuMode.Sdl3) {
error("Sdl3.* modes aren't available in a Skia build — rerun with -Prenderer=sdl3")
+74 -5
View File
@@ -1,16 +1,46 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import demo.registry.allCategories
import demo.shell.App
import org.jetbrains.skia.EncodedImageFormat
import java.io.File
// The JVM comparison app's entry point. Runs the SAME shared App() shell + Core /
// Material 3 screens (expressive included) as :demo, but on Compose Desktop (JVM)
// against upstream org.jetbrains.compose. Compare side-by-side with
// `:demo:runDebugExecutable<host>`.
fun main() = application {
// The JVM comparison app: the SAME shared App() + screens as :demo, on upstream
// Compose Desktop. Interactive by default; a headless screenshot mode drives
// the parity harness (scripts/parity — compares each screen native vs jvm).
//
// --screenshot-all=<dir> render every registered screen to <dir>/<Name>.png
// --width / --height viewport size (default 1000 / 700)
//
// The single-screen wrapper MIRRORS MainNative's --screen path (dark theme,
// verticalScroll + 24dp padding) so layout constraints match the native
// screenshots pixel-for-pixel.
fun main(args: Array<String>) {
val screenshotDir = args.firstOrNull { it.startsWith("--screenshot-all=") }?.substringAfter('=')
if (screenshotDir != null) {
screenshotAllScreens(
outDir = File(screenshotDir),
width = args.intArg("--width", 1000),
height = args.intArg("--height", 700),
)
return
}
application {
Window(
onCloseRequest = ::exitApplication,
title = "ComposeDesktopNative — JVM (upstream Compose)",
@@ -21,3 +51,42 @@ fun main() = application {
}
}
}
}
private fun Array<String>.intArg(name: String, default: Int): Int =
firstOrNull { it.startsWith("$name=") }?.substringAfter('=')?.toIntOrNull() ?: default
/* Render each registered screen headlessly (density 1 to match the native
physical-pixel screenshots) and write a PNG per screen. */
@OptIn(ExperimentalComposeUiApi::class)
private fun screenshotAllScreens(outDir: File, width: Int, height: Int) {
outDir.mkdirs()
val screens = allCategories().flatMap { it.screens }.distinctBy { it.name }
for (screen in screens) {
val scene = ImageComposeScene(width, height, density = Density(1f)) {
ScreenHost { screen.content() }
}
try {
val image = scene.render()
val png = image.encodeToData(EncodedImageFormat.PNG) ?: continue
File(outDir, "${screen.name}.png").writeBytes(png.bytes)
} finally {
scene.close()
}
println("jvm screenshot: ${screen.name}")
}
}
/* Same wrapper as MainNative's --screen path. */
@Composable
private fun ScreenHost(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(24.dp),
) { content() }
}
}
@@ -59,8 +59,42 @@ kotlin {
}
```
The plugin can also be applied to a single module's `build.gradle.kts` instead
of settings (it then bridges only that module's configurations).
### Settings-wide or per-module
The plugin applies at either level:
```kotlin
// settings.gradle.kts — every module of the build
plugins { id("com.bitsycore.compose-desktop-native.bridge") version "<v>" }
```
```kotlin
// build.gradle.kts — this module only (compose-plugin style)
plugins { id("com.bitsycore.compose-desktop-native.bridge") }
```
Note the difference between the two settings blocks: a
`pluginManagement { plugins { id(...) version ... } }` entry only PINS the
version (so module-level applications can omit it); the top-level
`plugins { }` block in settings is what actually applies it build-wide.
Rule of thumb: single app module → apply in the module; multi-module builds →
apply once in settings.
## compose.desktop.native — the application block for native
The native counterpart of `compose.desktop { application { mainClass } }`:
```kotlin
compose.desktop {
application { mainClass = "app.MainJvmKt" } // upstream jvm
native { entryPoint = "app.main" } // compose-desktop-native
}
```
Declares an executable with that entry point on every Kotlin/Native desktop
target — no `targets.withType<KotlinNativeTarget> { binaries.executable { … } }`
boilerplate. Targets that already declare an executable are left untouched,
so manual configuration (extra linker flags, custom build types) still wins.
## composeResources — zero setup
@@ -80,6 +80,7 @@ class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
private fun installBridge(project: Project) {
installResourcePackaging(project)
installNativeApplicationDsl(project)
val version = project.providers.gradleProperty(versionProperty).orNull ?: pluginVersion
project.configurations.configureEach { configuration ->
if (nativeTargetTokens.any { configuration.name.contains(it, ignoreCase = true) }) {
@@ -0,0 +1,76 @@
package com.bitsycore.compose.sdl.gradle
import org.gradle.api.Action
import org.gradle.api.NamedDomainObjectCollection
import org.gradle.api.Project
import org.gradle.api.plugins.ExtensionAware
// ==================
// MARK: compose.desktop.native { } — the native counterpart of application { }
// ==================
/**
* The native desktop analog of `compose.desktop { application { mainClass } }`:
*
* ```kotlin
* compose.desktop {
* application { mainClass = "bubblewrap.MainJvmKt" } // jvm (upstream)
* native { entryPoint = "bubblewrap.main" } // this port
* }
* ```
*
* Declares an executable with the given entry point on every Kotlin/Native
* DESKTOP target (mingwX64 / linuxX64 / linuxArm64 / macosArm64) — replacing
* the hand-written `targets.withType<KotlinNativeTarget> { binaries.executable
* { entryPoint = … } }` block. Targets that already declare an executable are
* left untouched, so manual configuration (extra linker flags, custom build
* types) still wins.
*/
abstract class ComposeDesktopNativeExtension {
/** Entry point (`package.functionName`) for the native executables. */
var entryPoint: String? = null
}
/* Grafts the `native` extension onto the Compose plugin's `desktop` extension
(extensions are ExtensionAware, so this needs no compile-time dependency)
and materialises the executables in afterEvaluate. All KGP access is
reflective: applied from settings, this plugin's classloader is a PARENT of
the project's buildscript loader and cannot see KGP types. */
internal fun installNativeApplicationDsl(project: Project) {
project.pluginManager.withPlugin("org.jetbrains.compose") {
project.pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
val composeExt = project.extensions.findByName("compose") as? ExtensionAware ?: return@withPlugin
val desktopExt = composeExt.extensions.findByName("desktop") as? ExtensionAware ?: return@withPlugin
if (desktopExt.extensions.findByName("native") != null) return@withPlugin
val nativeExt = desktopExt.extensions.create("native", ComposeDesktopNativeExtension::class.java)
project.afterEvaluate { configureNativeExecutables(it, nativeExt) }
}
}
}
private val desktopKonanFamilies = setOf("MINGW", "LINUX", "OSX")
private fun configureNativeExecutables(project: Project, ext: ComposeDesktopNativeExtension) {
val entryPoint = ext.entryPoint ?: return
val kotlinExt = project.extensions.findByName("kotlin") ?: return
@Suppress("UNCHECKED_CAST")
val targets = kotlinExt.javaClass.getMethod("getTargets").invoke(kotlinExt) as NamedDomainObjectCollection<Any>
for (target in targets) {
val platformType = target.javaClass.getMethod("getPlatformType").invoke(target)
if (platformType.toString() != "native") continue
val konanTarget = target.javaClass.getMethod("getKonanTarget").invoke(target)
val family = konanTarget.javaClass.getMethod("getFamily").invoke(konanTarget)
if ((family as Enum<*>).name !in desktopKonanFamilies) continue
val binaries = target.javaClass.getMethod("getBinaries").invoke(target)
// Respect manual configuration: skip targets that already have an
// executable (matching by the binary class, not the name suffix).
val existing = (binaries as Iterable<*>).any { it != null && it.javaClass.simpleName == "Executable" }
if (existing) continue
val executableMethod = binaries.javaClass.methods.first {
it.name == "executable" && it.parameterCount == 1 && Action::class.java.isAssignableFrom(it.parameterTypes[0])
}
executableMethod.invoke(binaries, Action<Any> { binary ->
binary.javaClass.getMethod("setEntryPoint", String::class.java).invoke(binary, entryPoint)
})
}
}
@@ -54,17 +54,24 @@ private val desktopTargets = mapOf(
)
private fun registerDataKresTasks(project: Project) {
val taskNames = project.tasks.names
val resPackage = project.resolveResourcePackage()
// The zips are PRE-REGISTERED lazily for every desktop target/variant and
// wired to their link task via matching{}.configureEach: executables may
// be declared after this afterEvaluate runs — notably by the bridge's OWN
// compose.desktop.native { entryPoint } DSL, which also materialises in
// afterEvaluate. (A name pre-scan raced that and silently packaged
// nothing — the "data.kres not found" crash; and Gradle forbids
// registering tasks from inside another task's configuration callback,
// so the zip cannot be created reactively either.) An unrealised
// registered task costs nothing: if the target has no executable, the
// link task never appears and the zip never runs. All content wiring
// happens in the zip's own configuration action, which only executes on
// realisation — by then the Compose plugin's prepare tasks and the final
// compose.resources config exist.
for ((target, sourceSets) in desktopTargets) {
for (variant in listOf("Debug", "Release")) {
val linkName = "link${variant}Executable$target"
if (linkName !in taskNames) continue
val zipName = "package${variant}ComposeResources$target"
if (zipName in taskNames) continue
val prepareNames = sourceSets
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
.filter { it in taskNames }
if (zipName in project.tasks.names) continue
val zipTask = project.tasks.register(zipName, Zip::class.java) { task ->
task.description = "Bundles composeResources into data.kres next to the $target ${variant.lowercase()} executable."
task.archiveFileName.set("data.kres")
@@ -77,6 +84,10 @@ private fun registerDataKresTasks(project: Project) {
// decoders — an entry is one fseek+fread, never inflated.
task.entryCompression = ZipEntryCompression.STORED
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
val resPackage = project.resolveResourcePackage()
val prepareNames = sourceSets
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
.filter { it in project.tasks.names }
for (prepareName in prepareNames) {
val sourceSet = prepareName.removePrefix("prepareComposeResourcesTaskFor")
.replaceFirstChar { it.lowercase() }
@@ -87,7 +98,7 @@ private fun registerDataKresTasks(project: Project) {
task.dependsOn(project.tasks.named(prepareName))
}
}
project.tasks.named(linkName) { it.dependsOn(zipTask) }
project.tasks.matching { it.name == linkName }.configureEach { it.dependsOn(zipTask) }
}
}
}
+47
View File
@@ -0,0 +1,47 @@
# Parity harness
Renders every `:demo` screen on the **native** (SDL/Skia, Kotlin/Native) stack
and on the **JVM** upstream-Compose stack from the *same* commonMain
composables, then pixel-diffs them per screen. It's a regression net: a screen
whose difference jumps far above its usual level is a port bug (missing
content, wrong shape/colour, broken clip). Several of this project's renderer
regressions would have surfaced here.
```bash
python scripts/parity/parity.py # all screens (builds first)
python scripts/parity/parity.py Buttons Shapes # a subset
python scripts/parity/parity.py --no-build # reuse the last renders
python scripts/parity/parity.py --gpu=sdl3 # native renderer (default sdl3)
```
Output → `build/parity/` (gitignored):
- `<pct>_<Name>_compare.png` — native ∣ jvm ∣ amplified-diff, side by side
- `<pct>_<Name>_diff.png` — the amplified difference heatmap alone
- `report.txt` — screens ranked by % differing
The `<pct>` prefix is zero-padded, so a plain file listing (or the report)
sorts worst-first.
## Reading the result
**Absolute % is not the metric — the ranking is.** The two stacks use
different default fonts, so text carries a steady baseline difference (in the
heatmap, every text line shows a faint *doubled* ghost from slightly different
line metrics). Buttons, cards, shapes, images should align (dark). So:
- **doubled/ghosted text, dark shapes** → normal font drift.
- **a solid bright block, or a shape present on one side only** → a real
regression. Open the `_compare.png` to see which stack is wrong.
Compare a screen's % against its neighbours and its own history: Buttons ~16%,
Shapes ~14%, Colors ~4% are the healthy baseline (mostly text). A screen that
reads 60% when text-light is the bug.
## Requirements
- Windows for the native leg today (mingwX64 exe); the JVM leg is host-neutral.
- Pillow (`pip install pillow`).
- The JVM leg renders all screens headlessly via `ImageComposeScene` in one
process (`:demo:run --args=--screenshot-all=…`); native takes one exe launch
per screen.
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
Parity harness — render every demo screen on the NATIVE (SDL/Skia) stack and on
the JVM upstream-Compose stack, then pixel-diff them per screen.
The two stacks share the exact same commonMain screen composables, so a screen
that suddenly diverges from its usual difference level is a PORT REGRESSION
(missing content, wrong shape/colour, broken clip). Absolute pixel-perfection
is NOT the goal — fonts differ between stacks, so text-heavy screens carry a
steady baseline difference. The signal is the RANKING: a screen that jumps from
~8% to ~60% different is the bug. Four of the renderer regressions this project
hit would have surfaced here.
Usage (from repo root):
python scripts/parity/parity.py # all screens
python scripts/parity/parity.py Buttons Shapes # a subset
python scripts/parity/parity.py --no-build # reuse existing screenshots
Outputs to build/parity/ (gitignored):
<pct>_<Name>_compare.png native | jvm | amplified-diff, side by side
<pct>_<Name>_diff.png the amplified pixel-difference heatmap alone
report.txt ranked table
The <pct> prefix is zero-padded so a plain file listing sorts worst-first.
Windows-only for the native leg today (mingwX64 exe). Needs Pillow.
Native uses the SDL renderer; pass --gpu to change.
"""
import subprocess, sys, os, shutil
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "parity"
NATIVE_EXE = REPO / "demo" / "build" / "bin" / "mingwX64" / "debugExecutable" / "demo.exe"
WIDTH, HEIGHT = 1000, 700
# Per-channel tolerance: below this a pixel counts as "same" (JPEG-ish noise,
# sub-pixel AA, font hinting). Tuned so unrelated screens sit well under it.
TOL = 32
GRADLEW = str(REPO / ("gradlew.bat" if os.name == "nt" else "gradlew"))
def run(cmd, **kw):
print("+ " + " ".join(str(c) for c in cmd))
return subprocess.run(cmd, cwd=REPO, **kw)
def build():
run([GRADLEW, ":demo:linkDebugExecutableMingwX64", "--console=plain"], check=True)
def jvm_shots(dst: Path):
run([GRADLEW, ":demo:run", f"--args=--screenshot-all={dst}", "--console=plain"], check=True)
def native_shot(name: str, dst: Path, gpu: str):
bmp = dst / f"{name}.bmp"
run([str(NATIVE_EXE), f"--screen={name}", f"--screenshot={bmp}",
f"--gpu={gpu}", f"--width={WIDTH}", f"--height={HEIGHT}"],
check=False, timeout=60)
if bmp.exists():
Image.open(bmp).convert("RGB").save(dst / f"{name}.png")
bmp.unlink()
def diff_pair(native: Image.Image, jvm: Image.Image):
"""Return (percent_differing, amplified_diff_image)."""
w = min(native.width, jvm.width)
h = min(native.height, jvm.height)
a = native.crop((0, 0, w, h)).convert("RGB")
b = jvm.crop((0, 0, w, h)).convert("RGB")
diff = ImageChops.difference(a, b)
# A pixel "differs" if any channel exceeds TOL.
gray = diff.convert("L")
mask = gray.point(lambda p: 255 if p > TOL else 0)
differing = sum(mask.point(lambda p: 1 if p else 0).getdata())
pct = 100.0 * differing / (w * h)
amplified = diff.point(lambda p: min(255, p * 4))
return pct, amplified
def side_by_side(native, jvm, diff, path):
w = min(native.width, jvm.width)
h = min(native.height, jvm.height)
canvas = Image.new("RGB", (w * 3 + 20, h + 20), (16, 16, 16))
canvas.paste(native.crop((0, 0, w, h)), (0, 10))
canvas.paste(jvm.crop((0, 0, w, h)), (w + 10, 10))
canvas.paste(diff, (w * 2 + 20, 10))
canvas.save(path)
def main():
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
flags = [a for a in sys.argv[1:] if a.startswith("--")]
no_build = "--no-build" in flags
gpu = next((f.split("=", 1)[1] for f in flags if f.startswith("--gpu=")), "sdl3")
OUT.mkdir(parents=True, exist_ok=True)
jvm_dir = OUT / "_jvm"
# Clear last run's pct-prefixed visualizations so stale percentages don't
# linger alongside the fresh ones.
for old in list(OUT.glob("*_compare.png")) + list(OUT.glob("*_diff.png")):
old.unlink()
if not no_build:
build()
if jvm_dir.exists():
shutil.rmtree(jvm_dir)
jvm_dir.mkdir(parents=True)
jvm_shots(jvm_dir)
# Screen set: the JVM run enumerates the full registry → use its PNGs as the
# source of truth for names (native takes one exe launch each).
names = sorted(p.stem for p in jvm_dir.glob("*.png"))
if argv:
wanted = {n.lower() for n in argv}
names = [n for n in names if n.lower() in wanted]
if not names:
print("No screens matched.", file=sys.stderr)
return 1
results = []
for name in names:
jvm_png = jvm_dir / f"{name}.png"
if not no_build or not (OUT / f"{name}.native.png").exists():
native_shot(name, OUT, gpu)
src = OUT / f"{name}.png"
if src.exists():
src.replace(OUT / f"{name}.native.png")
native_png = OUT / f"{name}.native.png"
if not native_png.exists():
results.append((name, None))
continue
native = Image.open(native_png).convert("RGB")
jvm = Image.open(jvm_png).convert("RGB")
pct, amp = diff_pair(native, jvm)
# Zero-padded pct prefix → worst-first in any file listing.
prefix = f"{pct:06.2f}"
amp.save(OUT / f"{prefix}_{name}_diff.png")
side_by_side(native, jvm, amp, OUT / f"{prefix}_{name}_compare.png")
results.append((name, pct))
print(f" {name:28s} {pct:6.2f}% differing")
results.sort(key=lambda r: (-1 if r[1] is None else r[1]), reverse=True)
lines = ["screen %differ", "-" * 40]
for name, pct in results:
lines.append(f"{name:28s} {'NATIVE FAILED' if pct is None else f'{pct:6.2f}%'}")
report = "\n".join(lines)
(OUT / "report.txt").write_text(report + "\n", encoding="utf-8")
print("\n" + report)
print(f"\nDiff images + report in {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())
+35
View File
@@ -0,0 +1,35 @@
# Probe driver
Launches a native compose-desktop-native app window, sends **window-relative**
synthetic input, and captures the client area — for reproducing visual bugs
deterministically (this is the packaged form of the rigs that caught the
square-on-click and TLS-chain regressions).
Why window-relative + PrintWindow: input coordinates are FRACTIONS of the
window client rect and addressed by process name, so a probe doesn't depend on
where the window landed or on it being foreground; capture uses `PrintWindow`,
which grabs the window even when occluded or unfocused (a plain screen grab
would catch whatever's on top).
```bash
# screenshot a demo screen
python scripts/probe/probe.py demo --screen=Images --shot images.png
# press-and-hold a point (fraction of the window) and capture DURING the press
python scripts/probe/probe.py demo --screen=Buttons --hold 0.1,0.32 --shot press.png
# hover then capture
python scripts/probe/probe.py demo --screen=Shapes --hover 0.3,0.45 --shot hov.png
# an app built elsewhere (e.g. the bridge example)
python scripts/probe/probe.py shared --exe /path/to/shared.exe --click 0.5,0.5 --shot x.png
```
- `proc` = the window's process name (`demo`, `apidemo`, `shared`).
- Actions run in order; `--hold` captures mid-press (async), `--click` /
`--hover` complete first. `--shot` captures after `--settle` seconds.
- Windows-only (uses Win32 input + PrintWindow). Pillow only needed if you
post-process the PNG.
Pairs with `scripts/parity/` (whole-screen diffing); this is for targeted
interaction repro.
+28
View File
@@ -0,0 +1,28 @@
param([string]$proc, [string]$out)
# Capture a window's CLIENT area by process name, via PrintWindow — works even
# when the window is occluded or not focused (unlike a screen grab). Used by the
# probe driver to screenshot native app windows deterministically.
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Cap {
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
public struct RECT { public int L, T, R, B; }
}
"@
$h = (Get-Process $proc -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).MainWindowHandle
if (-not $h -or $h -eq [IntPtr]::Zero) { Write-Output "NO WINDOW"; exit 1 }
$r = New-Object Cap+RECT
[Cap]::GetClientRect($h, [ref]$r) | Out-Null
$w = $r.R - $r.L; $ht = $r.B - $r.T
if ($w -le 0 -or $ht -le 0) { Write-Output "ZERO SIZE"; exit 1 }
$bmp = New-Object System.Drawing.Bitmap $w, $ht
$g = [System.Drawing.Graphics]::FromImage($bmp)
$hdc = $g.GetHdc()
# flags=3 : PW_CLIENTONLY | PW_RENDERFULLCONTENT (captures accelerated content)
[Cap]::PrintWindow($h, $hdc, 3) | Out-Null
$g.ReleaseHdc($hdc)
$bmp.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
Write-Output "captured ${w}x${ht}"
+42
View File
@@ -0,0 +1,42 @@
param(
[string]$proc, # process name of the target window
[string]$action, # click | hold | hover | move
[double]$fx, # fractional X within the window client area (0..1)
[double]$fy, # fractional Y
[int]$holdMs = 80 # press duration for 'hold'
)
# Window-CLIENT-relative synthetic input against a native app window, addressed
# by process name — so probes don't depend on where the window landed or on it
# being the foreground window (it's raised to foreground first). Coordinates are
# FRACTIONS of the client rect, so they're resolution/DPI independent.
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class In {
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr h, ref POINT p);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, UIntPtr e);
public struct RECT { public int L, T, R, B; }
public struct POINT { public int X, Y; }
public const uint DOWN = 2, UP = 4;
}
"@
$h = (Get-Process $proc -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).MainWindowHandle
if (-not $h -or $h -eq [IntPtr]::Zero) { Write-Output "NO WINDOW"; exit 1 }
$r = New-Object In+RECT
[In]::GetClientRect($h, [ref]$r) | Out-Null
$p = New-Object In+POINT
$p.X = [int](($r.R - $r.L) * $fx); $p.Y = [int](($r.B - $r.T) * $fy)
[In]::ClientToScreen($h, [ref]$p) | Out-Null
[In]::SetForegroundWindow($h) | Out-Null
Start-Sleep -Milliseconds 120
[In]::SetCursorPos($p.X, $p.Y) | Out-Null
switch ($action) {
"move" { }
"hover" { Start-Sleep -Milliseconds 250 }
"click" { Start-Sleep -Milliseconds 120; [In]::mouse_event([In]::DOWN,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 60; [In]::mouse_event([In]::UP,0,0,0,[UIntPtr]::Zero) }
"hold" { Start-Sleep -Milliseconds 120; [In]::mouse_event([In]::DOWN,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds $holdMs; [In]::mouse_event([In]::UP,0,0,0,[UIntPtr]::Zero) }
}
Write-Output "$action at $($p.X),$($p.Y)"
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
Drive a native compose-desktop-native app window for manual/automated visual
checks: launch it, send window-relative input (click / hover / hold / move),
and capture the client area via PrintWindow (works even when occluded).
Built from the ad-hoc rigs used to reproduce the square-on-click and TLS-chain
bugs — window-CLIENT-relative and process-addressed, so it doesn't care where
the window lands or whether it's focused.
Examples:
# boot the demo's Images screen, screenshot after 4s
python scripts/probe/probe.py demo --screen=Images --shot=images.png
# boot bubble-wrap (example repo), press-and-hold a bubble mid-sheet, capture
python scripts/probe/probe.py shared --exe <path-to>/shared.exe \\
--hold 0.55,0.55 --shot press.png
# hover then capture
python scripts/probe/probe.py demo --screen=Shapes --hover 0.3,0.4 --shot hov.png
`proc` is the window's process name (demo / apidemo / shared). Native apps
built here use the mingwX64 debug exe by default; override with --exe. Actions
run in order; --shot captures after them. Windows-only. Needs Pillow only if
you pass --crop.
"""
import argparse, subprocess, sys, time, os, signal
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
DEFAULT_EXE = {
"demo": REPO / "demo/build/bin/mingwX64/debugExecutable/demo.exe",
"apidemo": REPO / "apidemo/build/bin/mingwX64/debugExecutable/apidemo.exe",
}
def ps(script: str, *args: str):
return subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-File", str(HERE / script), *args],
capture_output=True, text=True,
)
def frac(s: str):
x, y = s.split(",")
return float(x), float(y)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("proc", help="window process name (demo / apidemo / shared)")
ap.add_argument("--exe", help="path to the executable (default: mingwX64 debug for demo/apidemo)")
ap.add_argument("--screen", help="pass --screen=<Name> to the app (demo)")
ap.add_argument("--args", default="", help="extra args passed verbatim to the exe")
ap.add_argument("--boot", type=float, default=4.0, help="seconds to wait after launch")
ap.add_argument("--click", help="fractional X,Y to click (e.g. 0.5,0.5)")
ap.add_argument("--hold", help="fractional X,Y to press-and-hold")
ap.add_argument("--hover", help="fractional X,Y to hover")
ap.add_argument("--hold-ms", type=int, default=900, help="hold duration")
ap.add_argument("--settle", type=float, default=0.3, help="seconds between action and capture")
ap.add_argument("--shot", help="capture the window to this PNG after the actions")
args = ap.parse_args()
exe = Path(args.exe) if args.exe else DEFAULT_EXE.get(args.proc)
if not exe or not exe.exists():
print(f"exe not found: {exe} (build it, or pass --exe)", file=sys.stderr)
return 1
cmd = [str(exe)]
if args.screen:
cmd.append(f"--screen={args.screen}")
if args.args:
cmd += args.args.split()
proc = subprocess.Popen(cmd)
try:
time.sleep(args.boot)
if args.hover:
fx, fy = frac(args.hover)
print(ps("_input.ps1", "-proc", args.proc, "-action", "hover", "-fx", str(fx), "-fy", str(fy)).stdout.strip())
if args.click:
fx, fy = frac(args.click)
print(ps("_input.ps1", "-proc", args.proc, "-action", "click", "-fx", str(fx), "-fy", str(fy)).stdout.strip())
if args.hold:
fx, fy = frac(args.hold)
# hold runs async so we can capture DURING the press
p = subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-File", str(HERE / "_input.ps1"),
"-proc", args.proc, "-action", "hold", "-fx", str(fx), "-fy", str(fy), "-holdMs", str(args.hold_ms)])
time.sleep(args.settle)
if args.shot:
out = str(Path(args.shot).resolve())
r = ps("_capture.ps1", "-proc", args.proc, "-out", out)
print(f"{r.stdout.strip()} -> {out}")
finally:
subprocess.run(["taskkill", "/F", "/IM", exe.name], capture_output=True)
return 0
if __name__ == "__main__":
sys.exit(main())