mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-05 18:57:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b420a7bad | ||
|
|
2dad6c9bcc | ||
|
|
723f80c8af | ||
|
|
450247175f | ||
|
|
5d6f772a1c | ||
|
|
575c19f921 | ||
|
|
328663b9e7 | ||
|
|
c9ddd3488d | ||
|
|
9958386792 | ||
|
|
f5fae6d32e | ||
|
|
b94f1f7bbb | ||
|
|
ffbb87eddd | ||
|
|
63fad4083f | ||
|
|
872d740ce7 |
@@ -458,6 +458,46 @@ build tell you what broke.
|
||||
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
||||
ship drop-in equivalents for our anchor / scrim patterns).
|
||||
|
||||
## Parity harness — native-vs-JVM screenshot diff
|
||||
|
||||
`: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,
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# 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.
|
||||
- [ ] **Right-size clip scratch targets** — the pool allocates window-sized
|
||||
textures per mask depth; allocate at the clip bbox (size-bucketed pool) to
|
||||
cut fill-rate for the masks that remain after lazy clips.
|
||||
- [ ] **Dirty-region rendering** — the big structural item: accumulate damage
|
||||
from invalidated layers, scissor redraw to it. One bubble's spring should
|
||||
not re-tessellate 84. Most likely single item to reach 75 Hz.
|
||||
- [ ] **Glyph atlas** — text draws currently break geometry batches (z-order
|
||||
flush per run); an atlas texture lets glyphs ride the vertex batches.
|
||||
- [ ] **Retained layer textures** (later) — RenderNode-style caching of static
|
||||
subtrees keyed by draw content.
|
||||
|
||||
## 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:
|
||||
|
||||
- [ ] **Gradient brushes on SDL** — `Brush.linearGradient/radialGradient`
|
||||
render solid; the per-vertex sampler infrastructure is half-present.
|
||||
- [ ] **Layer rotation** — rotationZ repositions but content doesn't rotate
|
||||
(hit-testing too).
|
||||
- [ ] **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.
|
||||
- [ ] Promote the press/hover automation rig (window-handle-relative input +
|
||||
screenshot) into `scripts/`.
|
||||
|
||||
## 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.
|
||||
@@ -34,6 +34,8 @@ import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
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
|
||||
@@ -148,7 +150,9 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
// Main loop
|
||||
var vGcLastTicks = SDL_GetTicks()
|
||||
var vRenderedSinceGc = false
|
||||
val vProfiler = if (platform.posix.getenv("CDN_PROFILE") != null) FrameProfiler() else null
|
||||
while (!runtime.exitRequested) {
|
||||
vProfiler?.mark()
|
||||
Snapshot.sendApplyNotifications()
|
||||
|
||||
// ============
|
||||
@@ -176,6 +180,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
}
|
||||
|
||||
mainDispatcher.drainPending()
|
||||
vProfiler?.phase(0)
|
||||
|
||||
// ============
|
||||
// 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
|
||||
vProfiler?.phase(1)
|
||||
|
||||
// ============
|
||||
// Per-window pump + render.
|
||||
@@ -208,13 +214,24 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
Snapshot.sendApplyNotifications()
|
||||
vW.frameClock.sendFrame()
|
||||
yield()
|
||||
vProfiler?.phase(2)
|
||||
if (vW.shouldRender()) {
|
||||
vW.renderFrame()
|
||||
vAnyRendered = true
|
||||
if (!vW.backend.vsyncEnabled) vAllVsync = false
|
||||
}
|
||||
vProfiler?.phase(3)
|
||||
}
|
||||
runtime.reapDestroyed()
|
||||
vProfiler?.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,47 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
SDL_Quit()
|
||||
}
|
||||
|
||||
/* CDN_PROFILE=1 — per-phase main-loop timings, printed every ~2s of rendered
|
||||
frames. Phases: events (poll+dispatch), app (app-composition pump), pump
|
||||
(per-window clocks/dispatch), render (layout+draw+present). Measure first,
|
||||
optimize second — see ROADMAP.md. */
|
||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
internal class FrameProfiler {
|
||||
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
|
||||
private val fSum = DoubleArray(4)
|
||||
private val fMax = DoubleArray(4)
|
||||
private var fFrames = 0
|
||||
private var fLastPrintMs = SDL_GetTicks()
|
||||
private var fMark = 0uL
|
||||
|
||||
fun mark() { fMark = SDL_GetPerformanceCounter() }
|
||||
|
||||
fun phase(inIndex: Int) {
|
||||
val vNow = SDL_GetPerformanceCounter()
|
||||
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
|
||||
fSum[inIndex] += vMs
|
||||
if (vMs > fMax[inIndex]) fMax[inIndex] = vMs
|
||||
fMark = vNow
|
||||
}
|
||||
|
||||
fun frameDone(inRendered: Boolean) {
|
||||
if (inRendered) fFrames++
|
||||
val vNowMs = SDL_GetTicks()
|
||||
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
|
||||
val vNames = listOf("events", "app", "pump", "render")
|
||||
val vParts = vNames.mapIndexed { vI, vName ->
|
||||
val vAvg = fSum[vI] / fFrames
|
||||
"$vName=${(vAvg * 100).toInt() / 100.0}/${(fMax[vI] * 100).toInt() / 100.0}ms"
|
||||
}
|
||||
val vTotal = fSum.sum() / fFrames
|
||||
println("[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " total=${(vTotal * 100).toInt() / 100.0}ms")
|
||||
for (vI in fSum.indices) { fSum[vI] = 0.0; fMax[vI] = 0.0 }
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+211
-13
@@ -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).
|
||||
|
||||
+14
-6
@@ -893,7 +893,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 +920,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.
|
||||
|
||||
+49
-7
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
@@ -20,4 +50,43 @@ fun main() = application {
|
||||
App(isJvm = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
+1
@@ -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) }) {
|
||||
|
||||
+76
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
-8
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user