Compare commits

...
12 Commits
Author SHA1 Message Date
Bitsy aad70f342b fix(sdl): stop re-rasterizing text at device size — popup/dialog text no longer steps size/weight
Regression from 120abbad's glyph device-scaling: drawNativeText scaled
the font size by the layer affine (inFontSize * scaleY, int-quantized)
and re-rasterized. Under an animated layer scale — every Material
DropdownMenu/AlertDialog enter transition scale-animates (Menu.kt
scaleX/scaleY) — text re-rasterized at each integer size every frame, so
it visibly stepped through sizes and (via SDL3_ttf re-hinting) weights
as the popup grew in. User-reported, seen across dropdowns and dialogs.

Revert to rasterising at LOGICAL font size + 1:1 blit at the
affine-mapped position (the pre-120abbad behaviour): the layer scale
reaches text POSITION but not glyph size, so animated-scale popups are
stable. Keeps 120abbad's separate clip-mask AA/overlap fixes. Trade-off:
statically graphicsLayer-scaled text renders at logical size (a rare,
minor parity gap) — smoothly scaling glyphs needs blit-scaling a
logical rasterisation (noted for a future text-renderer pass).

Verified: demo dropdown opens with clean uniform text; text no longer
scales with the layer, so mid-animation == settled by construction.
2026-07-14 20:19:40 +02:00
Bitsy cf597c12e0 cleanup: delete dead modifier scaffolding + fix demo's fake cacheKey claims
ModifierElements.kt (ClipModifier/ClipNode + GraphicsLayerModifier/
GraphicsLayerNode with the never-implemented cacheKey) was entirely
unreferenced — apps use the upstream clip/graphicsLayer; nothing read
these. Deleted the file (compiles clean across :ui and :demo).

The demo GraphicsLayer screen advertised caching that never existed
('Cached subtree (cacheKey)', 'Combined (cache + rotation)', a
'(cached)' label) over plain graphicsLayer() calls. Removed those two
fabricated sections and the cacheKey wording; the honest Rotation
(live) / Scale / Alpha sections already show what graphicsLayer does.
2026-07-14 20:02:30 +02:00
Bitsy cbddf8ca0b docs: RESOLVED — demo-70/apidemo-144 was mixed-refresh monitors, not a renderer bug
User has a 75Hz main + 144Hz second screen; each app's fps is just the
refresh of the monitor its window opened on (SDL vsync follows the
display). Instrumentation already showed both apps at ~3.7ms draw —
well under both the 13.3ms (75Hz) and 6.7ms (144Hz) budgets, so no perf
problem exists. Records that retained-layers/dirty-region/cacheKey are
speculative with no demonstrated need; only the dead-scaffolding cleanup
is worth doing regardless.
2026-07-14 19:54:04 +02:00
Bitsy ad738a643e docs(CLAUDE): profiler now file-output + draw counters + CDN_FORCERENDER; vsync/display caveat
Documents the DrawStats per-frame counters, file output, and
CDN_FORCERENDER added this session, plus the load-bearing caveat: present
is vsync-bound so all timings are capped by the dev display's refresh —
profile on the target rate before diagnosing a frame-rate gap.
2026-07-14 19:53:12 +02:00
Bitsy 8f5c59bd64 feat(window): per-frame DrawStats in profiler + CDN_FORCERENDER; correct the 70fps theory
Extends CDN_PROFILE with per-frame draw counters (DrawStats: geometry
submits, vertices, mask realizations, text/image blits) and a
CDN_FORCERENDER=1 flag to render every frame for steady-state timing on
idle screens.

Used them to test the demo-70fps theory and DISPROVE it: forced-render,
both apps pin at 75 fps (this dev display's refresh), present ~9ms; the
demo's draw (~3.7ms, geo=40, masks=0) is actually LIGHTER than apidemo's
(~2.9ms, geo=146, masks=74). So the demo is NOT draw-bound — the
70-vs-144 split is specific to the user's 144Hz monitor and can't be
reproduced on a 75Hz box. Recorded the correct next step (profile on the
144Hz machine) in NEXT-SESSION.md; noted the vsync/display confound in
CLAUDE.md's profiler docs. Thread A (spurious continuous render) resolved
as a non-issue — the demo idles when static.
2026-07-14 19:52:24 +02:00
Bitsy 914bab840d docs: handoff notes for the renderer-caching session
Captures the demo-70fps investigation for the next session: (A) the
likely-cheaper continuous-render / hover-self-loop thread to chase first,
and (B) the cacheKey/retained-layer work — dead scaffolding today,
blocked on a content-draw-redirect primitive (the 'renderer rewrite')
that also underpins dirty regions. Lists the tooling + existing
infrastructure to build on.
2026-07-14 19:34:39 +02:00
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
14 changed files with 585 additions and 212 deletions
+51 -1
View File
@@ -458,7 +458,57 @@ build tell you what broke.
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't `Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
ship drop-in equivalents for our anchor / scrim patterns). ship drop-in equivalents for our anchor / scrim patterns).
## Parity harness — native-vs-JVM screenshot diff ## 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 (`CDN_PROFILE=1`, or `=<path>` for a specific file) and run any
native app; every ~2 s of rendered frames it writes avg/max ms per main-loop
phase (`events` / `app` pump / `pump` per-window / `render`) to a FILE
(`cdn_profile.log` by default — works for GUI-subsystem apps like the demo that
have no console). The line also carries render SUB-phases (`layout` / `draw` /
`present`) and per-frame DRAW COUNTERS from `DrawStats` (`geo` =
SDL_RenderGeometry submits, `verts`, `masks` = rounded-clip offscreen passes,
`text`, `img` blits) — so you can see WHAT `draw` is doing, not just that it's
slow. `CDN_FORCERENDER=1` renders every frame (bypasses idle-skip) so
steady-state timings can be read on otherwise-idle static screens.
**Caveat learned the hard way:** `present` is vsync-blocking, so every timing is
capped by the DISPLAY refresh of the machine running the app. A 75 Hz dev
display makes every app look "75 fps, present-bound" no matter what — so profile
on the TARGET refresh rate before concluding anything about a frame-rate gap
(the demo-70 / apidemo-144 report could not be reproduced on a 75 Hz box, where
both pinned at 75 fps and the demo's `draw` was actually the lighter of the two).
### 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 `:demo` renders the **same commonMain screens** on two stacks: native
(SDL/Skia, Kotlin/Native) and a `jvm()` target on upstream Compose Desktop. (SDL/Skia, Kotlin/Native) and a `jvm()` target on upstream Compose Desktop.
+114
View File
@@ -0,0 +1,114 @@
# Next session — renderer caching / retained layers
## RESOLVED: the 70-vs-144 "gap" was two monitors, not a bug
The user has a **75 Hz main screen + a 144 Hz second screen**. Each app's FPS is
just the refresh of whichever monitor its window opened on (SDL locks vsync to
the window's current display). demo happened to open on the 75 Hz screen,
apidemo on the 144 Hz one. Move the demo to the 144 Hz screen and it hits ~144.
Instrumentation confirmed there is NO performance problem: both apps have only
~3.7 ms `draw` per frame (demo geo=40 masks=0; apidemo geo=146 masks=74 —
apidemo does MORE and is fine), which fits both the 13.3 ms (75 Hz) and 6.7 ms
(144 Hz) budgets with large headroom. Everything is present/vsync-bound.
**Implication:** the retained-layers / dirty-region / cacheKey work is a
SPECULATIVE optimization with NO demonstrated need right now — no app misses its
frame budget. Don't spend the big content-redirect-primitive effort chasing a
phantom. Revisit only if a real, animated, heavy screen is measured (with
CDN_PROFILE, ON the target monitor) to exceed its budget.
Still worth doing regardless of perf (correctness / cleanup, small):
- Delete the dead `cacheKey` scaffolding (`GraphicsLayerModifier` /
`GraphicsLayerNode`, unread) OR wire it — and fix the demo's misleading
"(cached)" label (it uses a plain `graphicsLayer()`).
--- Original investigation notes below (kept for context) ---
Starting point after v0.1.19. Context for the perf work the profiling pointed at.
## The problem (measured)
The demo caps at ~70 fps on every screen while apidemo hits 144. Cause is NOT
vsync (both use `SDL_SetRenderVSync(renderer, 1)`) — the demo is **draw-bound**:
`CDN_PROFILE=1` showed draw ~34 ms cold / ~14 ms steady, present only ~1.3 ms.
The renderer is **immediate-mode**: every rendered frame walks the layout tree
and re-tessellates everything (text → glyph quads, shapes → triangles). The
always-present sidebar (30+ text rows + icons) is re-tessellated each frame.
Two open threads:
### FINDING (updated) — the 70-vs-144 is NOT reproducible on a 75 Hz dev box
Instrumented the draw phase (`DrawStats`: geo/verts/masks/text/img) + added
`CDN_FORCERENDER=1`. Measured both apps forced-continuous:
- demo: draw ~3.7ms, geo=40, verts=6582, masks=0, text=51
- apidemo: draw ~2.9ms, geo=146, verts=18576, masks=74, text=174
BOTH pinned at exactly 150 frames/2s = 75 fps with present ~9ms — i.e. the DEV
DISPLAY is 75 Hz, so everything is present/vsync-bound here and the demo's draw
is actually LIGHTER than apidemo's. The "demo is draw-bound" theory is WRONG on
this hardware; the 70-vs-144 split is specific to the user's 144 Hz monitor and
can't be reproduced/diagnosed from a 75 Hz box.
NEXT STEP FOR THE USER (144 Hz machine): run both apps with
`CDN_FORCERENDER=1 CDN_PROFILE=1` and share the `cdn_profile.log` lines. If demo
shows present ~13ms (75 fps) while apidemo shows present ~7ms (144 fps) at
similar draw times, it's a present/vsync-path difference (driver / swap
interval / DWM), NOT draw cost — a different investigation than caching.
The demo idles correctly when static (verified: 2 frames headless, even while
hovering the sidebar), so there's NO spurious-continuous-render bug — thread A
below is resolved as a non-issue.
### A. Why does the demo render CONTINUOUSLY? (investigate FIRST — likely cheaper)
Headless the demo idled (2 frames); interactively you see a constant 70-71 fps,
so something invalidates every frame. Prime suspect: a **hover self-loop** — the
main loop dispatches a synthetic hover every rendered frame
(`ComposeWindow.renderFrame`: `if (hasMousePos) host.onPointerRaw(...)`), and if
that perpetually re-invalidates a hover-reactive sidebar row, the app never
idles while the cursor is over it. If confirmed, the fix is to stop the spurious
invalidation so static screens idle (→ sidebar stops re-tessellating because
nothing renders) — small and targeted, no caching needed.
- Quick test: does the demo's FPS-title stop updating when the mouse leaves the
window? If yes → hover loop confirmed.
- Look at: `renderFrame` synthetic hover; how hover state feeds `needsFrame` /
`hasPendingWork` / `shouldRender()`.
### B. cacheKey / retained-layer texture caching (bigger — the "renderer rewrite")
`Modifier.graphicsLayer(cacheKey=…)` exists as API (`GraphicsLayerModifier` /
`GraphicsLayerNode` in `element/ModifierElements.kt`) but is **dead
scaffolding**: `cacheKey` is stored and never read by the renderer; the node is
a bare `Modifier.Node` with no draw behaviour (comment: "stays dormant until the
renderer rewrite drives it"). The demo's GraphicsLayer "(cached)" section uses a
plain `graphicsLayer()` — the label is aspirational; nothing is cached.
Blocker for real texture caching: it needs to render a subtree's `drawContent()`
into an **offscreen canvas**, and the renderer has no way to redirect
`drawContent()` off the frame canvas. `GraphicsLayer.native.draw()` replays its
recorded block against a canvas via `drawScope.draw(…, canvas, …)`, but
`drawContent()` is bound to the outer `ContentDrawScope`'s canvas, which nothing
swaps. The vector/icon offscreen path works only because it draws an explicit
object, not opaque `drawContent()`.
So the real unit of work is a **content-redirect primitive**: make the
`ContentDrawScope` canvas swappable (or add a coordinator-level "draw this
subtree into canvas X"). That primitive is the foundation for BOTH cacheKey
(retained layers) AND dirty-region rendering. Infrastructure that already
exists to build on: `OffscreenRenderer` (`createImageBitmap` + `createCanvas`),
`SdlImageBitmap` render-target textures + `drawImage` blit-back, and the
`NativeReleaseQueue` for freeing cached textures.
Cleanup to do regardless: either implement `cacheKey` or delete the dead
`GraphicsLayerModifier`/`GraphicsLayerNode` scaffolding and fix the demo's
misleading "(cached)" label.
## Tooling ready for this work
- `CDN_PROFILE=1 <app>` → per-phase timings (layout/draw/present) to a file.
- `scripts/parity/parity.py` → native-vs-JVM screenshot diff (regression net).
- `scripts/probe/probe.py` → drive a native window (click/hover/hold) + capture.
- See ROADMAP.md item 2 (dirty regions + retained layers promoted, with the
demo evidence) and CLAUDE.md "Tooling".
+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 ViewModels scope per nav entry, per window, or anywhere in between — same
semantics as Android. 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 ## License
[MIT](LICENSE.md). [MIT](LICENSE.md).
+29 -15
View File
@@ -42,16 +42,23 @@ RSS; expect a plateau without GC sawtooth).
- [x] **Frame profiler** — `CDN_PROFILE=1` env flag prints per-phase timings - [x] **Frame profiler** — `CDN_PROFILE=1` env flag prints per-phase timings
(events / app pump / window pump / render / present) every ~2 s. Measure (events / app pump / window pump / render / present) every ~2 s. Measure
first, optimize second. first, optimize second.
- [ ] **Right-size clip scratch targets** — the pool allocates window-sized - [ ] **Dirty-region rendering** — NEXT, promoted by profiling: the app
textures per mask depth; allocate at the clip bbox (size-bucketed pool) to re-tessellates the whole window on any invalidation. Accumulate damage from
cut fill-rate for the masks that remain after lazy clips. invalidated layers and scissor the redraw. Biggest lever for heavy scenes.
- [ ] **Dirty-region rendering** — the big structural item: accumulate damage Evidence (CDN_PROFILE): the full demo is DRAW-bound — draw ~34ms cold /
from invalidated layers, scissor redraw to it. One bubble's spring should ~14ms steady, present only ~1.3ms — because it re-tessellates the whole
not re-tessellate 84. Most likely single item to reach 75 Hz. 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 - [ ] **Glyph atlas** — text draws currently break geometry batches (z-order
flush per run); an atlas texture lets glyphs ride the vertex batches. 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 ## 3. Skia renderer
@@ -62,12 +69,18 @@ RSS; expect a plateau without GC sawtooth).
## 4. Correctness / parity gaps (future bug reports waiting) ## 4. Correctness / parity gaps (future bug reports waiting)
Ordered by likelihood of a user hitting them: 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.
- [ ] **Gradient brushes on SDL** — `Brush.linearGradient/radialGradient` - [x] **Gradient brushes on SDL** — samplers were already implemented; the real
render solid; the per-vertex sampler infrastructure is half-present. bug was UNDER-SAMPLING: fills sampled only corners, so radial/sweep on a
- [ ] **Layer rotation** — rotationZ repositions but content doesn't rotate rect/round-rect rendered FLAT (a square's corners are equidistant from centre
(hit-testing too). → 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 - [ ] **Real `saveLayer` alpha on SDL** — overlapping content composites at
paint level; needs an offscreen (the clip-target pool can serve it). paint level; needs an offscreen (the clip-target pool can serve it).
- [ ] **`clipPath` generic shapes** — bbox fallback clips square. - [ ] **`clipPath` generic shapes** — bbox fallback clips square.
@@ -78,8 +91,9 @@ Ordered by likelihood of a user hitting them:
JVM from the same commonMain composables, pixel-diffs, ranks by %differ, JVM from the same commonMain composables, pixel-diffs, ranks by %differ,
emits per-screen diff heatmaps + side-by-side compares (pct in filename). emits per-screen diff heatmaps + side-by-side compares (pct in filename).
build/parity/ (gitignored). See scripts/parity/README.md. build/parity/ (gitignored). See scripts/parity/README.md.
- [ ] Promote the press/hover automation rig (window-handle-relative input + - [x] Promote the press/hover automation rig into `scripts/probe/`:
screenshot) into `scripts/`. window-CLIENT-relative input (click/hover/hold, fractional coords, process-
addressed) + PrintWindow capture (works occluded). See scripts/probe/README.md.
## 6. Long-term ## 6. Long-term
@@ -32,6 +32,7 @@ import com.compose.sdl.window.createPopupHostState
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.cinterop.toKString
import kotlinx.cinterop.reinterpret import kotlinx.cinterop.reinterpret
import sdl3.SDL_Delay import sdl3.SDL_Delay
import sdl3.SDL_GetPerformanceCounter import sdl3.SDL_GetPerformanceCounter
@@ -150,9 +151,8 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// Main loop // Main loop
var vGcLastTicks = SDL_GetTicks() var vGcLastTicks = SDL_GetTicks()
var vRenderedSinceGc = false var vRenderedSinceGc = false
val vProfiler = if (platform.posix.getenv("CDN_PROFILE") != null) FrameProfiler() else null
while (!runtime.exitRequested) { while (!runtime.exitRequested) {
vProfiler?.mark() FrameProfiler.mark()
Snapshot.sendApplyNotifications() Snapshot.sendApplyNotifications()
// ============ // ============
@@ -180,7 +180,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
} }
mainDispatcher.drainPending() mainDispatcher.drainPending()
vProfiler?.phase(0) FrameProfiler.phase("events")
// ============ // ============
// App composition pump — Window()s may appear / disappear here. // App composition pump — Window()s may appear / disappear here.
@@ -200,7 +200,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// Exit when the last window is gone (after at least one existed). // Exit when the last window is gone (after at least one existed).
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
if (runtime.exitRequested) break if (runtime.exitRequested) break
vProfiler?.phase(1) FrameProfiler.phase("app")
// ============ // ============
// Per-window pump + render. // Per-window pump + render.
@@ -214,16 +214,16 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
Snapshot.sendApplyNotifications() Snapshot.sendApplyNotifications()
vW.frameClock.sendFrame() vW.frameClock.sendFrame()
yield() yield()
vProfiler?.phase(2) FrameProfiler.phase("pump")
if (vW.shouldRender()) { if (vW.shouldRender()) {
vW.renderFrame() vW.renderFrame()
vAnyRendered = true vAnyRendered = true
if (!vW.backend.vsyncEnabled) vAllVsync = false if (!vW.backend.vsyncEnabled) vAllVsync = false
} }
vProfiler?.phase(3) FrameProfiler.phase("render")
} }
runtime.reapDestroyed() runtime.reapDestroyed()
vProfiler?.frameDone(vAnyRendered) FrameProfiler.frameDone(vAnyRendered)
// ============ // ============
// Drain deferred native-resource disposals on the MAIN thread — // Drain deferred native-resource disposals on the MAIN thread —
@@ -280,41 +280,68 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
SDL_Quit() SDL_Quit()
} }
/* CDN_PROFILE=1 — per-phase main-loop timings, printed every ~2s of rendered /* CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
frames. Phases: events (poll+dispatch), app (app-composition pump), pump A named-phase SINGLETON so both the main loop (events / app / pump / render)
(per-window clocks/dispatch), render (layout+draw+present). Measure first, AND renderFrame's sub-steps (render.layout / render.draw / render.present)
optimize second — see ROADMAP.md. */ 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) @OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal class FrameProfiler { 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() private val fFreq = SDL_GetPerformanceFrequency().toDouble()
private val fSum = DoubleArray(4) // Insertion-ordered so the printed line follows the call order.
private val fMax = DoubleArray(4) private val fSum = LinkedHashMap<String, Double>()
private val fMax = LinkedHashMap<String, Double>()
private var fFrames = 0 private var fFrames = 0
private var fLastPrintMs = SDL_GetTicks() private var fLastPrintMs = SDL_GetTicks()
private var fMark = 0uL private var fMark = 0uL
fun mark() { fMark = SDL_GetPerformanceCounter() } fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
fun phase(inIndex: Int) { fun phase(inName: String) {
if (!enabled) return
val vNow = SDL_GetPerformanceCounter() val vNow = SDL_GetPerformanceCounter()
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
fSum[inIndex] += vMs fSum[inName] = (fSum[inName] ?: 0.0) + vMs
if (vMs > fMax[inIndex]) fMax[inIndex] = vMs if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
fMark = vNow fMark = vNow
} }
fun frameDone(inRendered: Boolean) { fun frameDone(inRendered: Boolean) {
if (!enabled) return
if (inRendered) fFrames++ if (inRendered) fFrames++
val vNowMs = SDL_GetTicks() val vNowMs = SDL_GetTicks()
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) { if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
val vNames = listOf("events", "app", "pump", "render") val vParts = fSum.keys.map { vName ->
val vParts = vNames.mapIndexed { vI, vName -> val vAvg = (fSum[vName] ?: 0.0) / fFrames
val vAvg = fSum[vI] / fFrames "$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
"$vName=${(vAvg * 100).toInt() / 100.0}/${(fMax[vI] * 100).toInt() / 100.0}ms"
} }
val vTotal = fSum.sum() / fFrames // Per-frame draw-work averages (see DrawStats): what's inside `draw`.
println("[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " total=${(vTotal * 100).toInt() / 100.0}ms") val vStats = com.compose.sdl.graphics.DrawStats
for (vI in fSum.indices) { fSum[vI] = 0.0; fMax[vI] = 0.0 } val vDraw = "geo=${vStats.geometrySubmits / fFrames} verts=${vStats.vertices / fFrames} " +
"masks=${vStats.maskRealizations / fFrames} text=${vStats.textDraws / fFrames} img=${vStats.imageBlits / fFrames}"
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " | per-frame " + vDraw + "\n"
val vFile = platform.posix.fopen(fPath, "a")
if (vFile != null) {
platform.posix.fputs(vLine, vFile)
platform.posix.fclose(vFile)
}
vStats.reset()
fSum.clear(); fMax.clear()
fFrames = 0 fFrames = 0
fLastPrintMs = vNowMs fLastPrintMs = vNowMs
} }
@@ -323,6 +350,8 @@ internal class FrameProfiler {
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release /* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
their native memory (see the main loop's native-memory nudge). */ their native memory (see the main loop's native-memory nudge). */
private val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class) @OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect() private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
@@ -723,7 +752,10 @@ internal class WindowInstance(
// Frame pump // Frame pump
fun shouldRender(): Boolean = fun shouldRender(): Boolean =
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
// TEMP measurement: CDN_FORCERENDER=1 forces every frame to render so
// sustained steady-state timings can be measured on otherwise-idle screens.
fun renderFrame() { fun renderFrame() {
val vRender = renderBackend ?: return val vRender = renderBackend ?: return
@@ -734,6 +766,7 @@ internal class WindowInstance(
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight) vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
host.setConstraints(backend.pixelWidth, backend.pixelHeight) host.setConstraints(backend.pixelWidth, backend.pixelHeight)
host.measureAndLayout() host.measureAndLayout()
FrameProfiler.phase(" layout")
// Hover refresh after layout (upstream skiko: SyntheticEventSender). // Hover refresh after layout (upstream skiko: SyntheticEventSender).
if (hasMousePos) { if (hasMousePos) {
@@ -746,7 +779,9 @@ internal class WindowInstance(
// Probe consumers end the app when their scenario completes. // Probe consumers end the app when their scenario completes.
facade.close() facade.close()
} }
FrameProfiler.phase(" draw")
vRender.endFrame() vRender.endFrame()
FrameProfiler.phase(" present")
frameIndex++ frameIndex++
// FPS — refreshed ~once a second, per window. // FPS — refreshed ~once a second, per window.
@@ -1,97 +0,0 @@
package com.compose.sdl.element
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.node.ModifierNodeElement
// ==================
// MARK: Project modifier elements
// ==================
// Small set of project-only modifier elements — each pairs a
// `ModifierNodeElement<XxxNode>` with a `XxxNode : Modifier.Node`. Upstream
// LayoutNode reads them through the chain (ModifierNodeElement IS-A
// Modifier.Element). Everything gesture-related has been removed — pointer
// input, text input, right/middle click, drag: all go through the standard
// `Modifier.pointerInput` + `detectTapGestures` / `detectDragGestures` /
// `awaitPointerEventScope` APIs upstream now supplies. What's left is the
// two elements the renderer still owns directly:
// * ClipModifier — GraphicsLayer.kt lowers `clip = true` to it.
// * GraphicsLayerModifier — the transform / alpha / cache pipeline.
class ClipModifier(val shape: Shape) : ModifierNodeElement<ClipNode>() {
override fun create() = ClipNode(shape)
override fun update(node: ClipNode) { node.shape = shape }
override fun hashCode(): Int = shape.hashCode()
override fun equals(other: Any?): Boolean = other is ClipModifier && other.shape == shape
}
class ClipNode(var shape: Shape) : Modifier.Node()
// ==================
// MARK: GraphicsLayerModifier
// ==================
/**
* A "graphics layer" element: alpha + 2D transform (scale / rotation /
* translation), with an optional cacheKey that opts the subtree into
* render-to-texture caching across frames. See `Modifier.graphicsLayer`
* (in `androidx.compose.ui.graphics`) for the caching semantics.
*
* The renderer reads this element directly via the `LayoutNode.graphicsLayer`
* `foldIn` over the chain; the paired [GraphicsLayerNode] lifecycle stays
* dormant until the renderer rewrite drives it.
*/
class GraphicsLayerModifier(
val alpha: Float = 1f,
val scaleX: Float = 1f,
val scaleY: Float = 1f,
val rotationZ: Float = 0f,
val translationX: Float = 0f,
val translationY: Float = 0f,
val transformOrigin: TransformOrigin = TransformOrigin.Center,
val cacheKey: Any? = null,
) : ModifierNodeElement<GraphicsLayerNode>() {
val needsLayer: Boolean
get() = alpha < 1f || cacheKey != null
val needsTransform: Boolean
get() = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
translationX != 0f || translationY != 0f
val isIdentity: Boolean
get() = !needsLayer && !needsTransform
override fun create() =
GraphicsLayerNode(alpha, scaleX, scaleY, rotationZ, translationX, translationY, transformOrigin, cacheKey)
override fun update(node: GraphicsLayerNode) {
node.alpha = alpha; node.scaleX = scaleX; node.scaleY = scaleY
node.rotationZ = rotationZ; node.translationX = translationX; node.translationY = translationY
node.transformOrigin = transformOrigin; node.cacheKey = cacheKey
}
override fun hashCode(): Int {
var v = alpha.hashCode()
v = 31 * v + scaleX.hashCode(); v = 31 * v + scaleY.hashCode()
v = 31 * v + rotationZ.hashCode()
v = 31 * v + translationX.hashCode(); v = 31 * v + translationY.hashCode()
v = 31 * v + transformOrigin.hashCode(); v = 31 * v + (cacheKey?.hashCode() ?: 0)
return v
}
override fun equals(other: Any?): Boolean =
other is GraphicsLayerModifier &&
other.alpha == alpha && other.scaleX == scaleX && other.scaleY == scaleY &&
other.rotationZ == rotationZ &&
other.translationX == translationX && other.translationY == translationY &&
other.transformOrigin == transformOrigin && other.cacheKey == cacheKey
}
class GraphicsLayerNode(
var alpha: Float,
var scaleX: Float,
var scaleY: Float,
var rotationZ: Float,
var translationX: Float,
var translationY: Float,
var transformOrigin: TransformOrigin,
var cacheKey: Any?,
) : Modifier.Node()
@@ -0,0 +1,34 @@
package com.compose.sdl.graphics
// ==================
// MARK: DrawStats — per-frame renderer work counters (diagnostic)
// ==================
/**
* Cheap per-frame counters the SDL draw path bumps and the frame profiler
* reads, to answer "what inside `draw` costs the time?" — geometry submissions
* (SDL_RenderGeometry calls), total vertices tessellated, rounded-clip mask
* realizations (offscreen render-target passes — the expensive ones), text
* blits and image blits. Reset each frame by the profiler after reading.
*
* Single-threaded (all draw is on the main thread), so plain vars are fine.
* Zero overhead when the profiler isn't reading — just integer increments.
*/
object DrawStats {
var geometrySubmits: Int = 0
var vertices: Int = 0
var maskRealizations: Int = 0
var textDraws: Int = 0
var imageBlits: Int = 0
fun reset() {
geometrySubmits = 0
vertices = 0
maskRealizations = 0
textDraws = 0
imageBlits = 0
}
fun summary(): String =
"geo=$geometrySubmits verts=$vertices masks=$maskRealizations text=$textDraws img=$imageBlits"
}
@@ -259,6 +259,7 @@ internal class Sdl3Canvas(
fClip = vRegion fClip = vRegion
applyClip() applyClip()
clearRegion(vRegion) clearRegion(vRegion)
com.compose.sdl.graphics.DrawStats.maskRealizations++
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound)) fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound))
// Attribute the realized mask to the save-frame that pushed the // Attribute the realized mask to the save-frame that pushed the
// clip: inner frames entered since must NOT composite it on their // clip: inner frames entered since must NOT composite it on their
@@ -963,6 +964,7 @@ internal class Sdl3Canvas(
// a 2px margin for glyph overhang / AA bleed, instead of paying a // a 2px margin for glyph overhang / AA bleed, instead of paying a
// mask pass per text run. // mask pass per text run.
admitDraw(inX - 2f, inY - 2f, inX + inBoxWidth + 2f, inY + inBoxHeight + 2f) admitDraw(inX - 2f, inY - 2f, inX + inBoxWidth + 2f, inY + inBoxHeight + 2f)
com.compose.sdl.graphics.DrawStats.textDraws++
fScope.flush() fScope.flush()
// Paragraph-level decoration bits forwarded to every wrapped line. // Paragraph-level decoration bits forwarded to every wrapped line.
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
@@ -978,26 +980,25 @@ internal class Sdl3Canvas(
// node's real box. The per-line path below centres within a lineHeight band // node's real box. The per-line path below centres within a lineHeight band
// (1.2 em for Material Symbols), taller than the size-clamped icon node, // (1.2 em for Material Symbols), taller than the size-clamped icon node,
// which pushed every icon ~0.1 em below centre. // which pushed every icon ~0.1 em below centre.
// Device-space scale for GLYPHS: the pen position already maps through // Text is rasterised at its LOGICAL font size (stable glyph-texture cache
// the affine, but font size and centering boxes must scale with it too // + stable hinting) and blit 1:1 at the affine-mapped pen position; the
// or text inside a graphicsLayer(scale) renders at full size, off // layer's scale reaches position but NOT glyph size. Re-rasterising at a
// centre (JVM/Skia scales glyphs with the canvas). Wrap stays in LOCAL // per-frame device size (an earlier attempt) made text under an animated
// units below so line breaks match what layout measured. // layer scale — every Material popup/dropdown enter transition
val vTextScaleX = sqrt(fMa * fMa + fMb * fMb) // (Menu.kt scaleX/scaleY) — step through integer sizes with visible
val vTextScaleY = sqrt(fMc * fMc + fMd * fMd) // size/weight jumps, since SDL3_ttf re-hints at each size. Smoothly
val vDeviceFontPx = // scaling glyphs would need blit-scaling a logical-size rasterisation
if (vTextScaleY == 1f) inFontSizePx // (JVM/Skia GPU-scales the layer) — a future text-renderer enhancement;
else (inFontSizePx * vTextScaleY).toInt().coerceAtLeast(1) // until then logical-size + no glyph scale is the stable choice.
if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) { if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
vTr.drawText( vTr.drawText(
inText = inText, inText = inText,
inX = mapX(inX, inY).toInt(), inX = mapX(inX, inY).toInt(),
inY = mapY(inX, inY).toInt(), inY = mapY(inX, inY).toInt(),
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(), inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = (inBoxHeight * vTextScaleY).toInt(), inBoxHeight = inBoxHeight.toInt(),
inColor = vColor, inColor = vColor,
inFontSize = vDeviceFontPx, inFontSize = inFontSizePx,
inAlign = inTextAlign, inAlign = inTextAlign,
inFontFamily = inFontFamily, inFontFamily = inFontFamily,
inFontVariations = inFontVariations, inFontVariations = inFontVariations,
@@ -1040,10 +1041,10 @@ internal class Sdl3Canvas(
// scale with the layer. Rotation still only repositions. // scale with the layer. Rotation still only repositions.
inX = mapX(inX, vLineY).toInt(), inX = mapX(inX, vLineY).toInt(),
inY = mapY(inX, vLineY).toInt(), inY = mapY(inX, vLineY).toInt(),
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(), inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = (vLineH * vTextScaleY).toInt(), inBoxHeight = vLineH.toInt(),
inColor = vColor, inColor = vColor,
inFontSize = vDeviceFontPx, inFontSize = inFontSizePx,
inAlign = inTextAlign, inAlign = inTextAlign,
inFontFamily = inFontFamily, inFontFamily = inFontFamily,
inFontVariations = inFontVariations, inFontVariations = inFontVariations,
@@ -1115,6 +1116,7 @@ internal class Sdl3Canvas(
) { ) {
realizePendingClips() realizePendingClips()
val vTex = (image as? SdlImageBitmap)?.texture ?: return val vTex = (image as? SdlImageBitmap)?.texture ?: return
com.compose.sdl.graphics.DrawStats.imageBlits++
// Commit pending frame geometry and re-assert this canvas's target + clip // Commit pending frame geometry and re-assert this canvas's target + clip
// (an offscreen render just borrowed the render target). // (an offscreen render just borrowed the render target).
fScope.flush() fScope.flush()
@@ -141,6 +141,8 @@ internal class Sdl3DrawScope(
) )
} }
SDL_RenderGeometry(fRenderer.reinterpret(), null, fBatch, fBatchCount, null, 0) SDL_RenderGeometry(fRenderer.reinterpret(), null, fBatch, fBatchCount, null, 0)
com.compose.sdl.graphics.DrawStats.geometrySubmits++
com.compose.sdl.graphics.DrawStats.vertices += fBatchCount
fBatchCount = 0 fBatchCount = 0
} }
@@ -243,7 +245,10 @@ internal class Sdl3DrawScope(
val vB = vT + size.height val vB = vT + size.height
when (style) { when (style) {
Fill -> { 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 // Axis-aligned rects are pixel-crisp and need no AA. When the affine
// rotates/shears them, the edges become diagonal — feather each edge // rotates/shears them, the edges become diagonal — feather each edge
// outward (normals in local space; the matrix orients them on screen). // outward (normals in local space; the matrix orients them on screen).
@@ -524,20 +529,30 @@ internal class Sdl3DrawScope(
val vY = fOriginY + topLeft.y val vY = fOriginY + topLeft.y
val vW = size.width val vW = size.width
val vH = size.height 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) { if (vR <= 0f) {
// Trivial: just two triangles. if (vGrad) emitRectMesh(vX, vY, vX + vW, vY + vH, vSampler)
emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler) else emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
return return
} }
// Body in 3 strips: middle (full width × inner height), top edge, // Body in 3 strips: middle (full width × inner height), top edge,
// bottom edge — plus the 4 corner arcs. // bottom edge — plus the 4 corner arcs.
if (style == Fill) { if (style == Fill) {
// Middle strip if (vGrad) {
emitQuad(vX, vY + vR, vX + vW, vY + vR, vX + vW, vY + vH - vR, vX, vY + vH - vR, vSampler) emitRectMesh(vX, vY + vR, vX + vW, vY + vH - vR, vSampler) // middle
// Top edge (between left+right corners) emitRectMesh(vX + vR, vY, vX + vW - vR, vY + vR, vSampler) // top edge
emitQuad(vX + vR, vY, vX + vW - vR, vY, vX + vW - vR, vY + vR, vX + vR, vY + vR, vSampler) emitRectMesh(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH, vSampler) // bottom edge
// Bottom edge } else {
emitQuad(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH - vR, vX + vW - vR, vY + vH, vX + vR, vY + vH, vSampler) // 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. // 4 corner fills — segment count adapts to the corner radius.
val vSeg = arcSegments(90f, vR) val vSeg = arcSegments(90f, vR)
emitFilledArc(vX + vR, vY + vR, vR, vR, 180f, 90f, false, vSeg, vSampler) emitFilledArc(vX + vR, vY + vR, vR, vR, 180f, 90f, false, vSeg, vSampler)
@@ -854,6 +869,34 @@ internal class Sdl3DrawScope(
emitTri(ax, ay, cx, cy, dx, dy, inSampler) 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( private fun emitTri(
ax: Float, ay: Float, bx: Float, by: Float, cx: Float, cy: Float, ax: Float, ay: Float, bx: Float, by: Float, cx: Float, cy: Float,
inSampler: Sampler, inSampler: Sampler,
@@ -42,55 +42,9 @@ internal fun GraphicsLayerScreen() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTitle( ScreenTitle(
"Modifier.graphicsLayer", "Modifier.graphicsLayer",
"2D transform (scale / rotation / translation) + alpha + optional cacheKey opt-in. " + "2D transform (scale / rotation / translation) + alpha, applied to a subtree.",
"When cacheKey is set, the renderer renders the subtree into an offscreen target once " +
"and reuses it while the key compares equal — change the key to invalidate.",
) )
Section(
"Cached subtree (cacheKey)",
"Identical to a fresh-drawn row visually, but the subtree is rendered once and reused while " +
"cacheKey doesn't change. Useful for static screens — no per-frame redraw of the chrome.",
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
for (vI in 0..3) {
Box(
modifier = Modifier
.size(64.dp)
.graphicsLayer(),
) {
Canvas(modifier = Modifier.size(64.dp)) {
drawCircle(color = vPrimary, radius = 22f, center = Offset(32f, 32f))
drawCircle(color = vSecondary, radius = 12f, center = Offset(32f, 32f))
}
}
}
Text("(cached)", color = vPrimary, fontSize = 12.sp)
}
}
Section("Combined (cache + rotation)", "Cached subtree blitted with live rotation around its centre.") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
for (vI in 0..4) {
Box(
modifier = Modifier
.size(64.dp)
.graphicsLayer(
rotationZ = vSpin + vI * 30f,
),
) {
Canvas(modifier = Modifier.size(64.dp)) {
drawRect(
color = if (vI % 2 == 0) vPrimary else vSecondary,
topLeft = Offset(10f, 10f),
size = Size(44f, 44f),
)
}
}
}
}
}
Section("Rotation (live)", "Each tile holds the same shape rotated at multiples of the current spin angle.") { Section("Rotation (live)", "Each tile holds the same shape rotated at multiples of the current spin angle.") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
for (vI in 0..4) { for (vI in 0..4) {
+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())