Compare commits

..
12 Commits
Author SHA1 Message Date
Bitsy 9d2656f905 refactor: make module-local project declarations internal
162 declarations leave the published API. They were public only because nothing
had ever audited the surface - none is Compose Multiplatform API, and none was
a deliberate native extra.

Removed by package:
  com.compose.sdl.graphics       45   canvas/painter/gradient/path plumbing
  com.compose.sdl.renderer.skia  23   SkiaBridge + SkiaSurfaceBridge
  com.compose.sdl.platform       21   DefaultViewConfiguration
  com.compose.sdl.res            17   ResourcePainter, vector->SVG, resource kinds
  com.compose.sdl.window         11   popup host internals
  com.compose.sdl.icons           7   IconDefaults

Method: inventory each module's public declarations from its klib dump by
DECLARATION OWNER (not by grepping names, which the earlier pass got wrong),
keep those referenced from another module, mark the rest internal, then let the
COMPILER adjudicate. That last step matters - it rejected three candidates that
look module-local but are real API, and I would have got at least one of them
wrong by reasoning alone:

  EncodedImageDecoder  exposed by a public property - it is an install seam
  RawSdlHandles        the return type of rawSdlHandles()
  ApplicationScope     the receiver of nativeComposeApp { Window(...) }

All three stay public.

Compose Multiplatform surface untouched: every removed line is owned by a
com.compose.sdl.* package, zero androidx.compose.* or org.jetbrains.compose.*.

Verified at runtime, since internalising the resource and renderer plumbing is
exactly the kind of change that compiles and then fails to draw: nav3test,
backtest and multiwintest PASS; the Images screen (which drives ResourcePainter,
the vector->SVG path and the image cache) renders 2112 distinct colours; the CPU
raster path still renders.

NOTE what this does NOT do. Kotlin `internal` is per-module and this repo spans
~20 Gradle modules, so anything crossing a module boundary must stay public -
SDL3Backend, ComposeRootHost, AppEvent and the rest of the cross-module plumbing
are still exposed. Narrowing those needs a @RequiresOptIn marker (upstream's
@InternalComposeUiApi pattern); none exists in this project yet.
2026-09-18 22:39:13 +02:00
Bitsy 48cfa5da4d docs(renderer): record why RenderBackend keeps its one-implementation interface
SkiaRenderBackend is the only implementor, which invites 'collapse the
interface, it is dual-backend residue'. It is not, and I tried it: the concrete
type lives in skikoRendererMain, BELOW nativeMain, so :desktop-native-window's
WindowInstance - which holds the renderer in nativeMain - cannot name it.
compileNativeMainKotlinMetadata fails with "Unresolved reference 'renderer'".

Same constraint as the expect fun createRenderBackend that returns it, whose
KDoc already explains its half. Documented on the interface so the next pass
does not spend the same detour.
2026-09-18 22:32:31 +02:00
Bitsy 856cbe5ef3 refactor(renderer): flatten GpuMode - every mode is Skia
GpuMode read as `Software` vs a nested `Skia.*` group, which dated from when
the alternative to Skia was SDL's own renderer. With SDL reduced to windowing
that split is actively misleading: its own KDoc said "Software - SKIA CPU
raster", so all three modes were Skia and the taxonomy implied otherwise.

  Auto / Skia.Metal / Skia.OpenGL / Software
    -> Auto / Metal / OpenGL / CpuRaster

The axis is now what it actually is: how Skia's output reaches the screen.
Added GpuMode.isGpuAccelerated so the GPU-to-CPU fallback in WindowInstance
reads as intent rather than `gpuMode is GpuMode.Skia`.

CLI compatibility kept: --gpu still accepts the retired skia.metal / skia.opengl
/ skia-* spellings alongside the bare metal / opengl / cpu / software / raster
names, so existing scripts and docs do not break.

API break is in com.compose.sdl only - no androidx.compose.* or
org.jetbrains.compose.* declaration changes, so the Compose Multiplatform
surface this port mirrors is untouched.

Verified the renderer actually still works on both paths rather than just
compiling: the resolved default reports gpu=OpenGL, every --gpu alias parses and
renders, and CPU-raster vs OpenGL screenshots of the Buttons screen differ by
0.01% of pixels (antialiasing only) with identical mean RGB.
2026-09-18 22:31:20 +02:00
Bitsy 5c7308c614 refactor(demo,renderer): split the probe suite out of MainNative; drawRoot is abstract
MainNative.kt held the entry point plus 20 probe scenarios in 1240 lines. The
scenarios now live in demo/src/nativeMain/kotlin/probes/, grouped by what they
exercise, and MainNative.kt is 284 lines of argument dispatch:

  InputProbes.kt      click, toggle, key, scroll, cursor, IME
  WindowProbes.kt     multi-window, window info, search-escape, locale
  AnimationProbes.kt  shared transition, AnimatedVisibility, dialog entry
  TextProbes.kt       font resolution, paragraph layout, metrics
  LifecycleProbes.kt  back navigation, Navigation3
  RenderProbes.kt     soak run, encoded-image decode

Behaviour is unchanged - each probe is `internal` so main()'s dispatch still
reaches it - and 134 imports that did not apply to the file they landed in were
pruned. Verified by running them, not just compiling: nav3test, backtest,
multiwintest and winattrtest still PASS, and clicktest/scrolltest (whose
scenarios moved) still report their expected values.

RenderBackend.drawRoot is now abstract rather than carrying an empty default
body. The no-op dated from when a second, non-Skia backend could opt out of it;
with SkiaRenderBackend the only implementation, an unimplemented drawRoot would
silently render nothing instead of failing to compile.

NOT done, and it should not be: removing the expect/actual createRenderBackend
seam. It looks like dual-backend residue but is load-bearing for a different
reason its KDoc already documents - the actual sits in skikoRendererMain, below
nativeMain, so without the expect it is invisible to nativeMain-level consumers.
Tested by deleting it: compileCommonMainKotlinMetadata still passed, but
:desktop-native-window's compileNativeMainKotlinMetadata failed with
"Unresolved reference 'createRenderBackend'" - and that is the compile the
WINDOWS publish job runs to produce the root modules, so it would have broken
publishing rather than local builds.
2026-09-18 22:25:58 +02:00
Bitsy 2fb322776a refactor: rename the f-prefixed member convention to m
500 occurrences across 31 files: mWidth, mSurface, mCache and friends. The `f`
prefix is retired project-wide.

Scope was checked before touching anything, because a blind rename here could
have done real damage:

- VENDORED code is untouched. All 32 src/vendor trees contain ZERO f-prefixed
  names (upstream Compose does not use the convention), so nothing in the
  byte-for-byte copies could be caught by the pattern. check-vendor-clean and
  check-vendor-drift both still pass.
- API-NEUTRAL. No f-prefixed name appears in any published .klib.api dump -
  they were all private - so this changes no consumer-visible surface, and
  apiCheck passes without a re-dump.
- The only matches inside quotes were string TEMPLATES ("...at $mPos",
  "frames=$mFrames"), i.e. code references that had to be renamed with
  everything else, not literal text.

Verified at runtime, not just by compiling: --nav3test, --backtest,
--multiwintest and --winattrtest all pass, and a Buttons screenshot still
renders.

Also corrects CLAUDE.md's Conventions section, which claimed the project used
"no f/in/v prefixes". That had not been true for a long time - the code uses v
for locals, in for parameters and k for file-level constants throughout. It now
documents what the code actually does, including that Composable parameters stay
plain because the name is part of the call-site API.
2026-09-18 22:00:40 +02:00
Bitsy 7c7bb63cae refactor(input): fold LegacyPointerEvent into AppEvent.Pointer
LegacyPointerEvent was a hand-written 4-field carrier (x, y, type, button) with
longhand equals/hashCode/toString - a data class written out by hand. Its whole
life was three lines in SDL3EventMapper that wrapped it and four in
WindowInstance that immediately took it apart again, so the indirection bought
nothing while putting the word "Legacy" on the live input path.

AppEvent.Pointer now carries the four fields directly, which also makes it
consistent with its MouseWheel sibling (already flat). LegacyPointerEvent.kt is
deleted, and com.compose.sdl.input went empty with it.

API: this removes a published declaration, so it is binary-breaking for anyone
who reached into the raw SDL event pipeline. Nothing in-tree did - demo and
apidemo never reference AppEvent or com.compose.sdl.input at all - and this
layer is internal plumbing rather than documented consumer surface. Net effect
on the klib dump is 26 declarations out, 16 in.

Verified pointer dispatch still works rather than assuming: --searchesctest
CLICKS the SearchBar to expand it, and its before/after screenshots differ by
84.87% of pixels, so the click landed and the overlay opened. --nav3test,
--backtest and --multiwintest also pass.
2026-09-18 21:42:00 +02:00
Bitsy 02fb2ddf4e fix(renderer): bind each window's GL context per frame
`demo --multiwintest` exited 139 (SIGSEGV) with no output, so the multi-window
regression probe had silently been asserting nothing.

An OpenGL context is per-WINDOW, but "current" is per-THREAD state, and
SDL_GL_MakeCurrent ran exactly once per window at creation (SDL3Backend.init)
with nothing re-binding it per frame. So with two windows open, whichever
initialised last owned the thread: window A issued its draw and swap against
window B's context. That is silent corruption while both windows live, and a
hard fault in present() the moment B is destroyed and the current context is
left dangling - which is what the probe hit.

SkiaGLBridge now binds its own context at every entry point that touches GL:
init, ensureSize (before the unchanged-size early return, so it is the
per-frame bind covering the draw phase that follows), present, and destroy (a
window frees its GPU objects against its own context, not whichever happens to
be current). Purely additive.

Verified both paths, because the single-window path is the one with the most to
lose:
  single window  Buttons screenshot mean RGB [46.6 44.4 51.5], 167 distinct
                 colours - IDENTICAL to before the change, as expected since
                 SDL short-circuits a redundant bind
  multi window   --multiwintest PASS (was exit 139)
  --nav3test, --backtest, --winattrtest all still pass

Worth recording: multi-window was never correct here, not merely broken at
teardown - two live windows were sharing one context the whole time.
2026-09-18 21:35:25 +02:00
Bitsy 126e83c056 refactor(window): split the window module; add the Compose Desktop Window attributes + a raw SDL escape hatch
ComposeWindow.kt had grown to 985 lines holding four unrelated concerns. Split
by concern, no behaviour change:

  ComposeWindow.kt    337  public surface (ApplicationScope, Window(),
                           nativeComposeApp, nativeComposeWindow) + main loop
  WindowInstance.kt   540  one SDL window: backend, render backend, root host,
                           recomposer/composition, events, frame pump, teardown
  AppRuntime.kt        82  live-window registry + create/destroy lifecycle
  FrameTiming.kt       83  virtual/deterministic frame clocks + probe quiescence
  WindowAttributes.kt  30  the new attribute bundle

Also pruned 25 imports that no longer applied to what stayed behind.

WINDOW ATTRIBUTES. Window() and nativeComposeWindow() now take the same
parameters as Compose Desktop's Window(): undecorated, transparent, resizable,
enabled, focusable, alwaysOnTop, onPreviewKeyEvent and onKeyEvent. The mutable
ones re-apply whenever they change (DisposableEffect keyed on the bundle);
transparent is creation-only because SDL needs SDL_WINDOW_TRANSPARENT at
SDL_CreateWindow and offers no setter, which WindowAttributes documents.

`enabled = false` drops pointer / key / wheel / text / drop events while the
window keeps rendering, matching Compose Desktop. Key dispatch follows
upstream's order: window preview -> focused content -> window onKeyEvent -> app
shortcut handler -> back navigation.

RAW SDL ACCESS. ComposeNativeWindow.rawSdlHandles() returns a RawSdlHandles data
class of the four opaque pointers (window / renderer / glContext / metalView) so
advanced users can call the sdl3.* cinterop directly. Named for what it is
rather than getRawSdlAccessor(): it hands back handles, not an accessor object,
and it is a snapshot - the KDoc spells out that the pointers die with the window
and which ones are non-null per resolved GpuMode.

Verified by a new `demo --winattrtest` probe, which asserts all four behaviours
rather than eyeballing them:
  ATTR      undecorated=true resizable=false alwaysOnTop=true focusable=true
  RAW       gpu=Skia.OpenGL window=true glContext=true renderer=false metalView=false
  KEYCHAIN  preview=2 onKey=2   (both window handlers saw the down/up pair)
  DISABLED  preview=2 onKey=2   (unchanged after injecting with enabled=false)

Existing probes still pass (--nav3test, --backtest, --searchesctest).

Records PLAN.md 7c: `demo --multiwintest` segfaults on mingwX64. Confirmed
PRE-EXISTING by stashing this work and reproducing on the previous commit, so
it is not fallout from the split - the probe is just silent on crash, so it had
gone unnoticed.
2026-09-18 20:54:25 +02:00
Bitsy 0c09109e6e fix(bridge): make the app-icon object a real input of the mingw link
Editing an app-icon PNG rebuilt the .ico and the windres .o and then left the
link UP-TO-DATE, so the .exe silently kept the OLD embedded icon. Reproduced on
:apidemo - generateComposeNativeIco and compileComposeNativeIconResource both
re-ran, linkDebugExecutableMingwX64 reported UP-TO-DATE, and the executable's
md5 did not move.

The link was wired with a bare `dependsOn(windresTask)`, which only ORDERS the
two. The object's path reaches the linker as a `linkerOpts` STRING, so the path
is an input but its CONTENT never is - nothing about a changed .o could
invalidate the link.

Replaced with `inputs.files(windresTask)`, which carries the task dependency AND
puts the object's content into the up-to-date check. Verified: the link now
re-runs and the .exe changes when an icon PNG is edited, in both directions.

The .rgba window-icon blobs were never affected - they go into the data.kres Zip
via `from(dir)`, and a Copy spec is already a declared input.

Also annotates the three icon task types with @DisableCachingByDefault. They
had no cacheability declaration, which failed `validatePlugins` and with it
:compose-desktop-native-bridge:build; that failure predates this change.
2026-09-12 18:44:46 +02:00
Bitsy 5804128e1d fix(bridge): aggregate dependency modules' composeResources into data.kres
In a multi-module project, resources declared in a LIBRARY module never reached
the final data.kres - only the app module's own composeResources were packaged,
so every Res.* accessor pointing at a submodule resource failed at runtime.

The Zip sourced the app's own `generated/compose/resourceGenerator/
preparedResources/<sourceSet>/composeResources`, which is per-module by
construction. Kotlin already solves this: `<target>AggregateResources` merges
this project's own composeResources with those of every dependency module into
`build/kotlin-multiplatform-resources/aggregated-resources/<target>/`, each
under its OWN `composeResources/<package>/` prefix. The plugin now reads that.

Two bugs fixed at once. The old path also forced a SINGLE resolved package onto
everything it copied, which is wrong the moment more than one module
contributes - the aggregate keeps each module's real package instead.

The aggregate directory is already rooted at `composeResources/`, so it is
copied with no `into()` remapping. Kept the old per-module wiring as a fallback
for Kotlin versions that do not register the aggregation tasks.

Verified with a throwaway library module carrying a composeResources file:
  before  data.kres had only the app's resources
  after   composeResources/<app>.generated.resources/files/app_marker.txt
          composeResources/<lib>.generated.resources/files/submodule_marker.txt
Existing single-module apps are unaffected - :demo's resources still land under
`demo.generated.resources` (it sets packageOfResClass explicitly, which the old
code also read), and its Images screen still loads PNG/JPG/SVG/XML at runtime.
2026-09-12 13:10:30 +02:00
Bitsy 04d1df58de docs(plan): record the icon-font centring fix 2026-09-12 12:32:39 +02:00
Bitsy a87ff2d7f6 fix(icons): centre the icon-font glyph so Material Symbols stop rendering low
Material Symbols icons sat 2-4px lower on native than on the JVM reference,
the offset scaling with icon size (~13% of it): eye 11px -> +2, gear/lock 16px
-> +2, download 28px -> +4. Equal top and bottom deltas with identical ink
heights, so it was a pure downward translation, not a sizing problem.

The two stacks positioned the glyph differently. The JVM actual centres it
explicitly (MaterialSymbolsIcon.jvm.kt: y = height/2 - (ascent+descent)/2),
while IconFontIcon just placed IconText in a Box with the DEFAULT TopStart
alignment. The glyph's natural text box is taller than the nominal icon size -
its height is the font's ascent+descent and Material Symbols' em box overshoots
the icon grid - so top-aligning it pushed the glyph down.

Centering the text box is equivalent to the JVM's baseline math, because
IconText sets no lineHeight and its box height therefore IS ascent+descent.

wrapContentSize(unbounded = true) is load-bearing and was the whole reason a
first attempt at this changed nothing: Modifier.size() hands the child FIXED
constraints, so the text measured at exactly `size`, nothing overflowed, and
contentAlignment had nothing to align.

Measured on apidemo, native vs jvm ink boxes:
  gear      +2/+1 -> 0/0
  download  +4/+4 -> 0/0
  lock      +2/+1 -> 0/0
  eye       +2/+2 -> +1/+1   (half-pixel rounding: an odd-height glyph centred
                              in an even box; the JVM draws at a float y where
                              native placement is integer)
Whole-window drift >32/255: 0.29% -> 0.17%.

Found by measuring rather than by eye, off the apidemo --screenshot harness.
2026-09-12 12:32:30 +02:00
69 changed files with 2908 additions and 2400 deletions
+30 -6
View File
@@ -566,8 +566,18 @@ drift / vendor-clean guardrails are in
## Key files by area - start here when you need to find something
### Renderer + main loop
- `compose/desktop/native/desktop-native-window/src/nativeMain/…/ComposeWindow.kt` - main loop,
recomposer lifecycle, SDL event dispatch, composition-local seeding.
- `compose/desktop/native/desktop-native-window/src/nativeMain/…/` - the window
module, one concern per file:
- `ComposeWindow.kt` - the PUBLIC surface (`ApplicationScope`, `Window()`,
`nativeComposeApp`, `nativeComposeWindow`) + the main loop.
- `WindowInstance.kt` - one SDL window: backend + render backend + root host +
its own recomposer/composition, event handling, frame pump, teardown. This is
where composition-local seeding lives.
- `AppRuntime.kt` - the live-window registry + create/destroy lifecycle.
- `WindowAttributes.kt` - the Compose Desktop `Window()` attribute bundle.
- `FrameTiming.kt` - deterministic/virtual frame clocks and the
probe-quiescence hooks (`disableInfiniteAnimations`, `useVirtualFrameTime`,
`windowHasInvalidations`).
- `compose/ui/ui/src/nativeMain/…/RenderBackend.kt` - the interface.
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` - sealed driver picker
(`Auto` / `Software` / `Skia.OpenGL` / `Skia.Metal`).
@@ -632,9 +642,23 @@ Open renderer / fidelity / release work is tracked in [PLAN.md](PLAN.md).
## Conventions
Kotlin standard style - plain `camelCase` for parameters, local variables,
and fields. **No `f`/`in`/`v` prefixes**, no SPIRTECH scheme (see the global
CLAUDE.md - that scheme is C-only).
Kotlin standard style for formatting (braces, wrapping, indentation).
Naming, as the code actually is - this paragraph used to claim "no prefixes",
which had not been true for a long time:
- **`m` for private/protected members** - `mWidth`, `mSurface`, `mCache`. NOT
`f`: the `f` prefix was retired across the project, so do not reintroduce it.
- **`v` for local variables** - `vWindow`, `vResult`.
- **`in` for function parameters** - `inTitle`, `inWidth`. Note Compose
`@Composable` parameters are the exception: they stay plain, because the name
is part of the call-site API (`title = `, `onCloseRequest = `).
- **`k` for private file-level constants** - `kMaxFrames`, `kIsWindows`.
- Public API surface is plain `camelCase` - prefixes are for implementation
detail only, and nothing prefixed should ever reach the klib dump.
VENDORED code under `src/vendor/` follows UPSTREAM's naming, untouched - it is
copied byte-for-byte, so never re-prefix anything in it.
Section headers inside a file, when useful:
@@ -752,7 +776,7 @@ Caveats that make these work here (all already wired - listed so nobody
- The google `LocalViewModelStoreOwner` / `LocalSavedStateRegistryOwner` /
`LocalLifecycleOwner` are PLAIN composition locals - the JB HostDefault
mechanism (`compositionLocalWithHostDefaultOf`) does not exist in google
artifacts. `WindowArchitectureOwner` (ComposeWindow.kt) provides all three
artifacts. `WindowArchitectureOwner` (WindowInstance.kt) provides all three
per window, mirrors upstream desktop's DefaultArchitectureComponentsOwner,
calls `enableSavedStateHandles()` at construction, and follows SDL focus /
visibility (focused → RESUMED, unfocused → STARTED, minimised → CREATED).
+36
View File
@@ -727,6 +727,30 @@ A read-only feasibility map (no code changed) found two layers:
proves infeasible, fall back to keeping the seam but at least converting the hand files to tracked
Rule-3 vendors (like `FontRasterizationSettings.native.kt` now has a `VENDOR-BASE` line).
### 7c. `demo --multiwintest` segfault on mingwX64 - ✅ FIXED
The multi-window probe exited 139 with no output, so it had silently been
asserting nothing. Root cause: an OpenGL context is per-WINDOW but "current" is
per-THREAD, and `SDL_GL_MakeCurrent` ran only once at creation
(`SDL3Backend.init`) with nothing re-binding it per frame. With two windows open,
whichever initialised last owned the thread - so window A issued its draw and
swap against window B's context (silent corruption while both lived), and
destroying B left the current context dangling, faulting in `present()`.
Fixed in `SkiaGLBridge` by binding the window's own context at every entry point
that touches GL: `init`, `ensureSize` (before its unchanged-size early return -
this is the per-frame bind that covers the draw phase), `present`, and `destroy`
(so a window frees its GPU objects against its own context). Purely additive, 29
lines.
Single-window rendering is bit-identical before and after (same mean RGB
[46.6 44.4 51.5] and 167 distinct colours on the Buttons screenshot), which is
expected: SDL short-circuits a redundant bind. `--multiwintest` now PASSes.
NOTE the multi-window path was never correct, not just at teardown - two live
windows were sharing one context. Anything that looked like flicker or
cross-window corruption before this is explained by it.
## Accepted 1.0.0 gaps (documented, not fixed)
Drag-OUT of window (SDL platform limit; drop-IN works), full accessibility pipeline (out of
@@ -813,6 +837,18 @@ Two residuals, neither a text-metrics bug:
(0.29% at +0px vs 2.13% at +1px), where before +2px halved the error. The residual 0.29%
is rasterizer AA.
- **Icon-font glyphs rendered LOW - ✅ FIXED.** Material Symbols sat 2-4px lower on native
than on jvm, the offset scaling with icon size (~13% of it). Cause: the JVM actual centres
the glyph explicitly (`y = height/2 - (ascent+descent)/2`), while `IconFontIcon` placed
`IconText` in a Box at the DEFAULT TopStart alignment - and the glyph's natural text box is
taller than the nominal icon size. Fixed by centring, with
`wrapContentSize(unbounded = true)` so the text measures at its natural size first:
`Modifier.size()` imposes FIXED constraints, so without it nothing overflows and
contentAlignment is a no-op (a first attempt without it changed literally nothing).
gear/download/lock are now pixel-exact; the eye is 1px off from half-pixel rounding (odd
glyph height centred in an even box; jvm draws at a float y, native placement is integer).
Whole-window >32/255 drift 0.29% -> 0.17%.
- **Missing glyph in the session header.** The leading icon next to "Untitled session"
renders as a `.notdef` TOFU on jvm and as NOTHING on native - so the glyph is absent from
the subsetted Material Symbols font on both, and the two stacks just disagree on how to
+17
View File
@@ -71,6 +71,23 @@ Each window gets its own `Lifecycle`, `ViewModelStore`, and `SavedStateRegistry`
owners, driven by real SDL focus and visibility events, so ViewModels and
saved state behave as they do on Android.
`Window()` takes the same attributes as Compose Desktop's - `undecorated`,
`transparent`, `resizable`, `enabled`, `focusable`, `alwaysOnTop`,
`onPreviewKeyEvent` and `onKeyEvent` - and re-applies them when they change.
(`transparent` is the one exception: SDL needs it at window-creation time, so it
is fixed for the window's life.)
When the Compose-level API doesn't reach far enough, drop to SDL directly:
```kotlin
val handles = window.rawSdlHandles() // SDL_Window* / SDL_Renderer* / GL / Metal
handles.window?.let { sdl3.SDL_FlashWindow(it.reinterpret(), SDL_FLASH_BRIEFLY) }
```
The pointers are a snapshot valid only while the window lives, and which ones
are non-null depends on the resolved renderer (`glContext` for Skia OpenGL,
`metalView` for Metal, `renderer` for CPU raster).
### Building in this repo
```kotlin
+2 -2
View File
@@ -56,11 +56,11 @@ internal class PackState(
// A linked copy mirrors its source's requests read-only; non-linked packs own
// their requests. `requests` resolves to whichever applies.
var linkedSource by mutableStateOf<PackState?>(null)
private val fOwnRequests = mutableStateListOf<ReqState>().apply {
private val mOwnRequests = mutableStateListOf<ReqState>().apply {
inPack.requests.forEach { add(ReqState(it)) } // packs (and the loose root) may be empty
}
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
get() = linkedSource?.requests ?: fOwnRequests
get() = linkedSource?.requests ?: mOwnRequests
val isLinked: Boolean get() = linkedSource != null
var parent: PackState? =
null // enclosing pack (null = top-level / root)
+2 -2
View File
@@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonElement
// respects the editor's "tab size" (TextLayoutConfig.tabWidth), exactly like
// typed tabs. Lenient so slightly-off JSON (single quotes, trailing commas)
// still reformats instead of failing.
private val fPrettyJson = Json {
private val mPrettyJson = Json {
prettyPrint = true
prettyPrintIndent = "\t"
isLenient = true
@@ -23,7 +23,7 @@ private val fPrettyJson = Json {
fun formatBody(inText: String, inFormat: BodyFormat): String =
when (inFormat) {
BodyFormat.JSON -> runCatching {
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
mPrettyJson.encodeToString(JsonElement.serializer(), mPrettyJson.parseToJsonElement(inText))
}.getOrDefault(inText)
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
+3 -3
View File
@@ -22,7 +22,7 @@ macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
run() is a suspend fun - call it off the UI dispatcher. */
class HttpRunner {
private val fClient = createApiHttpClient()
private val mClient = createApiHttpClient()
init {
// Clear any temporary client certs a prior crash left in the Windows
@@ -36,7 +36,7 @@ class HttpRunner {
if (inReq.hasClientCert) return curlSendWithClientCert(inReq)
val vMark = TimeSource.Monotonic.markNow()
return try {
val vResp: HttpResponse = fClient.request(inReq.url.trim()) {
val vResp: HttpResponse = mClient.request(inReq.url.trim()) {
method = HttpMethod.parse(inReq.method.name)
inReq.params
.filter { it.enabled && it.key.isNotBlank() }
@@ -113,7 +113,7 @@ class HttpRunner {
}
}
fun close() = fClient.close()
fun close() = mClient.close()
}
/** Heuristic for a non-text body: textual content types (text, json, xml, html,
+6 -6
View File
@@ -9,7 +9,7 @@ import okio.Path.Companion.toPath
// MARK: Pack persistence (okio + kotlinx.serialization)
// ==================
private val fJson = Json {
private val mJson = Json {
prettyPrint = true
prettyPrintIndent = " "
ignoreUnknownKeys = true
@@ -20,7 +20,7 @@ private val fJson = Json {
error message. */
fun exportPack(inPack: Pack, inPath: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inPack))
writeUtf8(mJson.encodeToString(inPack))
}
null
} catch (e: Throwable) {
@@ -30,7 +30,7 @@ fun exportPack(inPack: Pack, inPath: String): String? = try {
/** Read a pack back from inPath. */
fun importPack(inPath: String): Result<Pack> = try {
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
Result.success(fJson.decodeFromString<Pack>(vText))
Result.success(mJson.decodeFromString<Pack>(vText))
} catch (e: Throwable) {
Result.failure(e)
}
@@ -38,7 +38,7 @@ fun importPack(inPath: String): Result<Pack> = try {
/** Write a whole self-contained session to inPath. Returns null on success. */
fun exportSession(inSession: Session, inPath: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inSession))
writeUtf8(mJson.encodeToString(inSession))
}
null
} catch (e: Throwable) {
@@ -48,7 +48,7 @@ fun exportSession(inSession: Session, inPath: String): String? = try {
/** Read a session back from inPath. */
fun importSession(inPath: String): Result<Session> = try {
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
Result.success(fJson.decodeFromString<Session>(vText))
Result.success(mJson.decodeFromString<Session>(vText))
} catch (e: Throwable) {
Result.failure(e)
}
@@ -85,7 +85,7 @@ fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
/** Re-indent a JSON response for display; returns the input unchanged if it
isn't valid JSON (so plain-text / HTML responses still show). */
fun prettyJsonOrRaw(inText: String): String = try {
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
mJson.encodeToString(JsonElement.serializer(), mJson.parseToJsonElement(inText))
} catch (_: Throwable) {
inText
}
+3 -3
View File
@@ -64,7 +64,7 @@ data class Session(
// MARK: Load / save (app-data dir via okio)
// ==================
private val fStateJson = Json {
private val mStateJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
prettyPrint = true
@@ -88,7 +88,7 @@ fun loadAppState(): AppState {
val vPath = stateFilePath() ?: return AppState()
return try {
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
fStateJson.decodeFromString<AppState>(vText)
mStateJson.decodeFromString<AppState>(vText)
} catch (_: Throwable) {
AppState()
}
@@ -99,7 +99,7 @@ the session, not the user's exported .json packs). */
fun saveAppState(inState: AppState) {
val vPath = stateFilePath() ?: return
try {
systemFileSystem.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
systemFileSystem.write(vPath.toPath()) { writeUtf8(mStateJson.encodeToString(inState)) }
} catch (_: Throwable) {
// ignore - nothing the user can act on, and packs can still be exported
}
@@ -157,9 +157,9 @@ actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inF
/** The jvm parity app only stores the preference - upstream's text pipeline has
no global tab-width knob. Backed by state so menu checkmarks recompose. */
private var fTabWidth by mutableStateOf(4)
private var mTabWidth by mutableStateOf(4)
actual var editorTabWidth: Int
get() = fTabWidth
get() = mTabWidth
set(value) {
fTabWidth = value
mTabWidth = value
}
@@ -51,20 +51,20 @@ private class SvgPainter(private val bytes: ByteArray) : Painter() {
override val intrinsicSize: Size = svgIntrinsicSize(bytes) ?: Size.Unspecified
private var fAlpha: Float = 1f
private var fColorFilter: ColorFilter? = null
private var fCacheKey: Long = -1L
private var fCacheBitmap: ImageBitmap? = null
private var mAlpha: Float = 1f
private var mColorFilter: ColorFilter? = null
private var mCacheKey: Long = -1L
private var mCacheBitmap: ImageBitmap? = null
override fun applyAlpha(alpha: Float): Boolean { fAlpha = alpha; return true }
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { fColorFilter = colorFilter; return true }
override fun applyAlpha(alpha: Float): Boolean { mAlpha = alpha; return true }
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { mColorFilter = colorFilter; return true }
private fun rasterFor(inWidthPx: Int, inHeightPx: Int): ImageBitmap? {
val vKey = (inWidthPx.toLong() shl 32) or inHeightPx.toLong()
if (vKey == fCacheKey && fCacheBitmap != null) return fCacheBitmap
if (vKey == mCacheKey && mCacheBitmap != null) return mCacheBitmap
val vBitmap = decodeSvgAt(bytes, inWidthPx, inHeightPx) ?: return null
fCacheKey = vKey
fCacheBitmap = vBitmap
mCacheKey = vKey
mCacheBitmap = vBitmap
return vBitmap
}
@@ -79,8 +79,8 @@ private class SvgPainter(private val bytes: ByteArray) : Painter() {
srcSize = IntSize(vBitmap.width, vBitmap.height),
dstOffset = IntOffset.Zero,
dstSize = IntSize(vWpx, vHpx),
alpha = fAlpha,
colorFilter = fColorFilter,
alpha = mAlpha,
colorFilter = mColorFilter,
)
}
}
@@ -32,13 +32,13 @@ private class Attr(val prefix: String, val localName: String, val nsUri: String,
val qualifiedName: String get() = if (prefix.isEmpty()) localName else "$prefix:$localName"
}
private class NodeListImpl(private val fNodes: List<Node>) : NodeList {
override fun item(i: Int): Node = fNodes[i]
override val length: Int get() = fNodes.size
private class NodeListImpl(private val mNodes: List<Node>) : NodeList {
override fun item(i: Int): Node = mNodes[i]
override val length: Int get() = mNodes.size
}
private class TextNodeImpl(private val fText: String) : Node {
override val textContent: String? get() = fText
private class TextNodeImpl(private val mText: String) : Node {
override val textContent: String? get() = mText
override val nodeName: String get() = "#text"
override val localName: String get() = ""
override val childNodes: NodeList = NodeListImpl(emptyList())
@@ -47,16 +47,16 @@ private class TextNodeImpl(private val fText: String) : Node {
}
private class ElementImpl(
private val fPrefix: String,
private val mPrefix: String,
override val localName: String,
override val namespaceURI: String,
private val fAttrs: List<Attr>,
private val mAttrs: List<Attr>,
// Innermost-first chain of xmlns scopes: each is prefix -> uri ("" = default ns).
private val fNsScope: List<Map<String, String>>,
private val mNsScope: List<Map<String, String>>,
) : Element {
var children: List<Node> = emptyList()
override val nodeName: String get() = if (fPrefix.isEmpty()) localName else "$fPrefix:$localName"
override val nodeName: String get() = if (mPrefix.isEmpty()) localName else "$mPrefix:$localName"
override val childNodes: NodeList get() = NodeListImpl(children)
// Concatenated descendant text - what DOM's textContent does.
@@ -72,7 +72,7 @@ private class ElementImpl(
override fun lookupPrefix(namespaceURI: String): String {
if (namespaceURI.isEmpty()) return ""
for (vScope in fNsScope) {
for (vScope in mNsScope) {
for ((vPrefix, vUri) in vScope) {
if (vUri == namespaceURI && vPrefix.isNotEmpty()) return vPrefix
}
@@ -81,22 +81,22 @@ private class ElementImpl(
}
override fun getAttributeNS(nameSpaceURI: String, localName: String): String =
fAttrs.firstOrNull { it.nsUri == nameSpaceURI && it.localName == localName }?.value ?: ""
mAttrs.firstOrNull { it.nsUri == nameSpaceURI && it.localName == localName }?.value ?: ""
override fun getAttribute(name: String): String =
fAttrs.firstOrNull { it.qualifiedName == name }?.value ?: ""
mAttrs.firstOrNull { it.qualifiedName == name }?.value ?: ""
}
// ============
// The parser - one pass over the string with a cursor.
private class XmlDomParser(private val fXml: String) {
private var fPos = 0
private class XmlDomParser(private val mXml: String) {
private var mPos = 0
fun skipProlog() {
while (true) {
skipWhitespace()
when {
lookingAt("") -> fPos++
lookingAt("") -> mPos++
lookingAt("<?") -> skipUntil("?>")
lookingAt("<!--") -> skipUntil("-->")
lookingAt("<!DOCTYPE") -> skipDoctype()
@@ -112,7 +112,7 @@ private class XmlDomParser(private val fXml: String) {
if (!lookingAt("<") || lookingAt("</")) return null
expect("<")
val vQName = readName()
if (vQName.isEmpty()) throw MalformedXMLException("empty element name at $fPos")
if (vQName.isEmpty()) throw MalformedXMLException("empty element name at $mPos")
// Attributes (collect raw first - xmlns declarations shape the scope
// that THIS element's own prefix resolves against).
@@ -121,7 +121,7 @@ private class XmlDomParser(private val fXml: String) {
skipWhitespace()
if (lookingAt("/>") || lookingAt(">")) break
val vAttrName = readName()
if (vAttrName.isEmpty()) throw MalformedXMLException("bad attribute at $fPos")
if (vAttrName.isEmpty()) throw MalformedXMLException("bad attribute at $mPos")
skipWhitespace(); expect("="); skipWhitespace()
vRawAttrs.add(vAttrName to readQuotedValue())
}
@@ -160,7 +160,7 @@ private class XmlDomParser(private val fXml: String) {
val vElement = ElementImpl(vPrefix, vLocal, resolveNs(vPrefix), vAttrs, vScope)
if (lookingAt("/>")) {
fPos += 2
mPos += 2
return vElement
}
expect(">")
@@ -169,9 +169,9 @@ private class XmlDomParser(private val fXml: String) {
val vChildren = ArrayList<Node>()
while (true) {
when {
fPos >= fXml.length -> throw MalformedXMLException("unclosed <$vQName>")
mPos >= mXml.length -> throw MalformedXMLException("unclosed <$vQName>")
lookingAt("</") -> {
fPos += 2
mPos += 2
val vClose = readName()
skipWhitespace(); expect(">")
if (vClose != vQName) throw MalformedXMLException("mismatched </$vClose> for <$vQName>")
@@ -180,18 +180,18 @@ private class XmlDomParser(private val fXml: String) {
}
lookingAt("<!--") -> skipUntil("-->")
lookingAt("<![CDATA[") -> {
val vEnd = fXml.indexOf("]]>", fPos + 9)
val vEnd = mXml.indexOf("]]>", mPos + 9)
if (vEnd < 0) throw MalformedXMLException("unclosed CDATA")
vChildren.add(TextNodeImpl(fXml.substring(fPos + 9, vEnd)))
fPos = vEnd + 3
vChildren.add(TextNodeImpl(mXml.substring(mPos + 9, vEnd)))
mPos = vEnd + 3
}
lookingAt("<?") -> skipUntil("?>")
lookingAt("<") -> parseElement(vScope)?.let { vChildren.add(it) }
else -> {
val vNext = fXml.indexOf('<', fPos).let { if (it < 0) fXml.length else it }
val vText = decodeEntities(fXml.substring(fPos, vNext))
val vNext = mXml.indexOf('<', mPos).let { if (it < 0) mXml.length else it }
val vText = decodeEntities(mXml.substring(mPos, vNext))
if (vText.isNotBlank()) vChildren.add(TextNodeImpl(vText))
fPos = vNext
mPos = vNext
}
}
}
@@ -200,28 +200,28 @@ private class XmlDomParser(private val fXml: String) {
// ============
// Lexing helpers
private fun lookingAt(inToken: String): Boolean = fXml.startsWith(inToken, fPos)
private fun lookingAt(inToken: String): Boolean = mXml.startsWith(inToken, mPos)
private fun expect(inToken: String) {
if (!lookingAt(inToken)) throw MalformedXMLException("expected '$inToken' at $fPos")
fPos += inToken.length
if (!lookingAt(inToken)) throw MalformedXMLException("expected '$inToken' at $mPos")
mPos += inToken.length
}
private fun skipWhitespace() {
while (fPos < fXml.length && fXml[fPos].isWhitespace()) fPos++
while (mPos < mXml.length && mXml[mPos].isWhitespace()) mPos++
}
private fun skipUntil(inToken: String) {
val vEnd = fXml.indexOf(inToken, fPos)
val vEnd = mXml.indexOf(inToken, mPos)
if (vEnd < 0) throw MalformedXMLException("unterminated '$inToken' section")
fPos = vEnd + inToken.length
mPos = vEnd + inToken.length
}
/* DOCTYPE may nest an internal subset in [ ] - skip to the matching '>'. */
private fun skipDoctype() {
var vDepth = 0
while (fPos < fXml.length) {
val c = fXml[fPos++]
while (mPos < mXml.length) {
val c = mXml[mPos++]
if (c == '[') vDepth++
else if (c == ']') vDepth--
else if (c == '>' && vDepth <= 0) return
@@ -230,24 +230,24 @@ private class XmlDomParser(private val fXml: String) {
}
private fun readName(): String {
val vStart = fPos
while (fPos < fXml.length) {
val c = fXml[fPos]
val vStart = mPos
while (mPos < mXml.length) {
val c = mXml[mPos]
if (c.isWhitespace() || c == '=' || c == '>' || c == '/' || c == '<') break
fPos++
mPos++
}
return fXml.substring(vStart, fPos)
return mXml.substring(vStart, mPos)
}
private fun readQuotedValue(): String {
if (fPos >= fXml.length) throw MalformedXMLException("unterminated attribute value")
val vQuote = fXml[fPos]
if (vQuote != '"' && vQuote != '\'') throw MalformedXMLException("attribute value must be quoted at $fPos")
fPos++
val vEnd = fXml.indexOf(vQuote, fPos)
if (mPos >= mXml.length) throw MalformedXMLException("unterminated attribute value")
val vQuote = mXml[mPos]
if (vQuote != '"' && vQuote != '\'') throw MalformedXMLException("attribute value must be quoted at $mPos")
mPos++
val vEnd = mXml.indexOf(vQuote, mPos)
if (vEnd < 0) throw MalformedXMLException("unterminated attribute value")
val vRaw = fXml.substring(fPos, vEnd)
fPos = vEnd + 1
val vRaw = mXml.substring(mPos, vEnd)
mPos = vEnd + 1
return decodeEntities(vRaw)
}
@@ -28,8 +28,8 @@ final var com.compose.sdl/useVirtualFrameTime // com.compose.sdl/useVirtualFrame
final fun <get-useVirtualFrameTime>(): kotlin/Boolean // com.compose.sdl/useVirtualFrameTime.<get-useVirtualFrameTime>|<get-useVirtualFrameTime>(){}[0]
final fun <set-useVirtualFrameTime>(kotlin/Boolean) // com.compose.sdl/useVirtualFrameTime.<set-useVirtualFrameTime>|<set-useVirtualFrameTime>(kotlin.Boolean){}[0]
final fun (com.compose.sdl/ApplicationScope).com.compose.sdl/Window(kotlin/Function0<kotlin/Unit>, kotlin/String?, kotlin/Int, kotlin/Int, com.compose.sdl/GpuMode?, com.compose.sdl/AppWindowIcon?, kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>?, kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl/Window|Window@com.compose.sdl.ApplicationScope(kotlin.Function0<kotlin.Unit>;kotlin.String?;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode?;com.compose.sdl.AppWindowIcon?;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun (com.compose.sdl/ApplicationScope).com.compose.sdl/Window(kotlin/Function0<kotlin/Unit>, kotlin/String?, kotlin/Int, kotlin/Int, com.compose.sdl/GpuMode?, com.compose.sdl/AppWindowIcon?, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean>?, kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean>?, kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>?, kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // com.compose.sdl/Window|Window@com.compose.sdl.ApplicationScope(kotlin.Function0<kotlin.Unit>;kotlin.String?;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode?;com.compose.sdl.AppWindowIcon?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>?;kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>?;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun com.compose.sdl/com_compose_sdl_AppWindowIcon$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_AppWindowIcon$stableprop_getter|com_compose_sdl_AppWindowIcon$stableprop_getter(){}[0]
final fun com.compose.sdl/nativeComposeApp(kotlin/Function3<com.compose.sdl/ApplicationScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.sdl/nativeComposeApp|nativeComposeApp(kotlin.Function3<com.compose.sdl.ApplicationScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
final fun com.compose.sdl/nativeComposeWindow(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., com.compose.sdl/AppWindowIcon? = ..., kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>? = ..., kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.sdl/nativeComposeWindow|nativeComposeWindow(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;com.compose.sdl.AppWindowIcon?;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
final fun com.compose.sdl/nativeComposeWindow(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., com.compose.sdl/AppWindowIcon? = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean> = ..., kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean> = ..., kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>? = ..., kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.sdl/nativeComposeWindow|nativeComposeWindow(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;com.compose.sdl.AppWindowIcon?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>;kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
final fun com.compose.sdl/windowHasInvalidations(): kotlin/Boolean // com.compose.sdl/windowHasInvalidations|windowHasInvalidations(){}[0]
@@ -0,0 +1,88 @@
@file:OptIn(
androidx.compose.ui.InternalComposeUiApi::class,
androidx.compose.runtime.InternalComposeApi::class,
)
package com.compose.sdl
import androidx.compose.runtime.AbstractApplier
import androidx.compose.runtime.Composable
import kotlinx.coroutines.CoroutineScope
// ==================
// MARK: App runtime internals
// ==================
internal class ApplicationScopeImpl(val runtime: AppRuntime) : ApplicationScope {
override fun exitApplication() { runtime.exitRequested = true }
}
/** Registry + lifecycle for the live windows. Windows are created by the app
composition (Window()'s remember) and destroyed by the LOOP - DisposableEffect
onDispose only schedules, because teardown disposes a composition and that
must not run re-entrantly inside another composition's apply pass. */
internal class AppRuntime {
lateinit var scope: CoroutineScope
val windows = mutableListOf<WindowInstance>()
private val destroyQueue = mutableListOf<WindowInstance>()
var exitRequested = false
var hadWindow = false
fun windowFor(inId: UInt): WindowInstance? =
if (inId == 0u) windows.firstOrNull()
else windows.firstOrNull { it.backend.windowId == inId } ?: windows.firstOrNull()
fun markAllNeedFrame() {
for (vW in windows) vW.needsFrame = true
}
fun createWindow(
inTitle: String,
inWidth: Int,
inHeight: Int,
inGpu: GpuMode,
inIcon: AppWindowIcon?,
inAttrs: WindowAttributes,
inOnFrame: ((RenderBackend, Int) -> Boolean)?,
inPreviewKeyHandler: () -> ((androidx.compose.ui.input.key.KeyEvent) -> Boolean),
inKeyHandler: () -> ((androidx.compose.ui.input.key.KeyEvent) -> Boolean),
inContent: () -> (@Composable ComposeWindowScope.() -> Unit),
): WindowInstance {
val vWindow = WindowInstance(
inTitle, inWidth, inHeight, inGpu, inIcon, inAttrs, inOnFrame,
inPreviewKeyHandler, inKeyHandler, inContent,
)
// A Window() was declared either way - the "exit when the last window
// is gone" rule must also fire when every declared window failed to
// initialise (otherwise the loop would spin forever with none).
hadWindow = true
if (vWindow.init(scope)) {
windows.add(vWindow)
} else {
println("nativeComposeApp: window '$inTitle' failed to initialise")
}
return vWindow
}
fun scheduleDestroy(inWindow: WindowInstance) {
if (inWindow in windows && inWindow !in destroyQueue) destroyQueue.add(inWindow)
}
fun reapDestroyed() {
while (destroyQueue.isNotEmpty()) {
val vW = destroyQueue.removeLast()
windows.remove(vW)
vW.destroy()
}
}
}
// No-op applier for the app-level composition - it emits no UI nodes, only
// side effects (each Window() manages a native window).
internal class UnitApplier : AbstractApplier<Unit>(Unit) {
override fun insertTopDown(index: Int, instance: Unit) {}
override fun insertBottomUp(index: Int, instance: Unit) {}
override fun remove(index: Int, count: Int) {}
override fun move(from: Int, to: Int, count: Int) {}
override fun onClear() {}
}
@@ -5,46 +5,22 @@
package com.compose.sdl
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.AbstractApplier
import androidx.compose.runtime.Composable
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import com.compose.sdl.node.ComposeRootHost
import com.compose.sdl.res.currentImageLoader
import com.compose.sdl.window.LocalPopupHost
import com.compose.sdl.window.PopupLayer
import com.compose.sdl.window.createPopupHostState
import kotlinx.coroutines.*
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.cinterop.toKString
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.alloc
import kotlinx.cinterop.ptr
import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.pointed
import sdl3.SDL_Delay
import sdl3.SDL_GetCurrentDisplayMode
import sdl3.SDL_GetDisplayForWindow
import sdl3.SDL_GetPerformanceCounter
import sdl3.SDL_GetPerformanceFrequency
import sdl3.SDL_GetTicks
import sdl3.SDL_GetTicksNS
import sdl3.SDL_Quit
import sdl3.SDL_SetWindowTitle
import sdl3.SDL_WaitEventTimeout
// ==================
@@ -92,6 +68,14 @@ fun ApplicationScope.Window(
height: Int = 600,
gpu: GpuMode = GpuMode.Auto,
icon: AppWindowIcon? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
alwaysOnTop: Boolean = false,
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
content: @Composable ComposeWindowScope.() -> Unit,
) {
@@ -99,6 +83,16 @@ fun ApplicationScope.Window(
?: error("Window() must be called inside nativeComposeApp { ... }")
val vContent = rememberUpdatedState(content)
val vClose = rememberUpdatedState(onCloseRequest)
val vPreviewKey = rememberUpdatedState(onPreviewKeyEvent)
val vKey = rememberUpdatedState(onKeyEvent)
val vAttrs = WindowAttributes(
undecorated = undecorated,
transparent = transparent,
resizable = resizable,
enabled = enabled,
focusable = focusable,
alwaysOnTop = alwaysOnTop,
)
val vWindow = remember {
vScope.runtime.createWindow(
inTitle = title,
@@ -106,7 +100,12 @@ fun ApplicationScope.Window(
inHeight = height,
inGpu = gpu,
inIcon = icon,
inAttrs = vAttrs,
inOnFrame = onFrame,
// Read through holders so updated lambdas propagate without
// recreating the window (same reason as the content holder below).
inPreviewKeyHandler = { vPreviewKey.value },
inKeyHandler = { vKey.value },
// The window composition reads the holder each recomposition, so
// updated content lambdas propagate without recreating the window.
inContent = { vContent.value },
@@ -115,6 +114,12 @@ fun ApplicationScope.Window(
SideEffect {
if (vWindow.facade.title != title) vWindow.facade.setTitle(title)
}
// Re-apply only on a real change; `transparent` is creation-only (see
// WindowAttributes) so it is deliberately not re-applied here.
DisposableEffect(vAttrs) {
vWindow.applyAttributes(vAttrs)
onDispose { }
}
DisposableEffect(Unit) {
onDispose { vScope.runtime.scheduleDestroy(vWindow) }
}
@@ -311,74 +316,6 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
SDL_Quit()
}
// ==================
// MARK: Probe / screenshot support (render-to-quiescence)
// ==================
/** Set BEFORE nativeComposeApp/nativeComposeWindow: every window's composition then runs
under an InfiniteAnimationPolicy that CANCELS infinite animations, so
rememberInfiniteTransition & co. freeze at their initial value - the same mechanism
upstream's test rules use. Screenshot/parity runs enable it so looping screens can
reach quiescence and capture deterministically. */
var disableInfiniteAnimations: Boolean = false
/** The cancelling policy: a coroutine that ends in CancellationException counts as
cancelled (not failed), so only the animation coroutine stops - nothing propagates. */
private object CancelInfiniteAnimationsPolicy : androidx.compose.ui.platform.InfiniteAnimationPolicy {
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
throw CancellationException("infinite animations are disabled (disableInfiniteAnimations)")
}
/** Set BEFORE nativeComposeApp/nativeComposeWindow: the composition + animation frame
clocks advance a VIRTUAL 16.6ms per main-loop iteration instead of reading SDL's
real-time ticks - the native mirror of the JVM parity leg's render(nanos) stepping.
Animations then progress by exact per-frame deltas, so anything time-raced (e.g. a
bring-into-view scroll interrupted mid-flight) resolves identically on every run and
screenshots become deterministic. Input timestamps and FPS stay on real time. */
var useVirtualFrameTime: Boolean = false
private var virtualFrameNanos = 0L
private fun advanceVirtualFrame() {
if (useVirtualFrameTime) virtualFrameNanos += 16_666_667L
}
// Timestamp for the composition frame clocks (recomposer + withFrameNanos animations).
private fun frameClockNanos(): Long =
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicksNS().toLong()
// Timestamp for the owner's node-animation clock - real path keeps the pre-existing
// ms-resolution SDL_GetTicks base so non-screenshot behaviour is bit-for-bit unchanged.
private fun animationClockNanos(): Long =
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicks().toLong() * 1_000_000L
// The window currently inside renderFrame - the loop is single-threaded, so a plain
// var is enough for onFrame probes to address "the window I'm being called for".
private var renderingWindow: WindowInstance? = null
/** From inside an onFrame callback: true while the just-rendered window still has pending
work (recomposition, layout/draw invalidation, or an animation awaiting the next frame).
Screenshot probes capture once this stays false for a few consecutive frames instead of
at a fixed frame count. Outside onFrame it answers false. */
fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: false
/** 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)
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
/** Frame interval (ms) of the window's current display, for the non-vsync
fallback pacing (SDL_Delay). Falls back to 16ms (~60Hz) when the mode can't
be read, so a 144Hz panel on a non-vsync path isn't capped to 60. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
private fun displayFrameDelayMs(window: COpaquePointer?): UInt {
val vWindow = window ?: return 16u
val vDisplay = SDL_GetDisplayForWindow(vWindow.reinterpret())
val vMode = SDL_GetCurrentDisplayMode(vDisplay) ?: return 16u
val vHz = vMode.pointed.refresh_rate
return if (vHz > 0f) (1000f / vHz).toUInt().coerceAtLeast(1u) else 16u
}
/** Single-window compatibility wrapper - the pre-multi-window entry point.
Closing the window exits the app, exactly as before. */
fun nativeComposeWindow(
@@ -387,6 +324,14 @@ fun nativeComposeWindow(
height: Int = 600,
gpu: GpuMode = GpuMode.Auto,
icon: AppWindowIcon? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
alwaysOnTop: Boolean = false,
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
content: @Composable ComposeWindowScope.() -> Unit,
) {
@@ -398,592 +343,16 @@ fun nativeComposeWindow(
height = height,
gpu = gpu,
icon = icon,
undecorated = undecorated,
transparent = transparent,
resizable = resizable,
enabled = enabled,
focusable = focusable,
alwaysOnTop = alwaysOnTop,
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent,
onFrame = onFrame,
content = content,
)
}
}
// ==================
// MARK: App runtime internals
// ==================
internal class ApplicationScopeImpl(val runtime: AppRuntime) : ApplicationScope {
override fun exitApplication() { runtime.exitRequested = true }
}
/** Registry + lifecycle for the live windows. Windows are created by the app
composition (Window()'s remember) and destroyed by the LOOP - DisposableEffect
onDispose only schedules, because teardown disposes a composition and that
must not run re-entrantly inside another composition's apply pass. */
internal class AppRuntime {
lateinit var scope: CoroutineScope
val windows = mutableListOf<WindowInstance>()
private val destroyQueue = mutableListOf<WindowInstance>()
var exitRequested = false
var hadWindow = false
fun windowFor(inId: UInt): WindowInstance? =
if (inId == 0u) windows.firstOrNull()
else windows.firstOrNull { it.backend.windowId == inId } ?: windows.firstOrNull()
fun markAllNeedFrame() {
for (vW in windows) vW.needsFrame = true
}
fun createWindow(
inTitle: String,
inWidth: Int,
inHeight: Int,
inGpu: GpuMode,
inIcon: AppWindowIcon?,
inOnFrame: ((RenderBackend, Int) -> Boolean)?,
inContent: () -> (@Composable ComposeWindowScope.() -> Unit),
): WindowInstance {
val vWindow = WindowInstance(inTitle, inWidth, inHeight, inGpu, inIcon, inOnFrame, inContent)
// A Window() was declared either way - the "exit when the last window
// is gone" rule must also fire when every declared window failed to
// initialise (otherwise the loop would spin forever with none).
hadWindow = true
if (vWindow.init(scope)) {
windows.add(vWindow)
} else {
println("nativeComposeApp: window '$inTitle' failed to initialise")
}
return vWindow
}
fun scheduleDestroy(inWindow: WindowInstance) {
if (inWindow in windows && inWindow !in destroyQueue) destroyQueue.add(inWindow)
}
fun reapDestroyed() {
while (destroyQueue.isNotEmpty()) {
val vW = destroyQueue.removeLast()
windows.remove(vW)
vW.destroy()
}
}
}
// No-op applier for the app-level composition - it emits no UI nodes, only
// side effects (each Window() manages a native window).
private class UnitApplier : AbstractApplier<Unit>(Unit) {
override fun insertTopDown(index: Int, instance: Unit) {}
override fun insertBottomUp(index: Int, instance: Unit) {}
override fun remove(index: Int, count: Int) {}
override fun move(from: Int, to: Int, count: Int) {}
override fun onClear() {}
}
// ==================
// MARK: WindowInstance - one SDL window + renderer + composition
// ==================
internal class WindowInstance(
private val initialTitle: String,
inWidth: Int,
inHeight: Int,
inGpu: GpuMode,
inIcon: AppWindowIcon?,
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
) {
// Resolved renderer mode. `var` so init() can fall back GPU → CPU raster when
// a Skia GPU context can't be created (RDP / headless / missing GL driver).
private var gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
private val initialWidth = inWidth
private val initialHeight = inHeight
private val icon = inIcon
// Assigned by init() from the first backend that comes up (see makeBackend()).
lateinit var backend: SDL3Backend
private set
private var renderBackend: RenderBackend? = null
lateinit var host: ComposeRootHost
private set
lateinit var facade: ComposeNativeWindow
private set
private var popupHost: com.compose.sdl.window.PopupHostState? = null
val frameClock = SDL3FrameClock()
private var recomposer: Recomposer? = null
private var recomposeJob: Job? = null
private var composition: Composition? = null
// Set by Window(); the compat wrapper points it at exitApplication().
var onCloseRequest: () -> Unit = {}
var closeDispatched = false
// Render-on-demand flag (see the pre-multi-window loop): set by events,
// state writes (global observer), resize, expose.
var needsFrame = true
// Hover refresh: last pointer position in PIXEL space, re-dispatched as a
// synthetic Move after layout so items scrolled under the cursor hover.
private var lastMouseX = -1f
private var lastMouseY = -1f
private var hasMousePos = false
private var frameIndex = 0
private var fpsEma = 0.0
private var fpsLastFrameMs = SDL_GetTicks()
private var fpsLastTitleMs = 0uL
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
// Escape completes a back navigation on THIS window's dispatcher.
private val navigationEventOwner = object : androidx.navigationevent.NavigationEventDispatcherOwner {
override val navigationEventDispatcher = androidx.navigationevent.NavigationEventDispatcher()
}
private val backNavigationInput = BackNavigationInput()
// WINDOW-scoped architecture-components owner (Lifecycle + ViewModelStore +
// SavedStateRegistry) - the same trio upstream desktop's
// DefaultArchitectureComponentsOwner supplies through the window
// PlatformContext (compose/ui skikoMain PlatformOwnerProvider.skiko.kt).
// viewModel(), SavedStateHandle plumbing and navigation3's
// rememberViewModelStoreNavEntryDecorator all resolve their parent owners
// from this; destroy() moves it to DESTROYED and clears the store.
private val architectureOwner = WindowArchitectureOwner()
// Window focus / visibility → Lifecycle.State, Compose Desktop's mapping:
// focused → RESUMED, visible unfocused → STARTED, hidden/minimised →
// CREATED. Starts focused+visible (SDL fires FOCUS_GAINED right after
// creation anyway; headless probe runs simply stay RESUMED).
private var windowFocused = true
private var windowVisible = true
fun onActivationEvent(inEvent: AppEvent.WindowActivation) {
inEvent.focused?.let { windowFocused = it }
inEvent.visible?.let { windowVisible = it }
if (::host.isInitialized) host.setWindowFocused(windowFocused)
architectureOwner.setLifecycleState(
when {
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
windowFocused -> androidx.lifecycle.Lifecycle.State.RESUMED
else -> androidx.lifecycle.Lifecycle.State.STARTED
}
)
// Shown / restored / focus-gained also invalidate the contents - keep
// the pre-lifecycle RedrawNeeded behaviour of these SDL events.
needsFrame = true
}
// Creates + initialises an SDL3Backend for [mode] and its RenderBackend. On
// success commits `backend` and returns the renderer; else tears everything
// down and returns null so the caller can retry with a different mode.
private fun makeBackend(mode: GpuMode): RenderBackend? {
val vBackend = SDL3Backend(
initialTitle, initialWidth, initialHeight, gpuMode = mode,
iconLightResourcePaths = icon?.light ?: emptyList(),
iconDarkResourcePaths = icon?.dark ?: emptyList(),
)
if (!vBackend.init()) {
vBackend.destroy(inQuitSdl = false)
return null
}
vBackend.updateWindowSize()
val vRender = createRenderBackend(vBackend, mode)
if (vRender == null || !vRender.ensureSize(vBackend.pixelWidth, vBackend.pixelHeight)) {
vRender?.destroy()
vBackend.destroy(inQuitSdl = false)
return null
}
backend = vBackend
return vRender
}
fun init(inScope: CoroutineScope): Boolean {
// Try the resolved gpuMode; if it's a Skia GPU mode whose context/bridge
// can't come up (RDP / headless / missing GL driver), fall back once to
// CPU raster (Software) so the window still opens instead of failing.
var vRender = makeBackend(gpuMode)
if (vRender == null && gpuMode is GpuMode.Skia) {
println("GPU renderer ($gpuMode) unavailable - falling back to CPU raster (Software)")
gpuMode = GpuMode.Software
vRender = makeBackend(gpuMode)
}
if (vRender == null) {
println("Failed to init render backend")
return false
}
renderBackend = vRender
host = ComposeRootHost(inDensity = backend.pixelDensity)
host.attach()
// A layer whose content changed (OwnedLayer.invalidate) schedules a frame
// even when nothing else (recompose / relayout) is pending - retained layers
// need this so a draw-only state change still repaints.
host.setInvalidationCallback { needsFrame = true }
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
// The render-bridge globals must point at THIS window's renderer while
// its content composes/measures (first composition happens inside
// setContent below).
installGlobals()
// Effect context for this window's composition - the screenshot flag injects the
// infinite-animation-cancelling policy (see disableInfiniteAnimations above).
var vEffectContext = inScope.coroutineContext + frameClock
if (disableInfiniteAnimations) vEffectContext += CancelInfiniteAnimationsPolicy
val vRecomposer = Recomposer(vEffectContext)
recomposer = vRecomposer
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
val vComposition = Composition(host.applier, vRecomposer)
composition = vComposition
val vUriHandler = object : androidx.compose.ui.platform.UriHandler {
override fun openUri(uri: String) { openUrl(uri) }
}
val vPopupHost = createPopupHostState()
popupHost = vPopupHost
val vWindowScope = object : ComposeWindowScope {
override val window: ComposeNativeWindow = facade
}
vComposition.setContent {
CompositionLocalProvider(
LocalComposeNativeWindow provides facade,
LocalPopupHost provides vPopupHost,
// Upstream vendored CompositionLocals.kt declares each of these as
// `staticCompositionLocalOf<T> { noLocalProvidedFor("…") }` - reading
// one without a Provider throws. Seed them all from the ComposeOwner.
androidx.compose.ui.platform.LocalDensity provides host.density,
androidx.compose.ui.platform.LocalLayoutDirection provides host.layoutDirection,
androidx.compose.ui.platform.LocalFocusManager provides host.focusManager,
androidx.compose.ui.platform.LocalGraphicsContext provides host.graphicsContext,
androidx.compose.ui.platform.LocalViewConfiguration provides host.viewConfiguration,
androidx.compose.ui.platform.LocalInputModeManager provides host.inputModeManager,
androidx.compose.ui.platform.LocalHapticFeedback provides host.hapticFeedback,
androidx.compose.ui.platform.LocalTextToolbar provides host.textToolbar,
androidx.compose.ui.platform.LocalWindowInfo provides host.windowInfo,
androidx.compose.ui.platform.LocalSoftwareKeyboardController provides host.softwareKeyboardController,
androidx.compose.ui.platform.LocalClipboard provides
androidx.compose.ui.platform.platformClipboard(),
androidx.compose.ui.platform.LocalFontFamilyResolver provides
com.compose.sdl.text.font.projectFontFamilyResolver,
androidx.compose.ui.platform.LocalUriHandler provides vUriHandler,
// Desktop windows have no system bars / notch / IME insets - the
// interface's all-zero defaults are exactly right.
androidx.compose.ui.platform.LocalPlatformWindowInsets provides
object : androidx.compose.ui.platform.PlatformWindowInsets {},
// The window's architecture-components owner backs all three arch
// locals, exactly like upstream desktop's window PlatformContext:
// - LocalLifecycleOwner: RESUMED for the window's whole life;
// lifecycle-aware content (NavDisplay, rememberLifecycleOwner, …)
// errors without it.
// - LocalViewModelStoreOwner: window-scoped ViewModels (google
// lifecycle-viewmodel-compose's local is a plain composition
// local; the JB HostDefault route doesn't exist in the google
// artifacts this port ships).
// - LocalSavedStateRegistryOwner: SavedStateHandle / rememberSaveable
// registry parent (nav3's ViewModelStoreNavEntryDecorator requires it).
androidx.lifecycle.compose.LocalLifecycleOwner provides architectureOwner,
androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner provides architectureOwner,
androidx.savedstate.compose.LocalSavedStateRegistryOwner provides architectureOwner,
// Runtime-level host defaults - this window's navigation-event
// owner (SearchBar / sheets back plumbing). The viewmodel store
// is provided through the plain LocalViewModelStoreOwner above
// instead: the google lifecycle artifacts this port ships have no
// HostDefault key for it (that mechanism is JB-variant-only).
androidx.compose.runtime.LocalHostDefaultProvider provides
remember {
object : androidx.compose.runtime.HostDefaultProvider {
@Suppress("UNCHECKED_CAST")
override fun <T> getHostDefault(key: androidx.compose.runtime.HostDefaultKey<T>): T = when (key) {
androidx.navigationevent.compose.NavigationEventDispatcherOwnerHostDefaultKey -> navigationEventOwner
else -> null
} as T
}
},
) {
// Seed the :ui-internal LocalPointerIconService so Modifier.pointerHoverIcon
// drives the SDL cursor (the local + service type can't cross the module boundary).
host.ProvidePointerIconService {
Box(modifier = Modifier.fillMaxSize()) {
// Read through the holder so Window() recompositions with a
// new content lambda propagate into this composition.
val vContent = contentHolder()
with(vWindowScope) { vContent() }
PopupLayer(vPopupHost)
}
}
}
}
// First composition done (setContent is synchronous) - promote the
// window lifecycle from CREATED to the focus/visibility-derived state.
// Composition itself runs at CREATED so enableSavedStateHandles()
// callers see a legal state, mirroring upstream desktop's
// compose-first-resume-after ordering.
architectureOwner.setLifecycleState(
when {
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
windowFocused -> androidx.lifecycle.Lifecycle.State.RESUMED
else -> androidx.lifecycle.Lifecycle.State.STARTED
}
)
return true
}
/** Points the render-bridge globals (text measurer / image loader /
viewport) at this window's renderer. Must be called before composing,
measuring, or drawing this window's tree. */
fun installGlobals() {
val vRender = renderBackend ?: return
currentImageLoader = vRender.imageLoader
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
// Register bundled generic fonts (FontFamily.Monospace → NotoSansMono) once
// data.kres is loadable; idempotent, no-op if the font isn't bundled.
com.compose.sdl.text.registerGenericFonts()
// Position the OS IME candidate window the moment a field in THIS window
// gains focus, not only on the first TEXT_EDITING event (repointed per
// window here so multi-window focus targets the right SDL window).
com.compose.sdl.text.input.ImeBridge.onSessionActiveChange = { active ->
if (active) updateImeArea()
}
}
// ============
// Event handling (loop-routed, per window)
fun onPointerEvent(inEvent: AppEvent.Pointer) {
needsFrame = true
installGlobals()
val vType = when (inEvent.event.type) {
PointerEventType.Press -> 1
PointerEventType.Release -> 2
else -> 0
}
val vBtn = when (inEvent.event.button) {
PointerButton.Secondary -> 1
PointerButton.Tertiary -> 2
else -> 0
}
// SDL3 delivers mouse coords in logical points on HiDPI - multiply by
// DPR so hit-testing lands in the pixel space layout uses.
val vDpr = backend.pixelDensity
val vPx = inEvent.event.x * vDpr
val vPy = inEvent.event.y * vDpr
if (inEvent.event.type == PointerEventType.Press) {
popupHost?.notifyOutsidePress(vPx.toInt(), vPy.toInt())
}
host.onPointerRaw(vPx, vPy, vType, vBtn, SDL_GetTicks().toLong())
lastMouseX = vPx
lastMouseY = vPy
hasMousePos = true
}
/** Pointer left the window (SDL_EVENT_WINDOW_MOUSE_LEAVE). Stop the per-frame
synthetic hover re-dispatch and fire one Exit at the last position so a
hovered widget clears its highlight instead of sticking forever. */
fun onPointerExit() {
needsFrame = true
if (hasMousePos) {
host.onPointerRaw(lastMouseX, lastMouseY, 3, 0, SDL_GetTicks().toLong())
}
hasMousePos = false
}
fun onWheelEvent(inEvent: AppEvent.MouseWheel) {
needsFrame = true
installGlobals()
val vDpr = backend.pixelDensity
host.onWheel(inEvent.x * vDpr, inEvent.y * vDpr, inEvent.deltaX, inEvent.deltaY, SDL_GetTicks().toLong())
}
fun onKeyEvent(inEvent: AppEvent.Key) {
needsFrame = true
installGlobals()
// Focused chain → window-level shortcuts → back navigation (Escape).
if (!host.dispatchKeyEvent(inEvent.event) && !facade.dispatchKeyShortcut(inEvent.event)) {
backNavigationInput.onKeyEvent(inEvent.event)
}
}
fun onTextInputEvent(inEvent: AppEvent.TextInput) {
needsFrame = true
installGlobals()
// A focused text field runs an IME session (ComposeOwner.textInputSession):
// commit through it so the text REPLACES any active composition. Falls back
// to synthesising typed KeyEvents when no field is focused - SDL key events
// carry UNSHIFTED keycodes (no uppercase/numpad/dead keys), so committed
// text is the only layout-correct character source.
if (!com.compose.sdl.text.input.ImeBridge.commit(inEvent.text)) {
dispatchTypedText(host, inEvent.text)
}
}
fun onTextEditingEvent(inEvent: AppEvent.TextEditing) {
needsFrame = true
installGlobals()
// IME preedit: show the composing (underlined) region in the focused field.
// No-op if no field is focused. Also nudge the OS candidate window to the
// field's on-screen rect.
com.compose.sdl.text.input.ImeBridge.compose(inEvent.text)
updateImeArea()
}
// Nudge the OS IME candidate window to the focused field's on-screen rect.
// Best-effort: no-op without an active session or a laid-out field.
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, kotlinx.cinterop.ExperimentalForeignApi::class)
private fun updateImeArea() {
val vRequest = com.compose.sdl.text.input.ImeBridge.request ?: return
val vRect = vRequest.focusedRectInRoot() ?: return
val vWindow = backend.window ?: return
val vDpr = backend.pixelDensity
kotlinx.cinterop.memScoped {
// SDL wants the area in logical window points; layout runs in physical px.
val vSdlRect = alloc<sdl3.SDL_Rect>()
vSdlRect.x = (vRect.left / vDpr).toInt()
vSdlRect.y = (vRect.top / vDpr).toInt()
vSdlRect.w = (vRect.width / vDpr).toInt().coerceAtLeast(1)
vSdlRect.h = (vRect.height / vDpr).toInt().coerceAtLeast(1)
sdl3.SDL_SetTextInputArea(vWindow.reinterpret(), vSdlRect.ptr, 0)
}
}
fun onDropEvent(inEvent: AppEvent.Drop) {
needsFrame = true
installGlobals()
// SDL fires drop coords in logical points at DPR-1; scale to the pixel
// space layout runs in (Option-B density flow) so hit-testing lands
// where the pointer is.
val vDpr = backend.pixelDensity
when (inEvent.phase) {
AppEvent.DropPhase.BEGIN -> host.onDropBegin()
AppEvent.DropPhase.POSITION -> host.onDropPosition(inEvent.x * vDpr, inEvent.y * vDpr)
AppEvent.DropPhase.FILE -> inEvent.data?.let { host.onDropFile(it) }
AppEvent.DropPhase.TEXT -> inEvent.data?.let { host.onDropText(it) }
AppEvent.DropPhase.COMPLETE -> host.onDropComplete()
}
}
fun onResizedEvent() {
needsFrame = true
backend.updateWindowSize()
facade.onResized()
}
/** OS light/dark theme changed - re-pick the theme-appropriate window icon
(no-op if this window has no icon configured or the choice is unchanged). */
fun onSystemThemeChanged() {
backend.applyThemeIcon()
}
/** OS close button / app-level Quit: honour the veto handler, then hand the
decision to the Window() declaration. */
fun requestClose() {
if (closeDispatched) return
if (facade.requestCloseFromUser()) {
closeDispatched = true
onCloseRequest()
}
}
// ============
// Frame pump
fun shouldRender(): Boolean =
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
/** True while this window still has pending work: a state/layout/draw invalidation
(needsFrame), recomposer work, or a composition/node animation awaiting the next
frame. Sampled by windowHasInvalidations() from onFrame probes - the quiescence
signal for render-to-settle screenshot capture. */
fun hasInvalidations(): Boolean =
needsFrame || recomposer?.hasPendingWork == true ||
frameClock.hasAwaiters || host.hasAnimationAwaiters()
fun renderFrame() {
val vRender = renderBackend ?: return
needsFrame = false
if (recomposer?.hasPendingWork == true) needsFrame = true
backend.updateWindowSize()
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
// Charge the GPU back-buffer acquire to its own phase. On Metal this is
// where nextDrawable() blocks on vsync (the natural frame pacing), so
// folding it into "layout" made the profiler read the vsync wait as
// layout cost - a ~6-7ms phantom that hid the real (tiny) layout time.
FrameProfiler.phase(" acquire")
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
// Deliver any state written by this iteration's frame-clock continuations
// (withFrameNanos animations - notably smooth wheel scrolling) BEFORE we lay
// out. Those writes happen in the per-window pump's sendFrame()/yield(), which
// is AFTER the loop's last sendApplyNotifications() - so without this the
// relayout they trigger wasn't registered until the next frame, and the frame
// drew last frame's positions. That one-frame trail read as "the clip is a
// frame late" when scrolling (content briefly drawn past the viewport edge).
Snapshot.sendApplyNotifications()
host.measureAndLayout()
FrameProfiler.phase(" layout")
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
if (hasMousePos) {
host.onPointerRaw(lastMouseX, lastMouseY, 0, 0, SDL_GetTicks().toLong())
}
vRender.beginFrame(1f)
vRender.drawRoot { canvas -> host.drawRoot(canvas) }
if (onFrame != null) {
renderingWindow = this
val vContinue = onFrame.invoke(vRender, frameIndex)
renderingWindow = null
// Probe consumers end the app when their scenario completes.
if (!vContinue) facade.close()
}
FrameProfiler.phase(" draw")
vRender.endFrame()
FrameProfiler.phase(" present")
frameIndex++
// FPS - instantaneous inter-frame rate, EMA-smoothed, title refreshed
// ~4x/sec. This shows within ~2 rendered frames of ANY activity rather
// than waiting for a full second of unbroken rendering to accumulate
// (the old fixed-window counter never got its first update: bursty
// interaction kept idling before 1s elapsed, and the idle-skip reset the
// window each time - so FPS only appeared during a long enough page
// transition). A dt outside 1..100ms is the first frame or a resume from
// idle (the gap isn't a real frame interval), so it's not sampled - the
// title just holds the last active rate while idle.
val vNowMs = SDL_GetTicks()
val vDt = (vNowMs - fpsLastFrameMs).toInt()
fpsLastFrameMs = vNowMs
if (vDt in 1..100) {
val vInst = 1000.0 / vDt
fpsEma = if (fpsEma <= 0.0) vInst else fpsEma * 0.9 + vInst * 0.1
if ((vNowMs - fpsLastTitleMs).toInt() >= 250) {
val vFps = (fpsEma + 0.5).toInt()
facade.updateFps(vFps)
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
fpsLastTitleMs = vNowMs
}
}
}
// Idle-skip calls this: drop the frame timer so the resume-from-idle frame's
// dt (the whole idle gap) isn't sampled as a real interval. The EMA is left
// intact so the title keeps showing the last active rate while idle.
fun resetFpsWindow() {
fpsLastFrameMs = SDL_GetTicks()
}
fun destroy() {
composition?.dispose()
composition = null
// Window gone → DESTROYED lifecycle + ViewModels cleared (onCleared
// runs), matching upstream desktop's window-scoped owner lifetime.
architectureOwner.destroy()
recomposer?.cancel()
recomposer = null
recomposeJob?.cancel()
recomposeJob = null
renderBackend?.destroy()
renderBackend = null
backend.destroy(inQuitSdl = false)
}
}
// WindowArchitectureOwner -> WindowArchitectureOwner.kt
// BackNavigationInput / dispatchTypedText -> WindowInputHelpers.kt
@@ -18,56 +18,56 @@ import sdl3.SDL_GetTicks
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal object FrameProfiler {
// null until first checked; then true/false for the run's lifetime.
private var fEnabled: Boolean? = null
private var mEnabled: 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"
private var mPath: String = "cdn_profile.log"
val enabled: Boolean
get() = fEnabled ?: run {
get() = mEnabled ?: run {
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
(vEnv != null).also { fEnabled = it }
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) mPath = vEnv
(vEnv != null).also { mEnabled = it }
}
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
private val mFreq = SDL_GetPerformanceFrequency().toDouble()
// Insertion-ordered so the printed line follows the call order.
private val fSum = LinkedHashMap<String, Double>()
private val fMax = LinkedHashMap<String, Double>()
private var fFrames = 0
private var fLastPrintMs = SDL_GetTicks()
private var fMark = 0uL
private val mSum = LinkedHashMap<String, Double>()
private val mMax = LinkedHashMap<String, Double>()
private var mFrames = 0
private var mLastPrintMs = SDL_GetTicks()
private var mMark = 0uL
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
fun mark() { if (enabled) mMark = SDL_GetPerformanceCounter() }
fun phase(inName: String) {
if (!enabled) return
val vNow = SDL_GetPerformanceCounter()
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
fMark = vNow
val vMs = (vNow - mMark).toDouble() * 1000.0 / mFreq
mSum[inName] = (mSum[inName] ?: 0.0) + vMs
if (vMs > (mMax[inName] ?: 0.0)) mMax[inName] = vMs
mMark = vNow
}
fun frameDone(inRendered: Boolean) {
if (!enabled) return
if (inRendered) fFrames++
if (inRendered) mFrames++
val vNowMs = SDL_GetTicks()
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
val vParts = fSum.keys.map { vName ->
val vAvg = (fSum[vName] ?: 0.0) / fFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
if (vNowMs - mLastPrintMs >= 2000u && mFrames > 0) {
val vParts = mSum.keys.map { vName ->
val vAvg = (mSum[vName] ?: 0.0) / mFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${((mMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
}
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + "\n"
val vFile = platform.posix.fopen(fPath, "a")
val vLine = "[profile] frames=$mFrames avg/max " + vParts.joinToString(" ") + "\n"
val vFile = platform.posix.fopen(mPath, "a")
if (vFile != null) {
platform.posix.fputs(vLine, vFile)
platform.posix.fclose(vFile)
}
fSum.clear(); fMax.clear()
fFrames = 0
fLastPrintMs = vNowMs
mSum.clear(); mMax.clear()
mFrames = 0
mLastPrintMs = vNowMs
}
}
}
@@ -0,0 +1,83 @@
@file:OptIn(
androidx.compose.ui.InternalComposeUiApi::class,
androidx.compose.runtime.InternalComposeApi::class,
)
package com.compose.sdl
import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.pointed
import kotlinx.coroutines.CancellationException
import sdl3.SDL_GetCurrentDisplayMode
import sdl3.SDL_GetDisplayForWindow
import sdl3.SDL_GetTicks
import sdl3.SDL_GetTicksNS
// ==================
// MARK: Deterministic frame timing + probe/screenshot support
// ==================
/** Set BEFORE nativeComposeApp/nativeComposeWindow: every window's composition then runs
under an InfiniteAnimationPolicy that CANCELS infinite animations, so
rememberInfiniteTransition & co. freeze at their initial value - the same mechanism
upstream's test rules use. Screenshot/parity runs enable it so looping screens can
reach quiescence and capture deterministically. */
var disableInfiniteAnimations: Boolean = false
/** The cancelling policy: a coroutine that ends in CancellationException counts as
cancelled (not failed), so only the animation coroutine stops - nothing propagates. */
internal object CancelInfiniteAnimationsPolicy : androidx.compose.ui.platform.InfiniteAnimationPolicy {
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
throw CancellationException("infinite animations are disabled (disableInfiniteAnimations)")
}
/** Set BEFORE nativeComposeApp/nativeComposeWindow: the composition + animation frame
clocks advance a VIRTUAL 16.6ms per main-loop iteration instead of reading SDL's
real-time ticks - the native mirror of the JVM parity leg's render(nanos) stepping.
Animations then progress by exact per-frame deltas, so anything time-raced (e.g. a
bring-into-view scroll interrupted mid-flight) resolves identically on every run and
screenshots become deterministic. Input timestamps and FPS stay on real time. */
var useVirtualFrameTime: Boolean = false
private var virtualFrameNanos = 0L
internal fun advanceVirtualFrame() {
if (useVirtualFrameTime) virtualFrameNanos += 16_666_667L
}
// Timestamp for the composition frame clocks (recomposer + withFrameNanos animations).
internal fun frameClockNanos(): Long =
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicksNS().toLong()
// Timestamp for the owner's node-animation clock - real path keeps the pre-existing
// ms-resolution SDL_GetTicks base so non-screenshot behaviour is bit-for-bit unchanged.
internal fun animationClockNanos(): Long =
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicks().toLong() * 1_000_000L
// The window currently inside renderFrame - the loop is single-threaded, so a plain
// var is enough for onFrame probes to address "the window I'm being called for".
internal var renderingWindow: WindowInstance? = null
/** From inside an onFrame callback: true while the just-rendered window still has pending
work (recomposition, layout/draw invalidation, or an animation awaiting the next frame).
Screenshot probes capture once this stays false for a few consecutive frames instead of
at a fixed frame count. Outside onFrame it answers false. */
fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: false
/** 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)
internal fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
/** Frame interval (ms) of the window's current display, for the non-vsync
fallback pacing (SDL_Delay). Falls back to 16ms (~60Hz) when the mode can't
be read, so a 144Hz panel on a non-vsync path isn't capped to 60. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal fun displayFrameDelayMs(window: COpaquePointer?): UInt {
val vWindow = window ?: return 16u
val vDisplay = SDL_GetDisplayForWindow(vWindow.reinterpret())
val vMode = SDL_GetCurrentDisplayMode(vDisplay) ?: return 16u
val vHz = vMode.pointed.refresh_rate
return if (vHz > 0f) (1000f / vHz).toUInt().coerceAtLeast(1u) else 16u
}
@@ -36,16 +36,16 @@ import sdl3.SDL_GetCurrentThreadID
writes land at the loop's drainPending(), observable on the same frame. */
internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
private val fQueue = Channel<Runnable>(Channel.UNLIMITED)
private val mQueue = Channel<Runnable>(Channel.UNLIMITED)
// The SDL main thread - the dispatcher is constructed by nativeComposeApp
// on the thread that then runs the loop.
private val fMainThreadId = SDL_GetCurrentThreadID()
private val mMainThreadId = SDL_GetCurrentThreadID()
override val immediate: MainCoroutineDispatcher = ImmediateDispatcher()
override fun dispatch(context: CoroutineContext, block: Runnable) {
fQueue.trySend(block)
mQueue.trySend(block)
}
/** Drains all pending tasks. Called from the SDL3 main loop once per
@@ -54,7 +54,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
composition. */
fun drainPending() {
while (true) {
val vTask = fQueue.tryReceive().getOrNull() ?: break
val vTask = mQueue.tryReceive().getOrNull() ?: break
runCatching { vTask.run() }.onFailure { vErr ->
println("Sdl3MainDispatcher: dispatched task threw: ${vErr.message}")
}
@@ -64,7 +64,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
/** Closes the queue. After this, dispatch() drops new posts silently.
Called from nativeComposeWindow as part of shutdown. */
fun close() {
fQueue.close()
mQueue.close()
}
override fun toString(): String = "Sdl3MainDispatcher"
@@ -75,10 +75,10 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
override val immediate: MainCoroutineDispatcher get() = this
override fun isDispatchNeeded(context: CoroutineContext): Boolean =
SDL_GetCurrentThreadID() != fMainThreadId
SDL_GetCurrentThreadID() != mMainThreadId
override fun dispatch(context: CoroutineContext, block: Runnable) {
fQueue.trySend(block)
mQueue.trySend(block)
}
override fun toString(): String = "Sdl3MainDispatcher.immediate"
@@ -0,0 +1,31 @@
package com.compose.sdl
// ==================
// MARK: WindowAttributes
// ==================
/**
The Compose Desktop `Window()` attributes this port supports, bundled so they
travel as one value from the composable down to [WindowInstance] instead of as
six positional booleans.
[transparent] is creation-only: SDL needs "SDL_WINDOW_TRANSPARENT" at
"SDL_CreateWindow" time and offers no setter, so changing it after the window
exists has no effect. The rest are re-applied whenever "Window()" recomposes
with a new value.
*/
internal data class WindowAttributes(
/** Hides the title bar and border (`SDL_WINDOW_BORDERLESS`). */
val undecorated: Boolean = false,
/** Per-pixel alpha for the window surface. CREATION-ONLY - see above. */
val transparent: Boolean = false,
/** Whether the user can resize the window. */
val resizable: Boolean = true,
/** False drops input (pointer / key / text / wheel / drop) while the window
keeps rendering - Compose Desktop's `enabled = false` behaviour. */
val enabled: Boolean = true,
/** False stops the window taking keyboard focus when clicked or raised. */
val focusable: Boolean = true,
/** Keeps the window above other windows. */
val alwaysOnTop: Boolean = false,
)
@@ -0,0 +1,575 @@
@file:OptIn(
androidx.compose.ui.InternalComposeUiApi::class,
androidx.compose.runtime.InternalComposeApi::class,
)
package com.compose.sdl
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import com.compose.sdl.node.ComposeRootHost
import com.compose.sdl.res.currentImageLoader
import com.compose.sdl.window.LocalPopupHost
import com.compose.sdl.window.PopupLayer
import com.compose.sdl.window.createPopupHostState
import kotlinx.cinterop.alloc
import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import sdl3.SDL_GetTicks
import sdl3.SDL_SetWindowTitle
// ==================
// MARK: WindowInstance - one SDL window + renderer + composition
// ==================
internal class WindowInstance(
private val initialTitle: String,
inWidth: Int,
inHeight: Int,
inGpu: GpuMode,
inIcon: AppWindowIcon?,
private val attrs: WindowAttributes,
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
// Window-level key handlers, read through holders so Window() recompositions
// with new lambdas take effect without recreating the window.
private val previewKeyHandler: () -> ((KeyEvent) -> Boolean),
private val keyHandler: () -> ((KeyEvent) -> Boolean),
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
) {
// Resolved renderer mode. `var` so init() can fall back GPU → CPU raster when
// a Skia GPU context can't be created (RDP / headless / missing GL driver).
private var gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
private val initialWidth = inWidth
private val initialHeight = inHeight
private val icon = inIcon
// Assigned by init() from the first backend that comes up (see makeBackend()).
lateinit var backend: SDL3Backend
private set
private var renderBackend: RenderBackend? = null
lateinit var host: ComposeRootHost
private set
lateinit var facade: ComposeNativeWindow
private set
private var popupHost: com.compose.sdl.window.PopupHostState? = null
val frameClock = SDL3FrameClock()
private var recomposer: Recomposer? = null
private var recomposeJob: Job? = null
private var composition: Composition? = null
// Set by Window(); the compat wrapper points it at exitApplication().
var onCloseRequest: () -> Unit = {}
var closeDispatched = false
// Render-on-demand flag (see the pre-multi-window loop): set by events,
// state writes (global observer), resize, expose.
var needsFrame = true
// Hover refresh: last pointer position in PIXEL space, re-dispatched as a
// synthetic Move after layout so items scrolled under the cursor hover.
private var lastMouseX = -1f
private var lastMouseY = -1f
private var hasMousePos = false
private var frameIndex = 0
private var fpsEma = 0.0
private var fpsLastFrameMs = SDL_GetTicks()
private var fpsLastTitleMs = 0uL
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
// Escape completes a back navigation on THIS window's dispatcher.
private val navigationEventOwner = object : androidx.navigationevent.NavigationEventDispatcherOwner {
override val navigationEventDispatcher = androidx.navigationevent.NavigationEventDispatcher()
}
private val backNavigationInput = BackNavigationInput()
// WINDOW-scoped architecture-components owner (Lifecycle + ViewModelStore +
// SavedStateRegistry) - the same trio upstream desktop's
// DefaultArchitectureComponentsOwner supplies through the window
// PlatformContext (compose/ui skikoMain PlatformOwnerProvider.skiko.kt).
// viewModel(), SavedStateHandle plumbing and navigation3's
// rememberViewModelStoreNavEntryDecorator all resolve their parent owners
// from this; destroy() moves it to DESTROYED and clears the store.
private val architectureOwner = WindowArchitectureOwner()
// Window focus / visibility → Lifecycle.State, Compose Desktop's mapping:
// focused → RESUMED, visible unfocused → STARTED, hidden/minimised →
// CREATED. Starts focused+visible (SDL fires FOCUS_GAINED right after
// creation anyway; headless probe runs simply stay RESUMED).
private var windowFocused = true
private var windowVisible = true
fun onActivationEvent(inEvent: AppEvent.WindowActivation) {
inEvent.focused?.let { windowFocused = it }
inEvent.visible?.let { windowVisible = it }
if (::host.isInitialized) host.setWindowFocused(windowFocused)
architectureOwner.setLifecycleState(
when {
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
windowFocused -> androidx.lifecycle.Lifecycle.State.RESUMED
else -> androidx.lifecycle.Lifecycle.State.STARTED
}
)
// Shown / restored / focus-gained also invalidate the contents - keep
// the pre-lifecycle RedrawNeeded behaviour of these SDL events.
needsFrame = true
}
// Creates + initialises an SDL3Backend for [mode] and its RenderBackend. On
// success commits `backend` and returns the renderer; else tears everything
// down and returns null so the caller can retry with a different mode.
private fun makeBackend(mode: GpuMode): RenderBackend? {
val vBackend = SDL3Backend(
initialTitle, initialWidth, initialHeight, gpuMode = mode,
iconLightResourcePaths = icon?.light ?: emptyList(),
iconDarkResourcePaths = icon?.dark ?: emptyList(),
undecorated = attrs.undecorated,
transparent = attrs.transparent,
resizable = attrs.resizable,
alwaysOnTop = attrs.alwaysOnTop,
focusable = attrs.focusable,
)
if (!vBackend.init()) {
vBackend.destroy(inQuitSdl = false)
return null
}
vBackend.updateWindowSize()
val vRender = createRenderBackend(vBackend, mode)
if (vRender == null || !vRender.ensureSize(vBackend.pixelWidth, vBackend.pixelHeight)) {
vRender?.destroy()
vBackend.destroy(inQuitSdl = false)
return null
}
backend = vBackend
return vRender
}
fun init(inScope: CoroutineScope): Boolean {
// Try the resolved gpuMode; if it's a Skia GPU mode whose context/bridge
// can't come up (RDP / headless / missing GL driver), fall back once to
// CPU raster (Software) so the window still opens instead of failing.
var vRender = makeBackend(gpuMode)
if (vRender == null && gpuMode.isGpuAccelerated) {
println("GPU renderer ($gpuMode) unavailable - falling back to CPU raster (Software)")
gpuMode = GpuMode.CpuRaster
vRender = makeBackend(gpuMode)
}
if (vRender == null) {
println("Failed to init render backend")
return false
}
renderBackend = vRender
host = ComposeRootHost(inDensity = backend.pixelDensity)
host.attach()
// A layer whose content changed (OwnedLayer.invalidate) schedules a frame
// even when nothing else (recompose / relayout) is pending - retained layers
// need this so a draw-only state change still repaints.
host.setInvalidationCallback { needsFrame = true }
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
// Mirror the creation flags into the facade so isResizable & co. read true
// from the start and a later Window() update only fires on a real change.
applyAttributes(attrs)
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
// The render-bridge globals must point at THIS window's renderer while
// its content composes/measures (first composition happens inside
// setContent below).
installGlobals()
// Effect context for this window's composition - the screenshot flag injects the
// infinite-animation-cancelling policy (see disableInfiniteAnimations above).
var vEffectContext = inScope.coroutineContext + frameClock
if (disableInfiniteAnimations) vEffectContext += CancelInfiniteAnimationsPolicy
val vRecomposer = Recomposer(vEffectContext)
recomposer = vRecomposer
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
val vComposition = Composition(host.applier, vRecomposer)
composition = vComposition
val vUriHandler = object : androidx.compose.ui.platform.UriHandler {
override fun openUri(uri: String) { openUrl(uri) }
}
val vPopupHost = createPopupHostState()
popupHost = vPopupHost
val vWindowScope = object : ComposeWindowScope {
override val window: ComposeNativeWindow = facade
}
vComposition.setContent {
CompositionLocalProvider(
LocalComposeNativeWindow provides facade,
LocalPopupHost provides vPopupHost,
// Upstream vendored CompositionLocals.kt declares each of these as
// `staticCompositionLocalOf<T> { noLocalProvidedFor("…") }` - reading
// one without a Provider throws. Seed them all from the ComposeOwner.
androidx.compose.ui.platform.LocalDensity provides host.density,
androidx.compose.ui.platform.LocalLayoutDirection provides host.layoutDirection,
androidx.compose.ui.platform.LocalFocusManager provides host.focusManager,
androidx.compose.ui.platform.LocalGraphicsContext provides host.graphicsContext,
androidx.compose.ui.platform.LocalViewConfiguration provides host.viewConfiguration,
androidx.compose.ui.platform.LocalInputModeManager provides host.inputModeManager,
androidx.compose.ui.platform.LocalHapticFeedback provides host.hapticFeedback,
androidx.compose.ui.platform.LocalTextToolbar provides host.textToolbar,
androidx.compose.ui.platform.LocalWindowInfo provides host.windowInfo,
androidx.compose.ui.platform.LocalSoftwareKeyboardController provides host.softwareKeyboardController,
androidx.compose.ui.platform.LocalClipboard provides
androidx.compose.ui.platform.platformClipboard(),
androidx.compose.ui.platform.LocalFontFamilyResolver provides
com.compose.sdl.text.font.projectFontFamilyResolver,
androidx.compose.ui.platform.LocalUriHandler provides vUriHandler,
// Desktop windows have no system bars / notch / IME insets - the
// interface's all-zero defaults are exactly right.
androidx.compose.ui.platform.LocalPlatformWindowInsets provides
object : androidx.compose.ui.platform.PlatformWindowInsets {},
// The window's architecture-components owner backs all three arch
// locals, exactly like upstream desktop's window PlatformContext:
// - LocalLifecycleOwner: RESUMED for the window's whole life;
// lifecycle-aware content (NavDisplay, rememberLifecycleOwner, …)
// errors without it.
// - LocalViewModelStoreOwner: window-scoped ViewModels (google
// lifecycle-viewmodel-compose's local is a plain composition
// local; the JB HostDefault route doesn't exist in the google
// artifacts this port ships).
// - LocalSavedStateRegistryOwner: SavedStateHandle / rememberSaveable
// registry parent (nav3's ViewModelStoreNavEntryDecorator requires it).
androidx.lifecycle.compose.LocalLifecycleOwner provides architectureOwner,
androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner provides architectureOwner,
androidx.savedstate.compose.LocalSavedStateRegistryOwner provides architectureOwner,
// Runtime-level host defaults - this window's navigation-event
// owner (SearchBar / sheets back plumbing). The viewmodel store
// is provided through the plain LocalViewModelStoreOwner above
// instead: the google lifecycle artifacts this port ships have no
// HostDefault key for it (that mechanism is JB-variant-only).
androidx.compose.runtime.LocalHostDefaultProvider provides
remember {
object : androidx.compose.runtime.HostDefaultProvider {
@Suppress("UNCHECKED_CAST")
override fun <T> getHostDefault(key: androidx.compose.runtime.HostDefaultKey<T>): T = when (key) {
androidx.navigationevent.compose.NavigationEventDispatcherOwnerHostDefaultKey -> navigationEventOwner
else -> null
} as T
}
},
) {
// Seed the :ui-internal LocalPointerIconService so Modifier.pointerHoverIcon
// drives the SDL cursor (the local + service type can't cross the module boundary).
host.ProvidePointerIconService {
Box(modifier = Modifier.fillMaxSize()) {
// Read through the holder so Window() recompositions with a
// new content lambda propagate into this composition.
val vContent = contentHolder()
with(vWindowScope) { vContent() }
PopupLayer(vPopupHost)
}
}
}
}
// First composition done (setContent is synchronous) - promote the
// window lifecycle from CREATED to the focus/visibility-derived state.
// Composition itself runs at CREATED so enableSavedStateHandles()
// callers see a legal state, mirroring upstream desktop's
// compose-first-resume-after ordering.
architectureOwner.setLifecycleState(
when {
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
windowFocused -> androidx.lifecycle.Lifecycle.State.RESUMED
else -> androidx.lifecycle.Lifecycle.State.STARTED
}
)
return true
}
/** Points the render-bridge globals (text measurer / image loader /
viewport) at this window's renderer. Must be called before composing,
measuring, or drawing this window's tree. */
fun installGlobals() {
val vRender = renderBackend ?: return
currentImageLoader = vRender.imageLoader
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
// Register bundled generic fonts (FontFamily.Monospace → NotoSansMono) once
// data.kres is loadable; idempotent, no-op if the font isn't bundled.
com.compose.sdl.text.registerGenericFonts()
// Position the OS IME candidate window the moment a field in THIS window
// gains focus, not only on the first TEXT_EDITING event (repointed per
// window here so multi-window focus targets the right SDL window).
com.compose.sdl.text.input.ImeBridge.onSessionActiveChange = { active ->
if (active) updateImeArea()
}
}
// ============
// Event handling (loop-routed, per window)
fun onPointerEvent(inEvent: AppEvent.Pointer) {
if (!facade.isEnabled) return
needsFrame = true
installGlobals()
val vType = when (inEvent.type) {
PointerEventType.Press -> 1
PointerEventType.Release -> 2
else -> 0
}
val vBtn = when (inEvent.button) {
PointerButton.Secondary -> 1
PointerButton.Tertiary -> 2
else -> 0
}
// SDL3 delivers mouse coords in logical points on HiDPI - multiply by
// DPR so hit-testing lands in the pixel space layout uses.
val vDpr = backend.pixelDensity
val vPx = inEvent.x * vDpr
val vPy = inEvent.y * vDpr
if (inEvent.type == PointerEventType.Press) {
popupHost?.notifyOutsidePress(vPx.toInt(), vPy.toInt())
}
host.onPointerRaw(vPx, vPy, vType, vBtn, SDL_GetTicks().toLong())
lastMouseX = vPx
lastMouseY = vPy
hasMousePos = true
}
/** Pointer left the window (SDL_EVENT_WINDOW_MOUSE_LEAVE). Stop the per-frame
synthetic hover re-dispatch and fire one Exit at the last position so a
hovered widget clears its highlight instead of sticking forever. */
fun onPointerExit() {
if (!facade.isEnabled) return
needsFrame = true
if (hasMousePos) {
host.onPointerRaw(lastMouseX, lastMouseY, 3, 0, SDL_GetTicks().toLong())
}
hasMousePos = false
}
fun onWheelEvent(inEvent: AppEvent.MouseWheel) {
if (!facade.isEnabled) return
needsFrame = true
installGlobals()
val vDpr = backend.pixelDensity
host.onWheel(inEvent.x * vDpr, inEvent.y * vDpr, inEvent.deltaX, inEvent.deltaY, SDL_GetTicks().toLong())
}
fun onKeyEvent(inEvent: AppEvent.Key) {
if (!facade.isEnabled) return
needsFrame = true
installGlobals()
// Compose Desktop's order: window preview → focused content → window
// onKeyEvent → app shortcut handler → back navigation (Escape).
if (previewKeyHandler().invoke(inEvent.event)) return
if (host.dispatchKeyEvent(inEvent.event)) return
if (keyHandler().invoke(inEvent.event)) return
if (!facade.dispatchKeyShortcut(inEvent.event)) {
backNavigationInput.onKeyEvent(inEvent.event)
}
}
/** Applies the mutable window attributes. Called at init from the creation
flags and again whenever Window() sees one of them change. */
fun applyAttributes(inAttrs: WindowAttributes) {
facade.setUndecorated(inAttrs.undecorated)
facade.setResizable(inAttrs.resizable)
facade.setAlwaysOnTop(inAttrs.alwaysOnTop)
facade.setFocusable(inAttrs.focusable)
facade.setEnabled(inAttrs.enabled)
}
fun onTextInputEvent(inEvent: AppEvent.TextInput) {
if (!facade.isEnabled) return
needsFrame = true
installGlobals()
// A focused text field runs an IME session (ComposeOwner.textInputSession):
// commit through it so the text REPLACES any active composition. Falls back
// to synthesising typed KeyEvents when no field is focused - SDL key events
// carry UNSHIFTED keycodes (no uppercase/numpad/dead keys), so committed
// text is the only layout-correct character source.
if (!com.compose.sdl.text.input.ImeBridge.commit(inEvent.text)) {
dispatchTypedText(host, inEvent.text)
}
}
fun onTextEditingEvent(inEvent: AppEvent.TextEditing) {
if (!facade.isEnabled) return
needsFrame = true
installGlobals()
// IME preedit: show the composing (underlined) region in the focused field.
// No-op if no field is focused. Also nudge the OS candidate window to the
// field's on-screen rect.
com.compose.sdl.text.input.ImeBridge.compose(inEvent.text)
updateImeArea()
}
// Nudge the OS IME candidate window to the focused field's on-screen rect.
// Best-effort: no-op without an active session or a laid-out field.
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, kotlinx.cinterop.ExperimentalForeignApi::class)
private fun updateImeArea() {
val vRequest = com.compose.sdl.text.input.ImeBridge.request ?: return
val vRect = vRequest.focusedRectInRoot() ?: return
val vWindow = backend.window ?: return
val vDpr = backend.pixelDensity
kotlinx.cinterop.memScoped {
// SDL wants the area in logical window points; layout runs in physical px.
val vSdlRect = alloc<sdl3.SDL_Rect>()
vSdlRect.x = (vRect.left / vDpr).toInt()
vSdlRect.y = (vRect.top / vDpr).toInt()
vSdlRect.w = (vRect.width / vDpr).toInt().coerceAtLeast(1)
vSdlRect.h = (vRect.height / vDpr).toInt().coerceAtLeast(1)
sdl3.SDL_SetTextInputArea(vWindow.reinterpret(), vSdlRect.ptr, 0)
}
}
fun onDropEvent(inEvent: AppEvent.Drop) {
if (!facade.isEnabled) return
needsFrame = true
installGlobals()
// SDL fires drop coords in logical points at DPR-1; scale to the pixel
// space layout runs in (Option-B density flow) so hit-testing lands
// where the pointer is.
val vDpr = backend.pixelDensity
when (inEvent.phase) {
AppEvent.DropPhase.BEGIN -> host.onDropBegin()
AppEvent.DropPhase.POSITION -> host.onDropPosition(inEvent.x * vDpr, inEvent.y * vDpr)
AppEvent.DropPhase.FILE -> inEvent.data?.let { host.onDropFile(it) }
AppEvent.DropPhase.TEXT -> inEvent.data?.let { host.onDropText(it) }
AppEvent.DropPhase.COMPLETE -> host.onDropComplete()
}
}
fun onResizedEvent() {
needsFrame = true
backend.updateWindowSize()
facade.onResized()
}
/** OS light/dark theme changed - re-pick the theme-appropriate window icon
(no-op if this window has no icon configured or the choice is unchanged). */
fun onSystemThemeChanged() {
backend.applyThemeIcon()
}
/** OS close button / app-level Quit: honour the veto handler, then hand the
decision to the Window() declaration. */
fun requestClose() {
if (closeDispatched) return
if (facade.requestCloseFromUser()) {
closeDispatched = true
onCloseRequest()
}
}
// ============
// Frame pump
fun shouldRender(): Boolean =
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
/** True while this window still has pending work: a state/layout/draw invalidation
(needsFrame), recomposer work, or a composition/node animation awaiting the next
frame. Sampled by windowHasInvalidations() from onFrame probes - the quiescence
signal for render-to-settle screenshot capture. */
fun hasInvalidations(): Boolean =
needsFrame || recomposer?.hasPendingWork == true ||
frameClock.hasAwaiters || host.hasAnimationAwaiters()
fun renderFrame() {
val vRender = renderBackend ?: return
needsFrame = false
if (recomposer?.hasPendingWork == true) needsFrame = true
backend.updateWindowSize()
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
// Charge the GPU back-buffer acquire to its own phase. On Metal this is
// where nextDrawable() blocks on vsync (the natural frame pacing), so
// folding it into "layout" made the profiler read the vsync wait as
// layout cost - a ~6-7ms phantom that hid the real (tiny) layout time.
FrameProfiler.phase(" acquire")
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
// Deliver any state written by this iteration's frame-clock continuations
// (withFrameNanos animations - notably smooth wheel scrolling) BEFORE we lay
// out. Those writes happen in the per-window pump's sendFrame()/yield(), which
// is AFTER the loop's last sendApplyNotifications() - so without this the
// relayout they trigger wasn't registered until the next frame, and the frame
// drew last frame's positions. That one-frame trail read as "the clip is a
// frame late" when scrolling (content briefly drawn past the viewport edge).
Snapshot.sendApplyNotifications()
host.measureAndLayout()
FrameProfiler.phase(" layout")
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
if (hasMousePos) {
host.onPointerRaw(lastMouseX, lastMouseY, 0, 0, SDL_GetTicks().toLong())
}
vRender.beginFrame(1f)
vRender.drawRoot { canvas -> host.drawRoot(canvas) }
if (onFrame != null) {
renderingWindow = this
val vContinue = onFrame.invoke(vRender, frameIndex)
renderingWindow = null
// Probe consumers end the app when their scenario completes.
if (!vContinue) facade.close()
}
FrameProfiler.phase(" draw")
vRender.endFrame()
FrameProfiler.phase(" present")
frameIndex++
// FPS - instantaneous inter-frame rate, EMA-smoothed, title refreshed
// ~4x/sec. This shows within ~2 rendered frames of ANY activity rather
// than waiting for a full second of unbroken rendering to accumulate
// (the old fixed-window counter never got its first update: bursty
// interaction kept idling before 1s elapsed, and the idle-skip reset the
// window each time - so FPS only appeared during a long enough page
// transition). A dt outside 1..100ms is the first frame or a resume from
// idle (the gap isn't a real frame interval), so it's not sampled - the
// title just holds the last active rate while idle.
val vNowMs = SDL_GetTicks()
val vDt = (vNowMs - fpsLastFrameMs).toInt()
fpsLastFrameMs = vNowMs
if (vDt in 1..100) {
val vInst = 1000.0 / vDt
fpsEma = if (fpsEma <= 0.0) vInst else fpsEma * 0.9 + vInst * 0.1
if ((vNowMs - fpsLastTitleMs).toInt() >= 250) {
val vFps = (fpsEma + 0.5).toInt()
facade.updateFps(vFps)
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
fpsLastTitleMs = vNowMs
}
}
}
// Idle-skip calls this: drop the frame timer so the resume-from-idle frame's
// dt (the whole idle gap) isn't sampled as a real interval. The EMA is left
// intact so the title keeps showing the last active rate while idle.
fun resetFpsWindow() {
fpsLastFrameMs = SDL_GetTicks()
}
fun destroy() {
composition?.dispose()
composition = null
// Window gone → DESTROYED lifecycle + ViewModels cleared (onCleared
// runs), matching upstream desktop's window-scoped owner lifetime.
architectureOwner.destroy()
recomposer?.cancel()
recomposer = null
recomposeJob?.cancel()
recomposeJob = null
renderBackend?.destroy()
renderBackend = null
backend.destroy(inQuitSdl = false)
}
}
@@ -2090,13 +2090,6 @@ final object androidx.compose.foundation/MarqueeDefaults { // androidx.compose.f
final fun <get-Velocity>(): androidx.compose.ui.unit/Dp // androidx.compose.foundation/MarqueeDefaults.Velocity.<get-Velocity>|<get-Velocity>(){}[0]
}
final object com.compose.sdl.icons/IconDefaults { // com.compose.sdl.icons/IconDefaults|null[0]
final val DefaultIconSize // com.compose.sdl.icons/IconDefaults.DefaultIconSize|{}DefaultIconSize[0]
final fun <get-DefaultIconSize>(): androidx.compose.ui.unit/Dp // com.compose.sdl.icons/IconDefaults.DefaultIconSize.<get-DefaultIconSize>|<get-DefaultIconSize>(){}[0]
final val LocalContentColor // com.compose.sdl.icons/IconDefaults.LocalContentColor|{}LocalContentColor[0]
final fun <get-LocalContentColor>(): androidx.compose.ui.graphics/Color // com.compose.sdl.icons/IconDefaults.LocalContentColor.<get-LocalContentColor>|<get-LocalContentColor>(){}[0]
}
final object com.compose.sdl.widgets/SplitPaneDefaults { // com.compose.sdl.widgets/SplitPaneDefaults|null[0]
final val DividerThickness // com.compose.sdl.widgets/SplitPaneDefaults.DividerThickness|{}DividerThickness[0]
final fun <get-DividerThickness>(): androidx.compose.ui.unit/Dp // com.compose.sdl.widgets/SplitPaneDefaults.DividerThickness.<get-DividerThickness>|<get-DividerThickness>(){}[0]
@@ -2233,7 +2226,6 @@ final val androidx.compose.foundation/androidx_compose_foundation_MarqueeDefault
final val androidx.compose.foundation/androidx_compose_foundation_MutatorMutex$stableprop // androidx.compose.foundation/androidx_compose_foundation_MutatorMutex$stableprop|#static{}androidx_compose_foundation_MutatorMutex$stableprop[0]
final val androidx.compose.foundation/androidx_compose_foundation_ScrollState$stableprop // androidx.compose.foundation/androidx_compose_foundation_ScrollState$stableprop|#static{}androidx_compose_foundation_ScrollState$stableprop[0]
final val androidx.compose.foundation/androidx_compose_foundation_ScrollbarStyle$stableprop // androidx.compose.foundation/androidx_compose_foundation_ScrollbarStyle$stableprop|#static{}androidx_compose_foundation_ScrollbarStyle$stableprop[0]
final val com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop // com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop|#static{}com_compose_sdl_icons_IconDefaults$stableprop[0]
final val com.compose.sdl.widgets/com_compose_sdl_widgets_SplitPaneDefaults$stableprop // com.compose.sdl.widgets/com_compose_sdl_widgets_SplitPaneDefaults$stableprop|#static{}com_compose_sdl_widgets_SplitPaneDefaults$stableprop[0]
final fun (androidx.compose.foundation.content/TransferableContent).androidx.compose.foundation.content/hasMediaType(androidx.compose.foundation.content/MediaType): kotlin/Boolean // androidx.compose.foundation.content/hasMediaType|hasMediaType@androidx.compose.foundation.content.TransferableContent(androidx.compose.foundation.content.MediaType){}[0]
@@ -2651,7 +2643,6 @@ final fun androidx.compose.foundation/rememberScrollbarAdapter(androidx.compose.
final fun androidx.compose.foundation/rememberScrollbarAdapter(androidx.compose.foundation.text/TextFieldScrollState, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.v2/ScrollbarAdapter // androidx.compose.foundation/rememberScrollbarAdapter|rememberScrollbarAdapter(androidx.compose.foundation.text.TextFieldScrollState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun androidx.compose.foundation/rememberScrollbarAdapter(androidx.compose.foundation/ScrollState, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.v2/ScrollbarAdapter // androidx.compose.foundation/rememberScrollbarAdapter|rememberScrollbarAdapter(androidx.compose.foundation.ScrollState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun com.compose.sdl.icons/IconFontIcon(kotlin/Int, kotlin/String, kotlin/String?, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/Dp, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.icons/IconFontIcon|IconFontIcon(kotlin.Int;kotlin.String;kotlin.String?;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.Dp;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop_getter(): kotlin/Int // com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop_getter|com_compose_sdl_icons_IconDefaults$stableprop_getter(){}[0]
final fun com.compose.sdl.modifier/rememberMutableInteractionSource(androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.interaction/MutableInteractionSource // com.compose.sdl.modifier/rememberMutableInteractionSource|rememberMutableInteractionSource(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun com.compose.sdl.text/IconText(kotlin/String, kotlin/String, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/TextUnit, androidx.compose.ui.text.style/TextAlign, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.text/IconText|IconText(kotlin.String;kotlin.String;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.text.style.TextAlign;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun com.compose.sdl.text/IconText(kotlin/String, kotlin/String, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/TextUnit, androidx.compose.ui.text.style/TextAlign?, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.text/IconText|IconText(kotlin.String;kotlin.String;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.text.style.TextAlign?;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
@@ -2,7 +2,9 @@ package com.compose.sdl.icons
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontVariation
@@ -36,9 +38,21 @@ fun IconFontIcon(
size: Dp = IconDefaults.DefaultIconSize,
fontVariationSettings: List<FontVariation.Setting>? = null,
) {
Box(modifier = modifier.size(size)) {
// CENTER the glyph in the icon box. The glyph's natural text box is TALLER than
// `size` - its height is the font's ascent+descent, and Material Symbols' em box
// overshoots the nominal icon size - so the glyph used to render ~13% of the icon
// size too LOW. This mirrors the JVM actual's explicit baseline math
// (`y = height/2 - (ascent+descent)/2` in MaterialSymbolsIcon.jvm.kt): IconText
// sets no lineHeight, so its box height IS ascent+descent and centering that box
// centers the same span the JVM centers.
//
// `wrapContentSize(unbounded = true)` is load-bearing: Modifier.size() hands the
// child FIXED constraints, so without it the text is measured at exactly `size`,
// nothing overflows, and contentAlignment has nothing to align.
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
com.compose.sdl.text.IconText(
text = codepointToString(codepoint),
modifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true),
fontFamily = fontFamily,
color = tint,
fontSize = size.value.sp,
@@ -47,7 +61,7 @@ fun IconFontIcon(
}
}
object IconDefaults {
internal object IconDefaults {
val DefaultIconSize: Dp = 24.dp
/** STATIC fallback tint for direct IconFontIcon use - :foundation cannot
read material3's LocalContentColor (module layering, same as upstream).
@@ -341,18 +341,10 @@ abstract interface com.compose.sdl.graphics/NativeFinishableCanvas { // com.comp
abstract fun finish() // com.compose.sdl.graphics/NativeFinishableCanvas.finish|finish(){}[0]
}
abstract interface com.compose.sdl.graphics/NativePainterCanvas { // com.compose.sdl.graphics/NativePainterCanvas|null[0]
abstract fun drawNativePainter(kotlin/String, com.compose.sdl.res/ResourceKind, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/NativePainterCanvas.drawNativePainter|drawNativePainter(kotlin.String;com.compose.sdl.res.ResourceKind;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
}
abstract interface com.compose.sdl.graphics/NativeShadowCanvas { // com.compose.sdl.graphics/NativeShadowCanvas|null[0]
abstract fun drawDropShadow(androidx.compose.ui.graphics/Outline, kotlin/Float, androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color) // com.compose.sdl.graphics/NativeShadowCanvas.drawDropShadow|drawDropShadow(androidx.compose.ui.graphics.Outline;kotlin.Float;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color){}[0]
}
abstract interface com.compose.sdl.graphics/NativeShapeClipCanvas { // com.compose.sdl.graphics/NativeShapeClipCanvas|null[0]
abstract fun clipRoundRect(androidx.compose.ui.geometry/RoundRect) // com.compose.sdl.graphics/NativeShapeClipCanvas.clipRoundRect|clipRoundRect(androidx.compose.ui.geometry.RoundRect){}[0]
}
abstract interface com.compose.sdl.res/ImageLoader { // com.compose.sdl.res/ImageLoader|null[0]
abstract fun intrinsicSize(kotlin/String, com.compose.sdl.res/ResourceKind): androidx.compose.ui.geometry/Size // com.compose.sdl.res/ImageLoader.intrinsicSize|intrinsicSize(kotlin.String;com.compose.sdl.res.ResourceKind){}[0]
abstract fun readBytes(kotlin/String): kotlin/ByteArray? // com.compose.sdl.res/ImageLoader.readBytes|readBytes(kotlin.String){}[0]
@@ -364,62 +356,6 @@ sealed interface androidx.compose.ui.graphics.shadow/ShadowContext { // androidx
open fun createInnerShadowPainter(androidx.compose.ui.graphics/Shape, androidx.compose.ui.graphics.shadow/Shadow): androidx.compose.ui.graphics.shadow/InnerShadowPainter // androidx.compose.ui.graphics.shadow/ShadowContext.createInnerShadowPainter|createInnerShadowPainter(androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.shadow.Shadow){}[0]
}
sealed interface com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand|null[0]
final class CubicTo : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.CubicTo|null[0]
constructor <init>(kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/PathCommand.CubicTo.<init>|<init>(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
final val c1x // com.compose.sdl.graphics/PathCommand.CubicTo.c1x|{}c1x[0]
final fun <get-c1x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.c1x.<get-c1x>|<get-c1x>(){}[0]
final val c1y // com.compose.sdl.graphics/PathCommand.CubicTo.c1y|{}c1y[0]
final fun <get-c1y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.c1y.<get-c1y>|<get-c1y>(){}[0]
final val c2x // com.compose.sdl.graphics/PathCommand.CubicTo.c2x|{}c2x[0]
final fun <get-c2x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.c2x.<get-c2x>|<get-c2x>(){}[0]
final val c2y // com.compose.sdl.graphics/PathCommand.CubicTo.c2y|{}c2y[0]
final fun <get-c2y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.c2y.<get-c2y>|<get-c2y>(){}[0]
final val x // com.compose.sdl.graphics/PathCommand.CubicTo.x|{}x[0]
final fun <get-x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.x.<get-x>|<get-x>(){}[0]
final val y // com.compose.sdl.graphics/PathCommand.CubicTo.y|{}y[0]
final fun <get-y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.y.<get-y>|<get-y>(){}[0]
}
final class LineTo : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.LineTo|null[0]
constructor <init>(kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/PathCommand.LineTo.<init>|<init>(kotlin.Float;kotlin.Float){}[0]
final val x // com.compose.sdl.graphics/PathCommand.LineTo.x|{}x[0]
final fun <get-x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.LineTo.x.<get-x>|<get-x>(){}[0]
final val y // com.compose.sdl.graphics/PathCommand.LineTo.y|{}y[0]
final fun <get-y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.LineTo.y.<get-y>|<get-y>(){}[0]
}
final class MoveTo : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.MoveTo|null[0]
constructor <init>(kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/PathCommand.MoveTo.<init>|<init>(kotlin.Float;kotlin.Float){}[0]
final val x // com.compose.sdl.graphics/PathCommand.MoveTo.x|{}x[0]
final fun <get-x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.MoveTo.x.<get-x>|<get-x>(){}[0]
final val y // com.compose.sdl.graphics/PathCommand.MoveTo.y|{}y[0]
final fun <get-y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.MoveTo.y.<get-y>|<get-y>(){}[0]
}
final class QuadTo : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.QuadTo|null[0]
constructor <init>(kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/PathCommand.QuadTo.<init>|<init>(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
final val cx // com.compose.sdl.graphics/PathCommand.QuadTo.cx|{}cx[0]
final fun <get-cx>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.QuadTo.cx.<get-cx>|<get-cx>(){}[0]
final val cy // com.compose.sdl.graphics/PathCommand.QuadTo.cy|{}cy[0]
final fun <get-cy>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.QuadTo.cy.<get-cy>|<get-cy>(){}[0]
final val x // com.compose.sdl.graphics/PathCommand.QuadTo.x|{}x[0]
final fun <get-x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.QuadTo.x.<get-x>|<get-x>(){}[0]
final val y // com.compose.sdl.graphics/PathCommand.QuadTo.y|{}y[0]
final fun <get-y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.QuadTo.y.<get-y>|<get-y>(){}[0]
}
final object Close : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.Close|null[0]
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl.graphics/PathCommand.Close.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // com.compose.sdl.graphics/PathCommand.Close.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // com.compose.sdl.graphics/PathCommand.Close.toString|toString(){}[0]
}
}
abstract class androidx.compose.ui.graphics.colorspace/Adaptation { // androidx.compose.ui.graphics.colorspace/Adaptation|null[0]
final object Companion { // androidx.compose.ui.graphics.colorspace/Adaptation.Companion|null[0]
final val Bradford // androidx.compose.ui.graphics.colorspace/Adaptation.Companion.Bradford|{}Bradford[0]
@@ -1111,19 +1047,6 @@ final class com.compose.sdl.renderer.skia/SkiaLeafDrawer { // com.compose.sdl.re
final fun <get-imageCache>(): com.compose.sdl.renderer.skia/SkiaImageCache // com.compose.sdl.renderer.skia/SkiaLeafDrawer.imageCache.<get-imageCache>|<get-imageCache>(){}[0]
}
final class com.compose.sdl.res/ResourcePainter : androidx.compose.ui.graphics.painter/Painter { // com.compose.sdl.res/ResourcePainter|null[0]
final val intrinsicSize // com.compose.sdl.res/ResourcePainter.intrinsicSize|{}intrinsicSize[0]
final fun <get-intrinsicSize>(): androidx.compose.ui.geometry/Size // com.compose.sdl.res/ResourcePainter.intrinsicSize.<get-intrinsicSize>|<get-intrinsicSize>(){}[0]
final val kind // com.compose.sdl.res/ResourcePainter.kind|{}kind[0]
final fun <get-kind>(): com.compose.sdl.res/ResourceKind // com.compose.sdl.res/ResourcePainter.kind.<get-kind>|<get-kind>(){}[0]
final val resourcePath // com.compose.sdl.res/ResourcePainter.resourcePath|{}resourcePath[0]
final fun <get-resourcePath>(): kotlin/String // com.compose.sdl.res/ResourcePainter.resourcePath.<get-resourcePath>|<get-resourcePath>(){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl.res/ResourcePainter.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // com.compose.sdl.res/ResourcePainter.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // com.compose.sdl.res/ResourcePainter.toString|toString(){}[0]
}
final value class androidx.compose.ui.graphics.colorspace/ColorModel { // androidx.compose.ui.graphics.colorspace/ColorModel|null[0]
final val componentCount // androidx.compose.ui.graphics.colorspace/ColorModel.componentCount|{}componentCount[0]
final fun <get-componentCount>(): kotlin/Int // androidx.compose.ui.graphics.colorspace/ColorModel.componentCount.<get-componentCount>|<get-componentCount>(){}[0]
@@ -2108,10 +2031,6 @@ final object com.compose.sdl.graphics/NativeReleaseQueue { // com.compose.sdl.gr
final fun enqueue(kotlin/Function0<kotlin/Unit>) // com.compose.sdl.graphics/NativeReleaseQueue.enqueue|enqueue(kotlin.Function0<kotlin.Unit>){}[0]
}
final object com.compose.sdl.res/AndroidVectorToSvg { // com.compose.sdl.res/AndroidVectorToSvg|null[0]
final fun convert(kotlin/String): kotlin/String // com.compose.sdl.res/AndroidVectorToSvg.convert|convert(kotlin.String){}[0]
}
final object com.compose.sdl.res/Res { // com.compose.sdl.res/Res|null[0]
final fun readBytes(kotlin/String): kotlin/ByteArray? // com.compose.sdl.res/Res.readBytes|readBytes(kotlin.String){}[0]
@@ -2261,11 +2180,9 @@ final val com.compose.sdl.graphics/gradientTileMode // com.compose.sdl.graphics/
final val com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaEncodedImageDecoder$stableprop // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaEncodedImageDecoder$stableprop|#static{}com_compose_sdl_renderer_skia_SkiaEncodedImageDecoder$stableprop[0]
final val com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaImageCache$stableprop // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaImageCache$stableprop|#static{}com_compose_sdl_renderer_skia_SkiaImageCache$stableprop[0]
final val com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop|#static{}com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop[0]
final val com.compose.sdl.res/com_compose_sdl_res_AndroidVectorToSvg$stableprop // com.compose.sdl.res/com_compose_sdl_res_AndroidVectorToSvg$stableprop|#static{}com_compose_sdl_res_AndroidVectorToSvg$stableprop[0]
final val com.compose.sdl.res/com_compose_sdl_res_Res$stableprop // com.compose.sdl.res/com_compose_sdl_res_Res$stableprop|#static{}com_compose_sdl_res_Res$stableprop[0]
final val com.compose.sdl.res/com_compose_sdl_res_Res_drawable$stableprop // com.compose.sdl.res/com_compose_sdl_res_Res_drawable$stableprop|#static{}com_compose_sdl_res_Res_drawable$stableprop[0]
final val com.compose.sdl.res/com_compose_sdl_res_Res_files$stableprop // com.compose.sdl.res/com_compose_sdl_res_Res_files$stableprop|#static{}com_compose_sdl_res_Res_files$stableprop[0]
final val com.compose.sdl.res/com_compose_sdl_res_ResourcePainter$stableprop // com.compose.sdl.res/com_compose_sdl_res_ResourcePainter$stableprop|#static{}com_compose_sdl_res_ResourcePainter$stableprop[0]
final var androidx.compose.ui.graphics/alphaMultiplier // androidx.compose.ui.graphics/alphaMultiplier|@androidx.compose.ui.graphics.Canvas{}alphaMultiplier[0]
final fun (androidx.compose.ui.graphics/Canvas).<get-alphaMultiplier>(): kotlin/Float // androidx.compose.ui.graphics/alphaMultiplier.<get-alphaMultiplier>|<get-alphaMultiplier>@androidx.compose.ui.graphics.Canvas(){}[0]
@@ -2456,14 +2373,11 @@ final fun com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaEncode
final fun com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaImageCache$stableprop_getter(): kotlin/Int // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaImageCache$stableprop_getter|com_compose_sdl_renderer_skia_SkiaImageCache$stableprop_getter(){}[0]
final fun com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop_getter(): kotlin/Int // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop_getter|com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop_getter(){}[0]
final fun com.compose.sdl.renderer.skia/readBackBgra(org.jetbrains.skia/Surface?, kotlin/Int, kotlin/Int): kotlin/Triple<kotlin/Int, kotlin/Int, kotlin/ByteArray>? // com.compose.sdl.renderer.skia/readBackBgra|readBackBgra(org.jetbrains.skia.Surface?;kotlin.Int;kotlin.Int){}[0]
final fun com.compose.sdl.res/com_compose_sdl_res_AndroidVectorToSvg$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_AndroidVectorToSvg$stableprop_getter|com_compose_sdl_res_AndroidVectorToSvg$stableprop_getter(){}[0]
final fun com.compose.sdl.res/com_compose_sdl_res_Res$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_Res$stableprop_getter|com_compose_sdl_res_Res$stableprop_getter(){}[0]
final fun com.compose.sdl.res/com_compose_sdl_res_Res_drawable$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_Res_drawable$stableprop_getter|com_compose_sdl_res_Res_drawable$stableprop_getter(){}[0]
final fun com.compose.sdl.res/com_compose_sdl_res_Res_files$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_Res_files$stableprop_getter|com_compose_sdl_res_Res_files$stableprop_getter(){}[0]
final fun com.compose.sdl.res/com_compose_sdl_res_ResourcePainter$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_ResourcePainter$stableprop_getter|com_compose_sdl_res_ResourcePainter$stableprop_getter(){}[0]
final fun com.compose.sdl.res/painterResource(kotlin/String): androidx.compose.ui.graphics.painter/Painter // com.compose.sdl.res/painterResource|painterResource(kotlin.String){}[0]
final fun com.compose.sdl.res/painterResource(kotlin/String, com.compose.sdl.res/ResourceKind): androidx.compose.ui.graphics.painter/Painter // com.compose.sdl.res/painterResource|painterResource(kotlin.String;com.compose.sdl.res.ResourceKind){}[0]
final fun com.compose.sdl.res/resourceKindForPath(kotlin/String): com.compose.sdl.res/ResourceKind // com.compose.sdl.res/resourceKindForPath|resourceKindForPath(kotlin.String){}[0]
final inline fun (androidx.compose.ui.graphics.drawscope/DrawScope).androidx.compose.ui.graphics.drawscope/clipPath(androidx.compose.ui.graphics/Path, androidx.compose.ui.graphics/ClipOp = ..., kotlin/Function1<androidx.compose.ui.graphics.drawscope/DrawScope, kotlin/Unit>) // androidx.compose.ui.graphics.drawscope/clipPath|clipPath@androidx.compose.ui.graphics.drawscope.DrawScope(androidx.compose.ui.graphics.Path;androidx.compose.ui.graphics.ClipOp;kotlin.Function1<androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit>){}[0]
final inline fun (androidx.compose.ui.graphics.drawscope/DrawScope).androidx.compose.ui.graphics.drawscope/clipRect(kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., androidx.compose.ui.graphics/ClipOp = ..., kotlin/Function1<androidx.compose.ui.graphics.drawscope/DrawScope, kotlin/Unit>) // androidx.compose.ui.graphics.drawscope/clipRect|clipRect@androidx.compose.ui.graphics.drawscope.DrawScope(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;androidx.compose.ui.graphics.ClipOp;kotlin.Function1<androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit>){}[0]
final inline fun (androidx.compose.ui.graphics.drawscope/DrawScope).androidx.compose.ui.graphics.drawscope/draw(androidx.compose.ui.unit/Density, androidx.compose.ui.unit/LayoutDirection, androidx.compose.ui.graphics/Canvas, androidx.compose.ui.geometry/Size, androidx.compose.ui.graphics.layer/GraphicsLayer? = ..., kotlin/Function1<androidx.compose.ui.graphics.drawscope/DrawScope, kotlin/Unit>) // androidx.compose.ui.graphics.drawscope/draw|draw@androidx.compose.ui.graphics.drawscope.DrawScope(androidx.compose.ui.unit.Density;androidx.compose.ui.unit.LayoutDirection;androidx.compose.ui.graphics.Canvas;androidx.compose.ui.geometry.Size;androidx.compose.ui.graphics.layer.GraphicsLayer?;kotlin.Function1<androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit>){}[0]
@@ -16,7 +16,7 @@ import com.compose.sdl.res.ResourceKind
the given rect, and this seam carries no ui-layout (ContentScale) dependency -
keeping :ui-graphics free of :ui.
*/
interface NativePainterCanvas {
internal interface NativePainterCanvas {
fun drawNativePainter(
inResourcePath: String,
inKind: ResourceKind,
@@ -10,7 +10,7 @@ package com.compose.sdl.graphics
carries a `commands: List<PathCommand>` that the SDL3 / Skia renderers walk
directly. No official equivalent, so lives in com.compose.sdl
per FIDELITY.md's relocate rule. */
sealed interface PathCommand {
internal sealed interface PathCommand {
class MoveTo(val x: Float, val y: Float) : PathCommand
class LineTo(val x: Float, val y: Float) : PathCommand
class QuadTo(val cx: Float, val cy: Float, val x: Float, val y: Float) : PathCommand
@@ -21,7 +21,7 @@ import androidx.compose.ui.geometry.RoundRect
`Canvas.restore()` - the backend composites and pops it on the matching
`restore()`.
*/
interface NativeShapeClipCanvas {
internal interface NativeShapeClipCanvas {
// Pushes a rounded-rect clip for subsequent draws until the matching
// Canvas.restore(). [inRoundRect] is in the CURRENT canvas-local coordinate
// space (the same space draw calls use after the enclosing translate).
@@ -16,7 +16,7 @@ package com.compose.sdl.res
Not supported: <group> transforms (paths are flattened, transforms ignored),
gradients, clip-paths, trim-path. This covers the common single-/multi-path
icon case such as exported Material icons. */
object AndroidVectorToSvg {
internal object AndroidVectorToSvg {
// Tag values never contain '>' (pathData is letters/digits/.,- /space) and
// quoted attribute values can't either, so [^>] within a tag is safe.
@@ -18,7 +18,7 @@ enum class ResourceKind {
}
/** Picks a ResourceKind from a file extension. */
fun resourceKindForPath(path: String): ResourceKind {
internal fun resourceKindForPath(path: String): ResourceKind {
val vExt = path.substringAfterLast('.', "").lowercase()
return when (vExt) {
"svg" -> ResourceKind.Svg
@@ -27,17 +27,17 @@ import com.compose.sdl.graphics.NativePainterCanvas
If the Canvas doesn't implement NativePainterCanvas (offscreen layer capture
for graphics-layer effects) the paint is skipped.
*/
class ResourcePainter internal constructor(
internal class ResourcePainter internal constructor(
val resourcePath: String,
val kind: ResourceKind,
) : Painter() {
private var fAlpha: Float = 1f
private var mAlpha: Float = 1f
override val intrinsicSize: Size
get() = currentImageLoader.intrinsicSize(resourcePath, kind)
override fun applyAlpha(alpha: Float): Boolean { fAlpha = alpha; return true }
override fun applyAlpha(alpha: Float): Boolean { mAlpha = alpha; return true }
override fun DrawScope.onDraw() {
drawIntoCanvas { vCanvas ->
@@ -45,7 +45,7 @@ class ResourcePainter internal constructor(
// translate + scaledSize, so we paint the resource FillBounds into the
// full DrawScope size. Alpha is threaded via applyAlpha.
(vCanvas as? NativePainterCanvas)?.drawNativePainter(
resourcePath, kind, 0f, 0f, size.width, size.height, fAlpha,
resourcePath, kind, 0f, 0f, size.width, size.height, mAlpha,
)
}
}
@@ -28,12 +28,12 @@ import kotlinx.atomicfu.locks.synchronized
*/
object NativeReleaseQueue {
private val fLock = SynchronizedObject()
private var fPending = ArrayList<() -> Unit>()
private val mLock = SynchronizedObject()
private var mPending = ArrayList<() -> Unit>()
/** Enqueue a release action. Safe to call from any thread. */
fun enqueue(action: () -> Unit) {
synchronized(fLock) { fPending.add(action) }
synchronized(mLock) { mPending.add(action) }
}
/**
@@ -41,10 +41,10 @@ object NativeReleaseQueue {
* 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()
val vBatch = synchronized(mLock) {
if (mPending.isEmpty()) return 0
val vTaken = mPending
mPending = ArrayList()
vTaken
}
for (vAction in vBatch) {
@@ -38,7 +38,7 @@ class SkiaImageCache {
// (registerMemoryResource - e.g. downloaded PNGs) from growing image memory
// without limit (issue #2). On-screen images stay hot; an evicted one just
// re-decodes on next use.
private val fCache = LinkedHashMap<String, Image?>()
private val mCache = LinkedHashMap<String, Image?>()
// SVG / Android-vector kinds are RESOLUTION-INDEPENDENT: instead of caching one
// intrinsic-size raster and letting drawImageRect upscale it (blurry when an icon
@@ -46,8 +46,8 @@ class SkiaImageCache {
// destination pixel size and cache that raster keyed by "path@WxH". Mirrors
// upstream's size-driven DrawCache for SVGPainter. Intrinsic dims are cached
// separately so the layout pass (intrinsicSize) doesn't force a raster.
private val fSvgRasterCache = LinkedHashMap<String, Image?>()
private val fSvgIntrinsic = HashMap<String, androidx.compose.ui.geometry.Size>()
private val mSvgRasterCache = LinkedHashMap<String, Image?>()
private val mSvgIntrinsic = HashMap<String, androidx.compose.ui.geometry.Size>()
fun intrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size {
if (inKind == ResourceKind.Svg || inKind == ResourceKind.AndroidVector) {
@@ -58,18 +58,18 @@ class SkiaImageCache {
}
private fun get(inPath: String, inKind: ResourceKind): Image? {
if (fCache.containsKey(inPath)) {
if (mCache.containsKey(inPath)) {
// Re-insert to mark most-recently-used (LinkedHashMap keeps first =
// least-recently-used for eviction below).
val vExisting = fCache.remove(inPath)
fCache[inPath] = vExisting
val vExisting = mCache.remove(inPath)
mCache[inPath] = vExisting
return vExisting
}
val vImage = decode(inPath, inKind)
fCache[inPath] = vImage
if (fCache.size > MAX_CACHED_IMAGES) {
val vEldest = fCache.keys.firstOrNull()
if (vEldest != null) fCache.remove(vEldest)?.close()
mCache[inPath] = vImage
if (mCache.size > MAX_CACHED_IMAGES) {
val vEldest = mCache.keys.firstOrNull()
if (vEldest != null) mCache.remove(vEldest)?.close()
}
return vImage
}
@@ -136,7 +136,7 @@ class SkiaImageCache {
/** Intrinsic (viewport) size of the vector, parsed once and cached. */
private fun svgIntrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size {
fSvgIntrinsic[inPath]?.let { return it }
mSvgIntrinsic[inPath]?.let { return it }
val vBytes = svgBytes(inPath, inKind) ?: return androidx.compose.ui.geometry.Size.Unspecified
val vSize = runCatching {
val vDom = SVGDOM(Data.makeFromBytes(vBytes))
@@ -146,7 +146,7 @@ class SkiaImageCache {
vDom.close()
androidx.compose.ui.geometry.Size(vW, vH)
}.getOrDefault(androidx.compose.ui.geometry.Size.Unspecified)
fSvgIntrinsic[inPath] = vSize
mSvgIntrinsic[inPath] = vSize
return vSize
}
@@ -157,9 +157,9 @@ class SkiaImageCache {
val vWpx = inW.roundToInt().coerceAtLeast(1)
val vHpx = inH.roundToInt().coerceAtLeast(1)
val vKey = "$inPath@${vWpx}x$vHpx"
val vImg = if (fSvgRasterCache.containsKey(vKey)) {
val vExisting = fSvgRasterCache.remove(vKey)
fSvgRasterCache[vKey] = vExisting
val vImg = if (mSvgRasterCache.containsKey(vKey)) {
val vExisting = mSvgRasterCache.remove(vKey)
mSvgRasterCache[vKey] = vExisting
vExisting
} else {
val vIntrinsic = svgIntrinsicSize(inPath, inKind)
@@ -167,9 +167,9 @@ class SkiaImageCache {
val vRaster = if (vBytes != null && vIntrinsic.isSpecified) {
rasterizeSvgAt(vBytes, vIntrinsic.width, vIntrinsic.height, vWpx, vHpx)
} else null
fSvgRasterCache[vKey] = vRaster
if (fSvgRasterCache.size > MAX_CACHED_IMAGES) {
fSvgRasterCache.keys.firstOrNull()?.let { fSvgRasterCache.remove(it)?.close() }
mSvgRasterCache[vKey] = vRaster
if (mSvgRasterCache.size > MAX_CACHED_IMAGES) {
mSvgRasterCache.keys.firstOrNull()?.let { mSvgRasterCache.remove(it)?.close() }
}
vRaster
} ?: return
@@ -183,11 +183,11 @@ class SkiaImageCache {
}
fun destroy() {
for (vImg in fCache.values) vImg?.close()
fCache.clear()
for (vImg in fSvgRasterCache.values) vImg?.close()
fSvgRasterCache.clear()
fSvgIntrinsic.clear()
for (vImg in mCache.values) vImg?.close()
mCache.clear()
for (vImg in mSvgRasterCache.values) vImg?.close()
mSvgRasterCache.clear()
mSvgIntrinsic.clear()
}
}
@@ -18,35 +18,35 @@ package com.compose.sdl.icons
the renderer where to find the bytes. */
object IconFont {
private val fFonts = mutableMapOf<String, ByteArray>()
private val mFonts = mutableMapOf<String, ByteArray>()
// Families that render as single variable-axis glyphs (Material Symbols),
// vs ordinary text fonts - icon families are drawn one codepoint at a time,
// while text families (e.g. a bundled monospace) go through normal
// full-string rendering.
private val fIconFamilies = mutableSetOf<String>()
private val mIconFamilies = mutableSetOf<String>()
/** Register a TEXT font family's bytes (rendered as full strings; e.g. a
bundled monospace). Idempotent - a second call with the same family
replaces the previous bytes. */
fun register(inFamily: String, inBytes: ByteArray) {
fFonts[inFamily] = inBytes
mFonts[inFamily] = inBytes
}
/** Register an ICON font family (Material Symbols et al.) - variable-axis.
Same byte store as register(), but also flags the family as an icon
font. */
fun registerIcon(inFamily: String, inBytes: ByteArray) {
fFonts[inFamily] = inBytes
fIconFamilies += inFamily
mFonts[inFamily] = inBytes
mIconFamilies += inFamily
}
/** Bytes for a registered family, or null if not registered. Renderers
fall back to the default font when this returns null. */
fun bytesFor(inFamily: String): ByteArray? = fFonts[inFamily]
fun bytesFor(inFamily: String): ByteArray? = mFonts[inFamily]
/** True only for families registered via registerIcon() - icon fonts that
need single-glyph (variable-axis) rendering, not text fonts. */
fun isIconFamily(inFamily: String): Boolean = inFamily in fIconFamilies
fun isIconFamily(inFamily: String): Boolean = inFamily in mIconFamilies
val families: Set<String> get() = fFonts.keys
val families: Set<String> get() = mFonts.keys
}
+76 -113
View File
@@ -1370,17 +1370,6 @@ abstract interface androidx.compose.ui/UiMediaScope { // androidx.compose.ui/UiM
}
}
abstract interface com.compose.sdl.renderer.skia/SkiaBridge { // com.compose.sdl.renderer.skia/SkiaBridge|null[0]
abstract val canvas // com.compose.sdl.renderer.skia/SkiaBridge.canvas|{}canvas[0]
abstract fun <get-canvas>(): org.jetbrains.skia/Canvas // com.compose.sdl.renderer.skia/SkiaBridge.canvas.<get-canvas>|<get-canvas>(){}[0]
abstract fun destroy() // com.compose.sdl.renderer.skia/SkiaBridge.destroy|destroy(){}[0]
abstract fun ensureSize(kotlin/Int, kotlin/Int): kotlin/Boolean // com.compose.sdl.renderer.skia/SkiaBridge.ensureSize|ensureSize(kotlin.Int;kotlin.Int){}[0]
abstract fun present() // com.compose.sdl.renderer.skia/SkiaBridge.present|present(){}[0]
abstract fun snapshot(): org.jetbrains.skia/Image? // com.compose.sdl.renderer.skia/SkiaBridge.snapshot|snapshot(){}[0]
abstract fun snapshotBgra(): kotlin/Triple<kotlin/Int, kotlin/Int, kotlin/ByteArray>? // com.compose.sdl.renderer.skia/SkiaBridge.snapshotBgra|snapshotBgra(){}[0]
}
abstract interface com.compose.sdl/ComposeWindowScope { // com.compose.sdl/ComposeWindowScope|null[0]
abstract val window // com.compose.sdl/ComposeWindowScope.window|{}window[0]
abstract fun <get-window>(): com.compose.sdl/ComposeNativeWindow // com.compose.sdl/ComposeWindowScope.window.<get-window>|<get-window>(){}[0]
@@ -1392,10 +1381,10 @@ abstract interface com.compose.sdl/RenderBackend { // com.compose.sdl/RenderBack
abstract fun beginFrame(kotlin/Float) // com.compose.sdl/RenderBackend.beginFrame|beginFrame(kotlin.Float){}[0]
abstract fun destroy() // com.compose.sdl/RenderBackend.destroy|destroy(){}[0]
abstract fun drawRoot(kotlin/Function1<androidx.compose.ui.graphics/Canvas, kotlin/Unit>) // com.compose.sdl/RenderBackend.drawRoot|drawRoot(kotlin.Function1<androidx.compose.ui.graphics.Canvas,kotlin.Unit>){}[0]
abstract fun endFrame() // com.compose.sdl/RenderBackend.endFrame|endFrame(){}[0]
abstract fun ensureSize(kotlin/Int, kotlin/Int): kotlin/Boolean // com.compose.sdl/RenderBackend.ensureSize|ensureSize(kotlin.Int;kotlin.Int){}[0]
abstract fun snapshotBgra(): kotlin/Triple<kotlin/Int, kotlin/Int, kotlin/ByteArray>? // com.compose.sdl/RenderBackend.snapshotBgra|snapshotBgra(){}[0]
open fun drawRoot(kotlin/Function1<androidx.compose.ui.graphics/Canvas, kotlin/Unit>) // com.compose.sdl/RenderBackend.drawRoot|drawRoot(kotlin.Function1<androidx.compose.ui.graphics.Canvas,kotlin.Unit>){}[0]
}
sealed interface androidx.compose.ui.autofill/ContentDataType { // androidx.compose.ui.autofill/ContentDataType|null[0]
@@ -2788,23 +2777,6 @@ final class androidx.compose.ui/CombinedModifier : androidx.compose.ui/Modifier
final fun toString(): kotlin/String // androidx.compose.ui/CombinedModifier.toString|toString(){}[0]
}
final class com.compose.sdl.input/LegacyPointerEvent { // com.compose.sdl.input/LegacyPointerEvent|null[0]
constructor <init>(kotlin/Float, kotlin/Float, androidx.compose.ui.input.pointer/PointerEventType, androidx.compose.ui.input.pointer/PointerButton = ...) // com.compose.sdl.input/LegacyPointerEvent.<init>|<init>(kotlin.Float;kotlin.Float;androidx.compose.ui.input.pointer.PointerEventType;androidx.compose.ui.input.pointer.PointerButton){}[0]
final val button // com.compose.sdl.input/LegacyPointerEvent.button|{}button[0]
final fun <get-button>(): androidx.compose.ui.input.pointer/PointerButton // com.compose.sdl.input/LegacyPointerEvent.button.<get-button>|<get-button>(){}[0]
final val type // com.compose.sdl.input/LegacyPointerEvent.type|{}type[0]
final fun <get-type>(): androidx.compose.ui.input.pointer/PointerEventType // com.compose.sdl.input/LegacyPointerEvent.type.<get-type>|<get-type>(){}[0]
final val x // com.compose.sdl.input/LegacyPointerEvent.x|{}x[0]
final fun <get-x>(): kotlin/Float // com.compose.sdl.input/LegacyPointerEvent.x.<get-x>|<get-x>(){}[0]
final val y // com.compose.sdl.input/LegacyPointerEvent.y|{}y[0]
final fun <get-y>(): kotlin/Float // com.compose.sdl.input/LegacyPointerEvent.y.<get-y>|<get-y>(){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl.input/LegacyPointerEvent.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // com.compose.sdl.input/LegacyPointerEvent.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // com.compose.sdl.input/LegacyPointerEvent.toString|toString(){}[0]
}
final class com.compose.sdl.node/ComposeRootHost { // com.compose.sdl.node/ComposeRootHost|null[0]
constructor <init>(kotlin/Float = ...) // com.compose.sdl.node/ComposeRootHost.<init>|<init>(kotlin.Float){}[0]
@@ -2850,31 +2822,6 @@ final class com.compose.sdl.node/ComposeRootHost { // com.compose.sdl.node/Compo
final fun setWindowFocused(kotlin/Boolean) // com.compose.sdl.node/ComposeRootHost.setWindowFocused|setWindowFocused(kotlin.Boolean){}[0]
}
final class com.compose.sdl.renderer.skia/SkiaSurfaceBridge : com.compose.sdl.renderer.skia/SkiaBridge { // com.compose.sdl.renderer.skia/SkiaSurfaceBridge|null[0]
constructor <init>(com.compose.sdl/SDL3Backend) // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.<init>|<init>(com.compose.sdl.SDL3Backend){}[0]
final val canvas // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.canvas|{}canvas[0]
final fun <get-canvas>(): org.jetbrains.skia/Canvas // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.canvas.<get-canvas>|<get-canvas>(){}[0]
final val height // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.height|{}height[0]
final fun <get-height>(): kotlin/Int // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.height.<get-height>|<get-height>(){}[0]
final val width // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.width|{}width[0]
final fun <get-width>(): kotlin/Int // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.width.<get-width>|<get-width>(){}[0]
final fun destroy() // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.destroy|destroy(){}[0]
final fun ensureSize(kotlin/Int, kotlin/Int): kotlin/Boolean // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.ensureSize|ensureSize(kotlin.Int;kotlin.Int){}[0]
final fun present() // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.present|present(){}[0]
final fun snapshot(): org.jetbrains.skia/Image? // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.snapshot|snapshot(){}[0]
final fun snapshotBgra(): kotlin/Triple<kotlin/Int, kotlin/Int, kotlin/ByteArray>? // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.snapshotBgra|snapshotBgra(){}[0]
}
final class com.compose.sdl.window/PopupExitHandle { // com.compose.sdl.window/PopupExitHandle|null[0]
final val isExiting // com.compose.sdl.window/PopupExitHandle.isExiting|{}isExiting[0]
final fun <get-isExiting>(): androidx.compose.runtime/State<kotlin/Boolean> // com.compose.sdl.window/PopupExitHandle.isExiting.<get-isExiting>|<get-isExiting>(){}[0]
final fun enableExitTransition() // com.compose.sdl.window/PopupExitHandle.enableExitTransition|enableExitTransition(){}[0]
final fun finish() // com.compose.sdl.window/PopupExitHandle.finish|finish(){}[0]
}
final class com.compose.sdl.window/PopupHostState { // com.compose.sdl.window/PopupHostState|null[0]
final fun notifyOutsidePress(kotlin/Int, kotlin/Int) // com.compose.sdl.window/PopupHostState.notifyOutsidePress|notifyOutsidePress(kotlin.Int;kotlin.Int){}[0]
}
@@ -2888,14 +2835,24 @@ final class com.compose.sdl/ComposeNativeWindow { // com.compose.sdl/ComposeNati
final fun <get-gpuMode>(): com.compose.sdl/GpuMode // com.compose.sdl/ComposeNativeWindow.gpuMode.<get-gpuMode>|<get-gpuMode>(){}[0]
final val height // com.compose.sdl/ComposeNativeWindow.height|{}height[0]
final fun <get-height>(): kotlin/Int // com.compose.sdl/ComposeNativeWindow.height.<get-height>|<get-height>(){}[0]
final val isAlwaysOnTop // com.compose.sdl/ComposeNativeWindow.isAlwaysOnTop|{}isAlwaysOnTop[0]
final fun <get-isAlwaysOnTop>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isAlwaysOnTop.<get-isAlwaysOnTop>|<get-isAlwaysOnTop>(){}[0]
final val isCloseRequested // com.compose.sdl/ComposeNativeWindow.isCloseRequested|{}isCloseRequested[0]
final fun <get-isCloseRequested>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isCloseRequested.<get-isCloseRequested>|<get-isCloseRequested>(){}[0]
final val isEnabled // com.compose.sdl/ComposeNativeWindow.isEnabled|{}isEnabled[0]
final fun <get-isEnabled>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isEnabled.<get-isEnabled>|<get-isEnabled>(){}[0]
final val isFocusable // com.compose.sdl/ComposeNativeWindow.isFocusable|{}isFocusable[0]
final fun <get-isFocusable>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isFocusable.<get-isFocusable>|<get-isFocusable>(){}[0]
final val isFullscreen // com.compose.sdl/ComposeNativeWindow.isFullscreen|{}isFullscreen[0]
final fun <get-isFullscreen>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isFullscreen.<get-isFullscreen>|<get-isFullscreen>(){}[0]
final val isMaximized // com.compose.sdl/ComposeNativeWindow.isMaximized|{}isMaximized[0]
final fun <get-isMaximized>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isMaximized.<get-isMaximized>|<get-isMaximized>(){}[0]
final val isMinimized // com.compose.sdl/ComposeNativeWindow.isMinimized|{}isMinimized[0]
final fun <get-isMinimized>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isMinimized.<get-isMinimized>|<get-isMinimized>(){}[0]
final val isResizable // com.compose.sdl/ComposeNativeWindow.isResizable|{}isResizable[0]
final fun <get-isResizable>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isResizable.<get-isResizable>|<get-isResizable>(){}[0]
final val isUndecorated // com.compose.sdl/ComposeNativeWindow.isUndecorated|{}isUndecorated[0]
final fun <get-isUndecorated>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isUndecorated.<get-isUndecorated>|<get-isUndecorated>(){}[0]
final val pixelDensity // com.compose.sdl/ComposeNativeWindow.pixelDensity|{}pixelDensity[0]
final fun <get-pixelDensity>(): kotlin/Float // com.compose.sdl/ComposeNativeWindow.pixelDensity.<get-pixelDensity>|<get-pixelDensity>(){}[0]
final val pixelHeight // com.compose.sdl/ComposeNativeWindow.pixelHeight|{}pixelHeight[0]
@@ -2915,19 +2872,48 @@ final class com.compose.sdl/ComposeNativeWindow { // com.compose.sdl/ComposeNati
final fun minimize() // com.compose.sdl/ComposeNativeWindow.minimize|minimize(){}[0]
final fun onResized() // com.compose.sdl/ComposeNativeWindow.onResized|onResized(){}[0]
final fun raise() // com.compose.sdl/ComposeNativeWindow.raise|raise(){}[0]
final fun rawSdlHandles(): com.compose.sdl/RawSdlHandles // com.compose.sdl/ComposeNativeWindow.rawSdlHandles|rawSdlHandles(){}[0]
final fun requestCloseFromUser(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.requestCloseFromUser|requestCloseFromUser(){}[0]
final fun restore() // com.compose.sdl/ComposeNativeWindow.restore|restore(){}[0]
final fun setAlwaysOnTop(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setAlwaysOnTop|setAlwaysOnTop(kotlin.Boolean){}[0]
final fun setEnabled(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setEnabled|setEnabled(kotlin.Boolean){}[0]
final fun setFocusable(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setFocusable|setFocusable(kotlin.Boolean){}[0]
final fun setFullscreen(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setFullscreen|setFullscreen(kotlin.Boolean){}[0]
final fun setOnCloseRequest(kotlin/Function0<kotlin/Boolean>?) // com.compose.sdl/ComposeNativeWindow.setOnCloseRequest|setOnCloseRequest(kotlin.Function0<kotlin.Boolean>?){}[0]
final fun setOnKeyShortcut(kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean>?) // com.compose.sdl/ComposeNativeWindow.setOnKeyShortcut|setOnKeyShortcut(kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>?){}[0]
final fun setOpacity(kotlin/Float) // com.compose.sdl/ComposeNativeWindow.setOpacity|setOpacity(kotlin.Float){}[0]
final fun setResizable(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setResizable|setResizable(kotlin.Boolean){}[0]
final fun setSize(kotlin/Int, kotlin/Int) // com.compose.sdl/ComposeNativeWindow.setSize|setSize(kotlin.Int;kotlin.Int){}[0]
final fun setTitle(kotlin/String) // com.compose.sdl/ComposeNativeWindow.setTitle|setTitle(kotlin.String){}[0]
final fun setUndecorated(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setUndecorated|setUndecorated(kotlin.Boolean){}[0]
final fun toggleFullscreen() // com.compose.sdl/ComposeNativeWindow.toggleFullscreen|toggleFullscreen(){}[0]
final fun updateFps(kotlin/Int) // com.compose.sdl/ComposeNativeWindow.updateFps|updateFps(kotlin.Int){}[0]
}
final class com.compose.sdl/RawSdlHandles { // com.compose.sdl/RawSdlHandles|null[0]
constructor <init>(kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>?, kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>?, kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>?, kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>?) // com.compose.sdl/RawSdlHandles.<init>|<init>(kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?){}[0]
final val glContext // com.compose.sdl/RawSdlHandles.glContext|{}glContext[0]
final fun <get-glContext>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.glContext.<get-glContext>|<get-glContext>(){}[0]
final val metalView // com.compose.sdl/RawSdlHandles.metalView|{}metalView[0]
final fun <get-metalView>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.metalView.<get-metalView>|<get-metalView>(){}[0]
final val renderer // com.compose.sdl/RawSdlHandles.renderer|{}renderer[0]
final fun <get-renderer>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.renderer.<get-renderer>|<get-renderer>(){}[0]
final val window // com.compose.sdl/RawSdlHandles.window|{}window[0]
final fun <get-window>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.window.<get-window>|<get-window>(){}[0]
final fun component1(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component1|component1(){}[0]
final fun component2(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component2|component2(){}[0]
final fun component3(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component3|component3(){}[0]
final fun component4(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component4|component4(){}[0]
final fun copy(kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? = ..., kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? = ..., kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? = ..., kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? = ...): com.compose.sdl/RawSdlHandles // com.compose.sdl/RawSdlHandles.copy|copy(kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl/RawSdlHandles.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // com.compose.sdl/RawSdlHandles.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // com.compose.sdl/RawSdlHandles.toString|toString(){}[0]
}
final class com.compose.sdl/SDL3Backend { // com.compose.sdl/SDL3Backend|null[0]
constructor <init>(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., kotlin.collections/List<kotlin/String> = ..., kotlin.collections/List<kotlin/String> = ...) // com.compose.sdl/SDL3Backend.<init>|<init>(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;kotlin.collections.List<kotlin.String>;kotlin.collections.List<kotlin.String>){}[0]
constructor <init>(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., kotlin.collections/List<kotlin/String> = ..., kotlin.collections/List<kotlin/String> = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // com.compose.sdl/SDL3Backend.<init>|<init>(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;kotlin.collections.List<kotlin.String>;kotlin.collections.List<kotlin.String>;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0]
final val gpuMode // com.compose.sdl/SDL3Backend.gpuMode|{}gpuMode[0]
final fun <get-gpuMode>(): com.compose.sdl/GpuMode // com.compose.sdl/SDL3Backend.gpuMode.<get-gpuMode>|<get-gpuMode>(){}[0]
@@ -4161,16 +4147,25 @@ sealed class com.compose.sdl/AppEvent { // com.compose.sdl/AppEvent|null[0]
}
final class Pointer : com.compose.sdl/AppEvent { // com.compose.sdl/AppEvent.Pointer|null[0]
constructor <init>(com.compose.sdl.input/LegacyPointerEvent, kotlin/UInt = ...) // com.compose.sdl/AppEvent.Pointer.<init>|<init>(com.compose.sdl.input.LegacyPointerEvent;kotlin.UInt){}[0]
constructor <init>(kotlin/Float, kotlin/Float, androidx.compose.ui.input.pointer/PointerEventType, androidx.compose.ui.input.pointer/PointerButton = ..., kotlin/UInt = ...) // com.compose.sdl/AppEvent.Pointer.<init>|<init>(kotlin.Float;kotlin.Float;androidx.compose.ui.input.pointer.PointerEventType;androidx.compose.ui.input.pointer.PointerButton;kotlin.UInt){}[0]
final val event // com.compose.sdl/AppEvent.Pointer.event|{}event[0]
final fun <get-event>(): com.compose.sdl.input/LegacyPointerEvent // com.compose.sdl/AppEvent.Pointer.event.<get-event>|<get-event>(){}[0]
final val button // com.compose.sdl/AppEvent.Pointer.button|{}button[0]
final fun <get-button>(): androidx.compose.ui.input.pointer/PointerButton // com.compose.sdl/AppEvent.Pointer.button.<get-button>|<get-button>(){}[0]
final val type // com.compose.sdl/AppEvent.Pointer.type|{}type[0]
final fun <get-type>(): androidx.compose.ui.input.pointer/PointerEventType // com.compose.sdl/AppEvent.Pointer.type.<get-type>|<get-type>(){}[0]
final val windowId // com.compose.sdl/AppEvent.Pointer.windowId|{}windowId[0]
final fun <get-windowId>(): kotlin/UInt // com.compose.sdl/AppEvent.Pointer.windowId.<get-windowId>|<get-windowId>(){}[0]
final val x // com.compose.sdl/AppEvent.Pointer.x|{}x[0]
final fun <get-x>(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.x.<get-x>|<get-x>(){}[0]
final val y // com.compose.sdl/AppEvent.Pointer.y|{}y[0]
final fun <get-y>(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.y.<get-y>|<get-y>(){}[0]
final fun component1(): com.compose.sdl.input/LegacyPointerEvent // com.compose.sdl/AppEvent.Pointer.component1|component1(){}[0]
final fun component2(): kotlin/UInt // com.compose.sdl/AppEvent.Pointer.component2|component2(){}[0]
final fun copy(com.compose.sdl.input/LegacyPointerEvent = ..., kotlin/UInt = ...): com.compose.sdl/AppEvent.Pointer // com.compose.sdl/AppEvent.Pointer.copy|copy(com.compose.sdl.input.LegacyPointerEvent;kotlin.UInt){}[0]
final fun component1(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.component1|component1(){}[0]
final fun component2(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.component2|component2(){}[0]
final fun component3(): androidx.compose.ui.input.pointer/PointerEventType // com.compose.sdl/AppEvent.Pointer.component3|component3(){}[0]
final fun component4(): androidx.compose.ui.input.pointer/PointerButton // com.compose.sdl/AppEvent.Pointer.component4|component4(){}[0]
final fun component5(): kotlin/UInt // com.compose.sdl/AppEvent.Pointer.component5|component5(){}[0]
final fun copy(kotlin/Float = ..., kotlin/Float = ..., androidx.compose.ui.input.pointer/PointerEventType = ..., androidx.compose.ui.input.pointer/PointerButton = ..., kotlin/UInt = ...): com.compose.sdl/AppEvent.Pointer // com.compose.sdl/AppEvent.Pointer.copy|copy(kotlin.Float;kotlin.Float;androidx.compose.ui.input.pointer.PointerEventType;androidx.compose.ui.input.pointer.PointerButton;kotlin.UInt){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl/AppEvent.Pointer.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // com.compose.sdl/AppEvent.Pointer.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // com.compose.sdl/AppEvent.Pointer.toString|toString(){}[0]
@@ -4293,22 +4288,23 @@ sealed class com.compose.sdl/AppEvent { // com.compose.sdl/AppEvent|null[0]
}
sealed class com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode|null[0]
sealed class Skia : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Skia|null[0]
final object Metal : com.compose.sdl/GpuMode.Skia { // com.compose.sdl/GpuMode.Skia.Metal|null[0]
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Skia.Metal.toString|toString(){}[0]
}
final object OpenGL : com.compose.sdl/GpuMode.Skia { // com.compose.sdl/GpuMode.Skia.OpenGL|null[0]
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Skia.OpenGL.toString|toString(){}[0]
}
}
final val isGpuAccelerated // com.compose.sdl/GpuMode.isGpuAccelerated|{}isGpuAccelerated[0]
final fun <get-isGpuAccelerated>(): kotlin/Boolean // com.compose.sdl/GpuMode.isGpuAccelerated.<get-isGpuAccelerated>|<get-isGpuAccelerated>(){}[0]
final object Auto : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Auto|null[0]
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Auto.toString|toString(){}[0]
}
final object Software : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Software|null[0]
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Software.toString|toString(){}[0]
final object CpuRaster : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.CpuRaster|null[0]
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.CpuRaster.toString|toString(){}[0]
}
final object Metal : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Metal|null[0]
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Metal.toString|toString(){}[0]
}
final object OpenGL : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.OpenGL|null[0]
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.OpenGL.toString|toString(){}[0]
}
}
@@ -4520,27 +4516,6 @@ final object androidx.compose.ui/ComposeUiFlags { // androidx.compose.ui/Compose
final fun <set-isViewFocusFixEnabled>(kotlin/Boolean) // androidx.compose.ui/ComposeUiFlags.isViewFocusFixEnabled.<set-isViewFocusFixEnabled>|<set-isViewFocusFixEnabled>(kotlin.Boolean){}[0]
}
final object com.compose.sdl.platform/DefaultViewConfiguration : androidx.compose.ui.platform/ViewConfiguration { // com.compose.sdl.platform/DefaultViewConfiguration|null[0]
final val doubleTapMinTimeMillis // com.compose.sdl.platform/DefaultViewConfiguration.doubleTapMinTimeMillis|{}doubleTapMinTimeMillis[0]
final fun <get-doubleTapMinTimeMillis>(): kotlin/Long // com.compose.sdl.platform/DefaultViewConfiguration.doubleTapMinTimeMillis.<get-doubleTapMinTimeMillis>|<get-doubleTapMinTimeMillis>(){}[0]
final val doubleTapTimeoutMillis // com.compose.sdl.platform/DefaultViewConfiguration.doubleTapTimeoutMillis|{}doubleTapTimeoutMillis[0]
final fun <get-doubleTapTimeoutMillis>(): kotlin/Long // com.compose.sdl.platform/DefaultViewConfiguration.doubleTapTimeoutMillis.<get-doubleTapTimeoutMillis>|<get-doubleTapTimeoutMillis>(){}[0]
final val handwritingGestureLineMargin // com.compose.sdl.platform/DefaultViewConfiguration.handwritingGestureLineMargin|{}handwritingGestureLineMargin[0]
final fun <get-handwritingGestureLineMargin>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.handwritingGestureLineMargin.<get-handwritingGestureLineMargin>|<get-handwritingGestureLineMargin>(){}[0]
final val handwritingSlop // com.compose.sdl.platform/DefaultViewConfiguration.handwritingSlop|{}handwritingSlop[0]
final fun <get-handwritingSlop>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.handwritingSlop.<get-handwritingSlop>|<get-handwritingSlop>(){}[0]
final val longPressTimeoutMillis // com.compose.sdl.platform/DefaultViewConfiguration.longPressTimeoutMillis|{}longPressTimeoutMillis[0]
final fun <get-longPressTimeoutMillis>(): kotlin/Long // com.compose.sdl.platform/DefaultViewConfiguration.longPressTimeoutMillis.<get-longPressTimeoutMillis>|<get-longPressTimeoutMillis>(){}[0]
final val maximumFlingVelocity // com.compose.sdl.platform/DefaultViewConfiguration.maximumFlingVelocity|{}maximumFlingVelocity[0]
final fun <get-maximumFlingVelocity>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.maximumFlingVelocity.<get-maximumFlingVelocity>|<get-maximumFlingVelocity>(){}[0]
final val minimumFlingVelocity // com.compose.sdl.platform/DefaultViewConfiguration.minimumFlingVelocity|{}minimumFlingVelocity[0]
final fun <get-minimumFlingVelocity>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.minimumFlingVelocity.<get-minimumFlingVelocity>|<get-minimumFlingVelocity>(){}[0]
final val minimumTouchTargetSize // com.compose.sdl.platform/DefaultViewConfiguration.minimumTouchTargetSize|{}minimumTouchTargetSize[0]
final fun <get-minimumTouchTargetSize>(): androidx.compose.ui.unit/DpSize // com.compose.sdl.platform/DefaultViewConfiguration.minimumTouchTargetSize.<get-minimumTouchTargetSize>|<get-minimumTouchTargetSize>(){}[0]
final val touchSlop // com.compose.sdl.platform/DefaultViewConfiguration.touchSlop|{}touchSlop[0]
final fun <get-touchSlop>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.touchSlop.<get-touchSlop>|<get-touchSlop>(){}[0]
}
final object com.compose.sdl.text.input/ImeBridge { // com.compose.sdl.text.input/ImeBridge|null[0]
final var onSessionActiveChange // com.compose.sdl.text.input/ImeBridge.onSessionActiveChange|{}onSessionActiveChange[0]
final fun <get-onSessionActiveChange>(): kotlin/Function1<kotlin/Boolean, kotlin/Unit>? // com.compose.sdl.text.input/ImeBridge.onSessionActiveChange.<get-onSessionActiveChange>|<get-onSessionActiveChange>(){}[0]
@@ -4854,7 +4829,6 @@ final val androidx.compose.ui/androidx_compose_ui_BiasAlignment_Vertical$stablep
final val androidx.compose.ui/androidx_compose_ui_CombinedModifier$stableprop // androidx.compose.ui/androidx_compose_ui_CombinedModifier$stableprop|#static{}androidx_compose_ui_CombinedModifier$stableprop[0]
final val androidx.compose.ui/androidx_compose_ui_ComposeUiFlags$stableprop // androidx.compose.ui/androidx_compose_ui_ComposeUiFlags$stableprop|#static{}androidx_compose_ui_ComposeUiFlags$stableprop[0]
final val androidx.compose.ui/androidx_compose_ui_Modifier_Node$stableprop // androidx.compose.ui/androidx_compose_ui_Modifier_Node$stableprop|#static{}androidx_compose_ui_Modifier_Node$stableprop[0]
final val com.compose.sdl.input/com_compose_sdl_input_LegacyPointerEvent$stableprop // com.compose.sdl.input/com_compose_sdl_input_LegacyPointerEvent$stableprop|#static{}com_compose_sdl_input_LegacyPointerEvent$stableprop[0]
final val com.compose.sdl.layout/intOffset // com.compose.sdl.layout/intOffset|@androidx.compose.ui.layout.LayoutCoordinates{}intOffset[0]
final fun (androidx.compose.ui.layout/LayoutCoordinates).<get-intOffset>(): androidx.compose.ui.unit/IntOffset // com.compose.sdl.layout/intOffset.<get-intOffset>|<get-intOffset>@androidx.compose.ui.layout.LayoutCoordinates(){}[0]
final val com.compose.sdl.layout/x // com.compose.sdl.layout/x|@androidx.compose.ui.layout.LayoutCoordinates{}x[0]
@@ -4862,14 +4836,9 @@ final val com.compose.sdl.layout/x // com.compose.sdl.layout/x|@androidx.compose
final val com.compose.sdl.layout/y // com.compose.sdl.layout/y|@androidx.compose.ui.layout.LayoutCoordinates{}y[0]
final fun (androidx.compose.ui.layout/LayoutCoordinates).<get-y>(): kotlin/Int // com.compose.sdl.layout/y.<get-y>|<get-y>@androidx.compose.ui.layout.LayoutCoordinates(){}[0]
final val com.compose.sdl.node/com_compose_sdl_node_ComposeRootHost$stableprop // com.compose.sdl.node/com_compose_sdl_node_ComposeRootHost$stableprop|#static{}com_compose_sdl_node_ComposeRootHost$stableprop[0]
final val com.compose.sdl.platform/com_compose_sdl_platform_DefaultViewConfiguration$stableprop // com.compose.sdl.platform/com_compose_sdl_platform_DefaultViewConfiguration$stableprop|#static{}com_compose_sdl_platform_DefaultViewConfiguration$stableprop[0]
final val com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop|#static{}com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop[0]
final val com.compose.sdl.text.input/com_compose_sdl_text_input_ImeBridge$stableprop // com.compose.sdl.text.input/com_compose_sdl_text_input_ImeBridge$stableprop|#static{}com_compose_sdl_text_input_ImeBridge$stableprop[0]
final val com.compose.sdl.window/LocalPopupExitHandle // com.compose.sdl.window/LocalPopupExitHandle|{}LocalPopupExitHandle[0]
final fun <get-LocalPopupExitHandle>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl.window/PopupExitHandle?> // com.compose.sdl.window/LocalPopupExitHandle.<get-LocalPopupExitHandle>|<get-LocalPopupExitHandle>(){}[0]
final val com.compose.sdl.window/LocalPopupHost // com.compose.sdl.window/LocalPopupHost|{}LocalPopupHost[0]
final fun <get-LocalPopupHost>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl.window/PopupHostState> // com.compose.sdl.window/LocalPopupHost.<get-LocalPopupHost>|<get-LocalPopupHost>(){}[0]
final val com.compose.sdl.window/com_compose_sdl_window_PopupExitHandle$stableprop // com.compose.sdl.window/com_compose_sdl_window_PopupExitHandle$stableprop|#static{}com_compose_sdl_window_PopupExitHandle$stableprop[0]
final val com.compose.sdl.window/com_compose_sdl_window_PopupHostState$stableprop // com.compose.sdl.window/com_compose_sdl_window_PopupHostState$stableprop|#static{}com_compose_sdl_window_PopupHostState$stableprop[0]
final val com.compose.sdl/LocalComposeNativeWindow // com.compose.sdl/LocalComposeNativeWindow|{}LocalComposeNativeWindow[0]
final fun <get-LocalComposeNativeWindow>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl/ComposeNativeWindow> // com.compose.sdl/LocalComposeNativeWindow.<get-LocalComposeNativeWindow>|<get-LocalComposeNativeWindow>(){}[0]
@@ -4890,10 +4859,10 @@ final val com.compose.sdl/com_compose_sdl_AppEvent_WindowResized$stableprop // c
final val com.compose.sdl/com_compose_sdl_ComposeNativeWindow$stableprop // com.compose.sdl/com_compose_sdl_ComposeNativeWindow$stableprop|#static{}com_compose_sdl_ComposeNativeWindow$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode$stableprop // com.compose.sdl/com_compose_sdl_GpuMode$stableprop|#static{}com_compose_sdl_GpuMode$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode_Auto$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Auto$stableprop|#static{}com_compose_sdl_GpuMode_Auto$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode_Skia$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Skia$stableprop|#static{}com_compose_sdl_GpuMode_Skia$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode_Skia_Metal$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Skia_Metal$stableprop|#static{}com_compose_sdl_GpuMode_Skia_Metal$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode_Skia_OpenGL$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Skia_OpenGL$stableprop|#static{}com_compose_sdl_GpuMode_Skia_OpenGL$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode_Software$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Software$stableprop|#static{}com_compose_sdl_GpuMode_Software$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode_CpuRaster$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_CpuRaster$stableprop|#static{}com_compose_sdl_GpuMode_CpuRaster$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode_Metal$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Metal$stableprop|#static{}com_compose_sdl_GpuMode_Metal$stableprop[0]
final val com.compose.sdl/com_compose_sdl_GpuMode_OpenGL$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_OpenGL$stableprop|#static{}com_compose_sdl_GpuMode_OpenGL$stableprop[0]
final val com.compose.sdl/com_compose_sdl_RawSdlHandles$stableprop // com.compose.sdl/com_compose_sdl_RawSdlHandles$stableprop|#static{}com_compose_sdl_RawSdlHandles$stableprop[0]
final val com.compose.sdl/com_compose_sdl_SDL3Backend$stableprop // com.compose.sdl/com_compose_sdl_SDL3Backend$stableprop|#static{}com_compose_sdl_SDL3Backend$stableprop[0]
final val com.compose.sdl/com_compose_sdl_TextLayoutConfig$stableprop // com.compose.sdl/com_compose_sdl_TextLayoutConfig$stableprop|#static{}com_compose_sdl_TextLayoutConfig$stableprop[0]
@@ -5389,16 +5358,10 @@ final fun androidx.compose.ui/androidx_compose_ui_CombinedModifier$stableprop_ge
final fun androidx.compose.ui/androidx_compose_ui_ComposeUiFlags$stableprop_getter(): kotlin/Int // androidx.compose.ui/androidx_compose_ui_ComposeUiFlags$stableprop_getter|androidx_compose_ui_ComposeUiFlags$stableprop_getter(){}[0]
final fun androidx.compose.ui/androidx_compose_ui_Modifier_Node$stableprop_getter(): kotlin/Int // androidx.compose.ui/androidx_compose_ui_Modifier_Node$stableprop_getter|androidx_compose_ui_Modifier_Node$stableprop_getter(){}[0]
final fun androidx.compose.ui/isUiSystemInDarkTheme(androidx.compose.runtime/Composer?, kotlin/Int): kotlin/Boolean // androidx.compose.ui/isUiSystemInDarkTheme|isUiSystemInDarkTheme(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun com.compose.sdl.input/com_compose_sdl_input_LegacyPointerEvent$stableprop_getter(): kotlin/Int // com.compose.sdl.input/com_compose_sdl_input_LegacyPointerEvent$stableprop_getter|com_compose_sdl_input_LegacyPointerEvent$stableprop_getter(){}[0]
final fun com.compose.sdl.node/com_compose_sdl_node_ComposeRootHost$stableprop_getter(): kotlin/Int // com.compose.sdl.node/com_compose_sdl_node_ComposeRootHost$stableprop_getter|com_compose_sdl_node_ComposeRootHost$stableprop_getter(){}[0]
final fun com.compose.sdl.platform/com_compose_sdl_platform_DefaultViewConfiguration$stableprop_getter(): kotlin/Int // com.compose.sdl.platform/com_compose_sdl_platform_DefaultViewConfiguration$stableprop_getter|com_compose_sdl_platform_DefaultViewConfiguration$stableprop_getter(){}[0]
final fun com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop_getter(): kotlin/Int // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop_getter|com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop_getter(){}[0]
final fun com.compose.sdl.text.input/com_compose_sdl_text_input_ImeBridge$stableprop_getter(): kotlin/Int // com.compose.sdl.text.input/com_compose_sdl_text_input_ImeBridge$stableprop_getter|com_compose_sdl_text_input_ImeBridge$stableprop_getter(){}[0]
final fun com.compose.sdl.text/registerGenericFonts() // com.compose.sdl.text/registerGenericFonts|registerGenericFonts(){}[0]
final fun com.compose.sdl.window/PopupLayer(com.compose.sdl.window/PopupHostState, androidx.compose.runtime/Composer?, kotlin/Int) // com.compose.sdl.window/PopupLayer|PopupLayer(com.compose.sdl.window.PopupHostState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun com.compose.sdl.window/PopupOutsideDismiss(kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Function0<kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // com.compose.sdl.window/PopupOutsideDismiss|PopupOutsideDismiss(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Function0<kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun com.compose.sdl.window/PositionedPopup(kotlin/Int, kotlin/Int, kotlin/Function0<kotlin/Unit>, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // com.compose.sdl.window/PositionedPopup|PositionedPopup(kotlin.Int;kotlin.Int;kotlin.Function0<kotlin.Unit>;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun com.compose.sdl.window/com_compose_sdl_window_PopupExitHandle$stableprop_getter(): kotlin/Int // com.compose.sdl.window/com_compose_sdl_window_PopupExitHandle$stableprop_getter|com_compose_sdl_window_PopupExitHandle$stableprop_getter(){}[0]
final fun com.compose.sdl.window/com_compose_sdl_window_PopupHostState$stableprop_getter(): kotlin/Int // com.compose.sdl.window/com_compose_sdl_window_PopupHostState$stableprop_getter|com_compose_sdl_window_PopupHostState$stableprop_getter(){}[0]
final fun com.compose.sdl.window/createPopupHostState(): com.compose.sdl.window/PopupHostState // com.compose.sdl.window/createPopupHostState|createPopupHostState(){}[0]
final fun com.compose.sdl/appDataDir(kotlin/String, kotlin/String): kotlin/String? // com.compose.sdl/appDataDir|appDataDir(kotlin.String;kotlin.String){}[0]
@@ -5420,10 +5383,10 @@ final fun com.compose.sdl/com_compose_sdl_AppEvent_WindowResized$stableprop_gett
final fun com.compose.sdl/com_compose_sdl_ComposeNativeWindow$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_ComposeNativeWindow$stableprop_getter|com_compose_sdl_ComposeNativeWindow$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode$stableprop_getter|com_compose_sdl_GpuMode$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode_Auto$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Auto$stableprop_getter|com_compose_sdl_GpuMode_Auto$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode_Skia$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Skia$stableprop_getter|com_compose_sdl_GpuMode_Skia$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode_Skia_Metal$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Skia_Metal$stableprop_getter|com_compose_sdl_GpuMode_Skia_Metal$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode_Skia_OpenGL$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Skia_OpenGL$stableprop_getter|com_compose_sdl_GpuMode_Skia_OpenGL$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode_Software$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Software$stableprop_getter|com_compose_sdl_GpuMode_Software$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode_CpuRaster$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_CpuRaster$stableprop_getter|com_compose_sdl_GpuMode_CpuRaster$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode_Metal$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Metal$stableprop_getter|com_compose_sdl_GpuMode_Metal$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_GpuMode_OpenGL$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_OpenGL$stableprop_getter|com_compose_sdl_GpuMode_OpenGL$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_RawSdlHandles$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_RawSdlHandles$stableprop_getter|com_compose_sdl_RawSdlHandles$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_SDL3Backend$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_SDL3Backend$stableprop_getter|com_compose_sdl_SDL3Backend$stableprop_getter(){}[0]
final fun com.compose.sdl/com_compose_sdl_TextLayoutConfig$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_TextLayoutConfig$stableprop_getter|com_compose_sdl_TextLayoutConfig$stableprop_getter(){}[0]
final fun com.compose.sdl/createRenderBackend(com.compose.sdl/SDL3Backend, com.compose.sdl/GpuMode): com.compose.sdl/RenderBackend? // com.compose.sdl/createRenderBackend|createRenderBackend(com.compose.sdl.SDL3Backend;com.compose.sdl.GpuMode){}[0]
@@ -14,7 +14,7 @@ import androidx.compose.ui.unit.DpSize
* StubOwner). Constant values match the upstream `PlatformContext.DefaultViewConfiguration`
* (longPressTimeout=500ms, doubleTapTimeout=300ms, doubleTapMinTime=40ms, touchSlop=8f).
*/
object DefaultViewConfiguration : ViewConfiguration {
internal object DefaultViewConfiguration : ViewConfiguration {
override val longPressTimeoutMillis: Long = 500
override val doubleTapTimeoutMillis: Long = 300
override val doubleTapMinTimeMillis: Long = 40
@@ -98,23 +98,23 @@ class PopupHostState internal constructor() {
fun contains(inX: Int, inY: Int) = inX >= x && inX < x + w && inY >= y && inY < y + h
}
private val fDismissers = mutableListOf<Dismisser>()
private val mDismissers = mutableListOf<Dismisser>()
internal fun setDismisser(inId: Any, inX: Int, inY: Int, inW: Int, inH: Int, inOnDismiss: () -> Unit) {
val vD = fDismissers.firstOrNull { it.id === inId } ?: Dismisser(inId).also { fDismissers.add(it) }
val vD = mDismissers.firstOrNull { it.id === inId } ?: Dismisser(inId).also { mDismissers.add(it) }
vD.x = inX; vD.y = inY; vD.w = inW; vD.h = inH; vD.onDismiss = inOnDismiss
}
internal fun removeDismisser(inId: Any) {
fDismissers.removeAll { it.id === inId }
mDismissers.removeAll { it.id === inId }
}
/** Dismiss every registered popup whose content rect does NOT contain the
press. Called from the window's press dispatch; never consumes the press. */
fun notifyOutsidePress(inX: Int, inY: Int) {
if (fDismissers.isEmpty()) return
if (mDismissers.isEmpty()) return
// Copy: an onDismiss typically removes the dismisser (state write).
for (vD in fDismissers.toList()) {
for (vD in mDismissers.toList()) {
if (vD.w > 0 && vD.h > 0 && !vD.contains(inX, inY)) vD.onDismiss()
}
}
@@ -139,26 +139,26 @@ fun createPopupHostState(): PopupHostState = PopupHostState()
the entry. The hosted content (still composed - it lives in the HOST's
composition, not the owner's) observes that, plays its animation, and MUST
end with [finish], which actually removes the entry. */
class PopupExitHandle internal constructor(
private val fHost: PopupHostState, // owning host - target of finish()
private val fEntry: PopupHostState.Entry, // the entry this handle controls
internal class PopupExitHandle internal constructor(
private val mHost: PopupHostState, // owning host - target of finish()
private val mEntry: PopupHostState.Entry, // the entry this handle controls
) {
/** True once the owning Popup left the composition and the host is waiting
for this entry's exit animation. Observable - recomposes the content. */
val isExiting: State<Boolean> get() = fEntry.exiting
val isExiting: State<Boolean> get() = mEntry.exiting
/** Opt in to exit deferral. Idempotent; call from a SideEffect. */
fun enableExitTransition() { fEntry.hasExitTransition = true }
fun enableExitTransition() { mEntry.hasExitTransition = true }
/** Ends the deferral - the entry is removed from the host for real. */
fun finish() { fHost.forceRemove(fEntry.id) }
fun finish() { mHost.forceRemove(mEntry.id) }
}
/** Per-entry handle, provided by PopupLayer around each hosted content. Null
outside a popup layer. NB: safe to expose through the caller-locals rewrap
in Popup (CompositionLocalProvider(callerContext)) - the caller never
provides this local, so the layer's per-entry value stays visible. */
val LocalPopupExitHandle = staticCompositionLocalOf<PopupExitHandle?> { null }
internal val LocalPopupExitHandle = staticCompositionLocalOf<PopupExitHandle?> { null }
// ==================
// MARK: PopupLayer
@@ -187,7 +187,7 @@ fun PopupLayer(inHost: PopupHostState) {
popup host so the dismissing press is NOT consumed (it still reaches whatever
is under it - no dead first click). */
@Composable
fun PopupOutsideDismiss(inX: Int, inY: Int, inW: Int, inH: Int, onDismissRequest: () -> Unit) {
internal fun PopupOutsideDismiss(inX: Int, inY: Int, inW: Int, inH: Int, onDismissRequest: () -> Unit) {
val vHost = LocalPopupHost.current
val vId = remember { Any() }
SideEffect { vHost.setDismisser(vId, inX, inY, inW, inH, onDismissRequest) }
@@ -203,7 +203,7 @@ fun PopupOutsideDismiss(inX: Int, inY: Int, inW: Int, inH: Int, onDismissRequest
`x`/`y` are LAYOUT PIXELS (matches `LayoutCoordinates.positionInRoot`), not
`Dp` - layout runs in physical pixels under the Option-B density flow. */
@Composable
fun PositionedPopup(
internal fun PositionedPopup(
x: Int,
y: Int,
onDismissRequest: () -> Unit,
@@ -103,14 +103,14 @@ private const val kImageMime = "image/png"
// call with an image). Held on nativeHeap because SDL keeps the callback alive
// until the clipboard is cleared or another SDL_SetClipboardData replaces us -
// a Kotlin heap reference would break as soon as the caller's coroutine
// returned. Read by [fClipboardDataCallback], freed by [fClipboardCleanupCallback].
private var fOutgoingImagePtr: CPointer<ByteVar>? = null
private var fOutgoingImageSize: Int = 0
// returned. Read by [mClipboardDataCallback], freed by [mClipboardCleanupCallback].
private var mOutgoingImagePtr: CPointer<ByteVar>? = null
private var mOutgoingImageSize: Int = 0
// SDL fires this on the main event thread when another app asks the clipboard
// for a MIME we advertised. We serve back the pointer + size we stashed at
// SDL_SetClipboardData time. Return NULL if the MIME doesn't match.
private val fClipboardDataCallback = staticCFunction {
private val mClipboardDataCallback = staticCFunction {
userdata: COpaquePointer?,
mimeType: CPointer<ByteVar>?,
sizePtr: CPointer<platform.posix.size_tVar>?,
@@ -118,20 +118,20 @@ private val fClipboardDataCallback = staticCFunction {
userdata.hashCode() // unused
val vRequested = mimeType?.toKString() ?: return@staticCFunction null
if (vRequested != kImageMime) return@staticCFunction null
val vPtr = fOutgoingImagePtr ?: return@staticCFunction null
sizePtr?.pointed?.value = fOutgoingImageSize.toULong()
val vPtr = mOutgoingImagePtr ?: return@staticCFunction null
sizePtr?.pointed?.value = mOutgoingImageSize.toULong()
vPtr as COpaquePointer
}
// SDL calls this when the clipboard we set is cleared or overwritten (by us
// or another app). Frees the nativeHeap allocation.
private val fClipboardCleanupCallback = staticCFunction {
private val mClipboardCleanupCallback = staticCFunction {
userdata: COpaquePointer?,
->
userdata.hashCode() // unused
fOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
fOutgoingImagePtr = null
fOutgoingImageSize = 0
mOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
mOutgoingImagePtr = null
mOutgoingImageSize = 0
Unit
}
@@ -172,19 +172,19 @@ private fun sdlReadImage(): ByteArray? = memScoped {
private fun sdlWriteImage(bytes: ByteArray) {
// Free any previously-held buffer proactively (the cleanup callback also
// fires from SDL, but doing it eagerly here handles same-thread re-set).
fOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
mOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
val vPtr = nativeHeap.allocArray<ByteVar>(bytes.size)
for (i in bytes.indices) vPtr[i] = bytes[i]
fOutgoingImagePtr = vPtr
fOutgoingImageSize = bytes.size
mOutgoingImagePtr = vPtr
mOutgoingImageSize = bytes.size
memScoped {
val vMimeArray = allocArray<CPointerVar<ByteVar>>(1)
vMimeArray[0] = kImageMime.cstr.ptr
SDL_SetClipboardData(
fClipboardDataCallback,
fClipboardCleanupCallback,
mClipboardDataCallback,
mClipboardCleanupCallback,
null,
vMimeArray,
1.toULong(),
@@ -18,12 +18,12 @@ import androidx.compose.ui.unit.IntRect
* no-ops.
*/
private class StubSemanticsRegion : SemanticsRegion {
private var fBounds: IntRect = IntRect.Zero
override fun set(rect: IntRect) { fBounds = rect }
private var mBounds: IntRect = IntRect.Zero
override fun set(rect: IntRect) { mBounds = rect }
override fun intersect(region: SemanticsRegion): Boolean = false
override fun difference(rect: IntRect): Boolean = false
override val bounds: IntRect get() = fBounds
override val isEmpty: Boolean get() = fBounds == IntRect.Zero
override val bounds: IntRect get() = mBounds
override val isEmpty: Boolean get() = mBounds == IntRect.Zero
}
internal actual fun SemanticsRegion(): SemanticsRegion = StubSemanticsRegion()
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.input.key.KeyEvent
import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.toKString
import sdl3.SDL_GetRendererName
@@ -12,7 +13,12 @@ import sdl3.SDL_MaximizeWindow
import sdl3.SDL_MinimizeWindow
import sdl3.SDL_RaiseWindow
import sdl3.SDL_RestoreWindow
import sdl3.SDL_SetWindowAlwaysOnTop
import sdl3.SDL_SetWindowBordered
import sdl3.SDL_SetWindowFocusable
import sdl3.SDL_SetWindowFullscreen
import sdl3.SDL_SetWindowOpacity
import sdl3.SDL_SetWindowResizable
import sdl3.SDL_SetWindowSize
import sdl3.SDL_SetWindowTitle
@@ -34,42 +40,42 @@ class ComposeNativeWindow constructor(
val gpuMode: GpuMode,
initialTitle: String,
) {
private var fWidth by mutableStateOf(backend.windowWidth)
private var fHeight by mutableStateOf(backend.windowHeight)
private var fPixelWidth by mutableStateOf(backend.pixelWidth)
private var fPixelHeight by mutableStateOf(backend.pixelHeight)
private var fTitle by mutableStateOf(initialTitle)
private var fMinimized by mutableStateOf(false)
private var fMaximized by mutableStateOf(false)
private var fFullscreen by mutableStateOf(false)
private var fCloseRequested by mutableStateOf(false)
private var fFps by mutableStateOf(0)
private var mWidth by mutableStateOf(backend.windowWidth)
private var mHeight by mutableStateOf(backend.windowHeight)
private var mPixelWidth by mutableStateOf(backend.pixelWidth)
private var mPixelHeight by mutableStateOf(backend.pixelHeight)
private var mTitle by mutableStateOf(initialTitle)
private var mMinimized by mutableStateOf(false)
private var mMaximized by mutableStateOf(false)
private var mFullscreen by mutableStateOf(false)
private var mCloseRequested by mutableStateOf(false)
private var mFps by mutableStateOf(0)
// ============
// State
/** Logical (point) width - the same units layout uses. */
val width: Int get() = fWidth
val height: Int get() = fHeight
val width: Int get() = mWidth
val height: Int get() = mHeight
/** Physical (pixel) back-buffer size; on Retina this is `width * pixelDensity`. */
val pixelWidth: Int get() = fPixelWidth
val pixelHeight: Int get() = fPixelHeight
val title: String get() = fTitle
val isMinimized: Boolean get() = fMinimized
val isMaximized: Boolean get() = fMaximized
val isFullscreen: Boolean get() = fFullscreen
val pixelWidth: Int get() = mPixelWidth
val pixelHeight: Int get() = mPixelHeight
val title: String get() = mTitle
val isMinimized: Boolean get() = mMinimized
val isMaximized: Boolean get() = mMaximized
val isFullscreen: Boolean get() = mFullscreen
val pixelDensity: Float get() = backend.pixelDensity
/** Frames per second, refreshed ~once a second by the render loop. Snapshot-
backed, so reading it in a composable recomposes when it changes. */
val fps: Int get() = fFps
val fps: Int get() = mFps
/** Human-readable name of the rendering pipeline:
"Skia / Metal", "Skia / OpenGL", "Skia / CPU raster". */
val rendererName: String
get() = when (gpuMode) {
is GpuMode.Skia.Metal -> "Skia / Metal"
is GpuMode.Skia.OpenGL -> "Skia / OpenGL"
is GpuMode.Software -> "Skia / CPU raster"
is GpuMode.Metal -> "Skia / Metal"
is GpuMode.OpenGL -> "Skia / OpenGL"
is GpuMode.CpuRaster -> "Skia / CPU raster"
is GpuMode.Auto -> "Auto (unresolved)"
}
@@ -79,7 +85,7 @@ class ComposeNativeWindow constructor(
fun setTitle(inTitle: String) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_SetWindowTitle(vWindow, inTitle)
fTitle = inTitle
mTitle = inTitle
}
/** Logical size in points - SDL fires AppEvent.WindowResized which
@@ -91,56 +97,111 @@ class ComposeNativeWindow constructor(
fun minimize() {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_MinimizeWindow(vWindow); fMinimized = true
SDL_MinimizeWindow(vWindow); mMinimized = true
}
fun maximize() {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_MaximizeWindow(vWindow); fMaximized = true; fMinimized = false
SDL_MaximizeWindow(vWindow); mMaximized = true; mMinimized = false
}
fun restore() {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_RestoreWindow(vWindow); fMaximized = false; fMinimized = false
SDL_RestoreWindow(vWindow); mMaximized = false; mMinimized = false
}
fun setFullscreen(inFullscreen: Boolean) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_SetWindowFullscreen(vWindow, inFullscreen); fFullscreen = inFullscreen
SDL_SetWindowFullscreen(vWindow, inFullscreen); mFullscreen = inFullscreen
}
fun toggleFullscreen() = setFullscreen(!fFullscreen)
fun toggleFullscreen() = setFullscreen(!mFullscreen)
fun raise() {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_RaiseWindow(vWindow)
}
// ============
// Mutable window attributes (Compose Desktop's Window() parameters)
//
// Window() re-applies these from the composition whenever they change, so
// app code normally passes them as parameters rather than calling these.
// `transparent` is absent on purpose: SDL needs SDL_WINDOW_TRANSPARENT at
// creation and offers no setter, so it is a construction-time attribute.
private var mUndecorated = false
private var mResizable = true
private var mAlwaysOnTop = false
private var mFocusable = true
private var mEnabled = true
val isUndecorated: Boolean get() = mUndecorated
val isResizable: Boolean get() = mResizable
val isAlwaysOnTop: Boolean get() = mAlwaysOnTop
val isFocusable: Boolean get() = mFocusable
/** False hides the window's title bar and border. */
fun setUndecorated(inUndecorated: Boolean) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_SetWindowBordered(vWindow, !inUndecorated); mUndecorated = inUndecorated
}
fun setResizable(inResizable: Boolean) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_SetWindowResizable(vWindow, inResizable); mResizable = inResizable
}
fun setAlwaysOnTop(inAlwaysOnTop: Boolean) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_SetWindowAlwaysOnTop(vWindow, inAlwaysOnTop); mAlwaysOnTop = inAlwaysOnTop
}
/** False stops the window taking keyboard focus when clicked or raised. */
fun setFocusable(inFocusable: Boolean) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_SetWindowFocusable(vWindow, inFocusable); mFocusable = inFocusable
}
/** Window opacity, 0f (fully transparent) to 1f. Needs a compositing window
manager; SDL reports failure on platforms without one. */
fun setOpacity(inOpacity: Float) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_SetWindowOpacity(vWindow, inOpacity.coerceIn(0f, 1f))
}
/** True while the window accepts input. A disabled window still renders and
repaints; it just drops pointer / keyboard / text / wheel / drop events,
which is how Compose Desktop's `enabled = false` behaves. */
val isEnabled: Boolean get() = mEnabled
fun setEnabled(inEnabled: Boolean) { mEnabled = inEnabled }
/** Asks composeWindow's main loop to break out at the next frame.
Same effect as the user closing the window via the OS. Bypasses any
onCloseRequest handler - this is the "really quit now" path. */
fun close() { fCloseRequested = true }
fun close() { mCloseRequested = true }
// ============
// Close interception
private var fOnCloseRequest: (() -> Boolean)? = null
private var mOnCloseRequest: (() -> Boolean)? = null
/** Register a handler invoked when the user tries to close the window (OS
close button / Quit). Return true to let the close proceed, false to veto
it (e.g. to show an "unsaved changes" dialog first, then call close()
once the user confirms). Pass null to clear. */
fun setOnCloseRequest(inHandler: (() -> Boolean)?) { fOnCloseRequest = inHandler }
fun setOnCloseRequest(inHandler: (() -> Boolean)?) { mOnCloseRequest = inHandler }
// ============
// Global key shortcuts
private var fOnKeyShortcut: ((KeyEvent) -> Boolean)? = null
private var mOnKeyShortcut: ((KeyEvent) -> Boolean)? = null
/** Register a handler for key events the focused node didn't consume - for
app-wide shortcuts (Ctrl+S, etc.). Receives every unconsumed key (and all
keys when nothing is focused). Return true if handled. Pass null to clear. */
fun setOnKeyShortcut(inHandler: ((KeyEvent) -> Boolean)?) { fOnKeyShortcut = inHandler }
fun setOnKeyShortcut(inHandler: ((KeyEvent) -> Boolean)?) { mOnKeyShortcut = inHandler }
// ============
// Framework hooks - driven by composeWindow's main loop (which lives in
@@ -148,26 +209,71 @@ class ComposeNativeWindow constructor(
// intended for app code.
fun onResized() {
fWidth = backend.windowWidth
fHeight = backend.windowHeight
fPixelWidth = backend.pixelWidth
fPixelHeight = backend.pixelHeight
mWidth = backend.windowWidth
mHeight = backend.windowHeight
mPixelWidth = backend.pixelWidth
mPixelHeight = backend.pixelHeight
}
/** Called by the main loop ~once a second with the measured frame rate. */
fun updateFps(inFps: Int) { fFps = inFps }
fun updateFps(inFps: Int) { mFps = inFps }
val isCloseRequested: Boolean get() = fCloseRequested
val isCloseRequested: Boolean get() = mCloseRequested
/** Driven by composeWindow's main loop on an OS close / Quit event. Returns
true if the close should proceed (no handler, or the handler allowed it). */
fun requestCloseFromUser(): Boolean = fOnCloseRequest?.invoke() ?: true
fun requestCloseFromUser(): Boolean = mOnCloseRequest?.invoke() ?: true
/** Driven by composeWindow's main loop for keys the focused node didn't
consume. Returns true if the shortcut handler handled it. */
fun dispatchKeyShortcut(inEvent: KeyEvent): Boolean = fOnKeyShortcut?.invoke(inEvent) ?: false
fun dispatchKeyShortcut(inEvent: KeyEvent): Boolean = mOnKeyShortcut?.invoke(inEvent) ?: false
// ============
// Raw SDL escape hatch
/**
The live SDL handles behind this window, for calling the `sdl3.*` cinterop
directly when the Compose-level API doesn't cover something.
Returns a snapshot: the pointers are only valid while the window is alive,
so re-read it rather than caching, and never use it after the window closes.
Whatever you do through it is outside the port's control - resizing,
destroying or re-creating the window or its renderer behind Compose's back
will desync the renderer state.
*/
fun rawSdlHandles(): RawSdlHandles = RawSdlHandles(
window = backend.window,
renderer = backend.renderer,
glContext = backend.glContext,
metalView = backend.metalView,
)
}
/**
Opaque SDL pointers for one window, as handed out by
[ComposeNativeWindow.rawSdlHandles].
`reinterpret()` each to the `cnames.structs.*` type the SDL function expects:
val vHandles = window.rawSdlHandles()
vHandles.window?.let { sdl3.SDL_FlashWindow(it.reinterpret(), FLASH_BRIEFLY) }
Which pointers are non-null depends on the resolved [GpuMode]: `renderer` is
set only for the CPU-raster (Software) backend, `glContext` only for Skia
OpenGL, and `metalView` only for Skia Metal. `window` is always set on a
successfully created window.
*/
data class RawSdlHandles(
/** `SDL_Window*` - always present while the window lives. */
val window: COpaquePointer?,
/** `SDL_Renderer*` - CPU-raster (Software) backend only. */
val renderer: COpaquePointer?,
/** `SDL_GLContext` - Skia OpenGL backend only. */
val glContext: COpaquePointer?,
/** `SDL_MetalView` - Skia Metal backend only. */
val metalView: COpaquePointer?,
)
// ==================
// MARK: Scope + CompositionLocal
// ==================
@@ -15,7 +15,7 @@ import sdl3.SDL_ShowSaveFileDialog
The C callback can't capture Kotlin state, so the result handler is parked in
a StableRef passed as the dialog's `userdata` and disposed inside the callback. */
private val fDialogCallback = staticCFunction { inUserData: COpaquePointer?, inFileList: CPointer<CPointerVar<ByteVar>>?, inFilter: Int ->
private val mDialogCallback = staticCFunction { inUserData: COpaquePointer?, inFileList: CPointer<CPointerVar<ByteVar>>?, inFilter: Int ->
inFilter.hashCode() // unused (selected filter index)
val vRef = inUserData?.asStableRef<(String?) -> Unit>() ?: return@staticCFunction
val vHandler = vRef.get()
@@ -31,7 +31,7 @@ private val fDialogCallback = staticCFunction { inUserData: COpaquePointer?, inF
fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> Unit) {
val vRef = StableRef.create(inOnResult)
SDL_ShowSaveFileDialog(
fDialogCallback,
mDialogCallback,
vRef.asCPointer(),
null, // parent window (null = unparented)
null, // no file-type filters
@@ -45,7 +45,7 @@ fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> U
fun showOpenFileDialog(inOnResult: (String?) -> Unit) {
val vRef = StableRef.create(inOnResult)
SDL_ShowOpenFileDialog(
fDialogCallback,
mDialogCallback,
vRef.asCPointer(),
null, // parent window
null, // filters
@@ -4,28 +4,38 @@ package com.compose.sdl
// MARK: GpuMode
// ==================
/** Which GPU path the Skia renderer uses.
*
* ┌─ Auto - pick the best for this platform (Metal on macOS, OpenGL on
* │ Linux, OpenGL on Windows). If the GPU context/bridge can't be
* │ created, ComposeWindow falls back to Software.
* ├─ Software - Skia CPU raster: paints a host pixel buffer that SDL_Renderer
* │ uploads as a texture each frame. No GPU context needed.
* └─ Skia.* - Skia GPU bridges
* ├─ OpenGL - Skia GL backend on an SDL OpenGL (WGL/GLX) context
* └─ Metal - Skia Metal on a CAMetalLayer via SDL_Metal_CreateView (macOS)
*
* AUTO resolves via rendererPreferredGpuMode() per target, at composeWindow entry. */
/**
Which drawing path the renderer uses.
Every mode is Skia - the port renders through Skia on all targets. The axis
here is only how Skia's output reaches the screen:
Auto resolve per platform at window creation (Metal on macOS, OpenGL
on Linux and Windows). If the GPU context can't be created, the
window falls back to CpuRaster rather than failing to open.
Metal Skia's Metal backend on a CAMetalLayer (SDL_Metal_CreateView), macOS.
OpenGL Skia's GL backend on an SDL OpenGL (WGL/GLX) context.
CpuRaster Skia paints a host pixel buffer that SDL_Renderer uploads as a
texture each frame. No GPU context needed.
This used to read `Software` vs a nested `Skia.*` group, which dated from when
the alternative to Skia was SDL's own renderer. With SDL reduced to windowing
that split was actively misleading - "Software" was always Skia too.
*/
sealed class GpuMode {
/** Let the platform pick: Skia.Metal on macOS, Skia.OpenGL on Linux/Windows. */
/** Let the platform pick: [Metal] on macOS, [OpenGL] on Linux and Windows. */
object Auto : GpuMode() { override fun toString() = "Auto" }
/** Skia CPU raster - a host pixel buffer uploaded via SDL_Renderer each frame. */
object Software : GpuMode() { override fun toString() = "Software" }
/** Skia Metal on a CAMetalLayer via SDL_Metal_CreateView (macOS only). */
object Metal : GpuMode() { override fun toString() = "Metal" }
/** Skia GPU bridges. */
sealed class Skia : GpuMode() {
object OpenGL : Skia() { override fun toString() = "Skia.OpenGL" }
object Metal : Skia() { override fun toString() = "Skia.Metal" }
}
/** Skia GL on an SDL OpenGL context (WGL on Windows, GLX on Linux). */
object OpenGL : GpuMode() { override fun toString() = "OpenGL" }
/** Skia CPU raster - a host pixel buffer uploaded via SDL_Renderer each frame. */
object CpuRaster : GpuMode() { override fun toString() = "CpuRaster" }
/** True for the GPU-backed modes. [Auto] is false because it is unresolved:
ask after the window has resolved it, via ComposeNativeWindow.gpuMode. */
val isGpuAccelerated: Boolean get() = this is Metal || this is OpenGL
}
@@ -22,7 +22,7 @@ import sdl3.SDL_OpenURL
UI until the file manager was up. None of the three platform backends
requires the main thread, so a small background scope launches them and
(optionally) posts the result back to Main. */
private val fOpenExternalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mOpenExternalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Opens a URL or file:// URI with the OS's default handler (browser, file
manager, …) via SDL_OpenURL - asynchronously, so the UI keeps pumping while
@@ -30,7 +30,7 @@ private val fOpenExternalScope = CoroutineScope(SupervisorJob() + Dispatchers.De
thread with SDL_OpenURL's success flag. */
@OptIn(ExperimentalForeignApi::class)
fun openUrl(inUrl: String, inOnResult: ((Boolean) -> Unit)? = null) {
fOpenExternalScope.launch {
mOpenExternalScope.launch {
val vOk = SDL_OpenURL(inUrl)
if (inOnResult != null) withContext(Dispatchers.Main) { inOnResult(vOk) }
}
@@ -10,6 +10,21 @@ import com.compose.sdl.res.ImageLoader
(official Skiko on macOS/Linux, the fork on mingwX64); text measurement +
drawing go through the skiko paragraph engine (androidx.compose.ui.text
Paragraph actuals), so there is no renderer-owned measurer here. */
/*
WHY THIS INTERFACE EXISTS WITH ONE IMPLEMENTATION - do not "simplify" it away.
SkiaRenderBackend is its only implementor, which reads like leftover
dual-backend scaffolding from when SDL's own renderer was the alternative. It
is not. SkiaRenderBackend lives in skikoRendererMain, BELOW nativeMain, so
nativeMain-level code cannot name it: :desktop-native-window's WindowInstance
holds the renderer in nativeMain and would not compile against the concrete
type. Verified by trying it - compileNativeMainKotlinMetadata fails with
"Unresolved reference 'renderer'".
So this interface is the nativeMain-visible boundary for an implementation
that must live lower down, exactly like the expect fun createRenderBackend
that returns it. Same reasoning, same constraint.
*/
interface RenderBackend {
/** Loader the shared ImageMeasurePolicy / Res.readBytes plug into. Backed
by the same decode cache the renderer uses to paint images, so a
@@ -30,8 +45,13 @@ interface RenderBackend {
DrawModifierNode pipeline. Taking a (Canvas)->Unit instead of the host keeps
RenderBackend + its implementations independent of the node/host layer - the
decoupling that lets the renderers live in :ui-graphics (no ui-graphics→ui
cycle). Default no-op. */
fun drawRoot(inDraw: (canvas: androidx.compose.ui.graphics.Canvas) -> Unit) {}
cycle).
ABSTRACT, not a no-op default: the empty body dates from when a second,
non-Skia backend could opt out of it, and with the Skia backend now the
only implementation an unimplemented drawRoot would silently render
nothing rather than fail to compile. */
fun drawRoot(inDraw: (canvas: androidx.compose.ui.graphics.Canvas) -> Unit)
/** Flush + present whatever was just drawn. */
fun endFrame()
@@ -43,7 +43,7 @@ import sdl3.SDL_GetBasePath
// no zlib/gzip wrapper). ZIP64 is not supported - the resource set is
// small enough that the standard 4 GB / 65535-entry limits don't apply.
private class ComposeResourceArchive(private val fHandle: FileHandle) {
private class ComposeResourceArchive(private val mHandle: FileHandle) {
// One entry's location + sizing + compression method from the central
// directory. method 0 = stored, 8 = deflated.
@@ -53,18 +53,18 @@ private class ComposeResourceArchive(private val fHandle: FileHandle) {
val uncompressedSize: Long,
val method: Int,
)
private val fEntries: Map<String, Entry>
private val mEntries: Map<String, Entry>
init {
fEntries = readCentralDirectory()
mEntries = readCentralDirectory()
}
fun has(inPath: String): Boolean = fEntries.containsKey(inPath)
fun has(inPath: String): Boolean = mEntries.containsKey(inPath)
/** Reads an entry's bytes (inflated if needed) or null if the entry is
missing / uses an unsupported compression method. */
fun readBytes(inPath: String): ByteArray? {
val vEntry = fEntries[inPath] ?: return null
val vEntry = mEntries[inPath] ?: return null
if (vEntry.uncompressedSize <= 0L) return ByteArray(0)
// Local file header layout: 30 fixed bytes, then filename_len + extra_len.
@@ -126,7 +126,7 @@ private class ComposeResourceArchive(private val fHandle: FileHandle) {
// End of Central Directory record: 22 bytes minimum, with up to 65535
// bytes of comment trailing. Read the last 64 KiB + 22 and scan backward
// for the EOCD signature.
val vFileLen: Long = fHandle.size()
val vFileLen: Long = mHandle.size()
if (vFileLen < 22L) return emptyMap()
val vTailLen = minOf(vFileLen, 65557L).toInt()
val vTail = ByteArray(vTailLen)
@@ -182,14 +182,14 @@ private class ComposeResourceArchive(private val fHandle: FileHandle) {
if (outBuf.isEmpty()) return true
var vRead = 0
while (vRead < outBuf.size) {
val vN = fHandle.read(inOffset + vRead, outBuf, vRead, outBuf.size - vRead)
val vN = mHandle.read(inOffset + vRead, outBuf, vRead, outBuf.size - vRead)
if (vN <= 0) return false
vRead += vN
}
return true
}
fun close() = fHandle.close()
fun close() = mHandle.close()
private fun le16(inBuf: ByteArray, inOff: Int): Int =
(inBuf[inOff].toInt() and 0xFF) or
@@ -11,12 +11,21 @@ class SDL3Backend(
private val title: String = "ComposeNativeSDL3",
private val width: Int = 800,
private val height: Int = 600,
val gpuMode: GpuMode = GpuMode.Software,
val gpuMode: GpuMode = GpuMode.CpuRaster,
// Resource paths (inside data.kres) of the pre-decoded .rgba icon blobs.
// The first-listed size is irrelevant - the largest becomes the base and
// the rest become alternate resolutions. Dark set falls back to light.
private val iconLightResourcePaths: List<String> = emptyList(),
private val iconDarkResourcePaths: List<String> = emptyList(),
// Creation-time window attributes, mirroring Compose Desktop's Window().
// `undecorated`, `resizable`, `alwaysOnTop` and `focusable` can also be
// changed later (see ComposeNativeWindow); `transparent` cannot - SDL needs
// SDL_WINDOW_TRANSPARENT at creation, so it is honoured here only.
private val undecorated: Boolean = false,
private val transparent: Boolean = false,
private val resizable: Boolean = true,
private val alwaysOnTop: Boolean = false,
private val focusable: Boolean = true,
) {
init {
require(gpuMode !is GpuMode.Auto) {
@@ -66,7 +75,7 @@ class SDL3Backend(
com.compose.sdl.res.preferredLocaleProvider = ::sdlPreferredLocale
com.compose.sdl.res.preferredLocaleTagsProvider = { com.compose.sdl.text.systemPreferredLocaleTags() }
if (gpuMode is GpuMode.Skia.OpenGL) {
if (gpuMode is GpuMode.OpenGL) {
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_MAJOR_VERSION, 3)
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_MINOR_VERSION, 2)
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_PROFILE_MASK,
@@ -75,10 +84,16 @@ class SDL3Backend(
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_STENCIL_SIZE, 8)
}
val flags = SDL_WINDOW_RESIZABLE or SDL_WINDOW_HIGH_PIXEL_DENSITY or when (gpuMode) {
is GpuMode.Skia.OpenGL -> SDL_WINDOW_OPENGL
is GpuMode.Skia.Metal -> SDL_WINDOW_METAL
is GpuMode.Software -> 0UL
var attrFlags = SDL_WINDOW_HIGH_PIXEL_DENSITY
if (resizable) attrFlags = attrFlags or SDL_WINDOW_RESIZABLE
if (undecorated) attrFlags = attrFlags or SDL_WINDOW_BORDERLESS
if (transparent) attrFlags = attrFlags or SDL_WINDOW_TRANSPARENT
if (alwaysOnTop) attrFlags = attrFlags or SDL_WINDOW_ALWAYS_ON_TOP
if (!focusable) attrFlags = attrFlags or SDL_WINDOW_NOT_FOCUSABLE
val flags = attrFlags or when (gpuMode) {
is GpuMode.OpenGL -> SDL_WINDOW_OPENGL
is GpuMode.Metal -> SDL_WINDOW_METAL
is GpuMode.CpuRaster -> 0UL
is GpuMode.Auto -> error("unreachable")
}
window = SDL_CreateWindow(title, width, height, flags)
@@ -88,7 +103,7 @@ class SDL3Backend(
}
when (gpuMode) {
is GpuMode.Skia.OpenGL -> {
is GpuMode.OpenGL -> {
glContext = SDL_GL_CreateContext(window?.reinterpret())
if (glContext == null) {
println("SDL_GL_CreateContext failed: ${SDL_GetError()?.toKString()}")
@@ -101,7 +116,7 @@ class SDL3Backend(
// keeps its fallback frame cap.
vsyncEnabled = SDL_GL_SetSwapInterval(1)
}
is GpuMode.Skia.Metal -> {
is GpuMode.Metal -> {
metalView = SDL_Metal_CreateView(window?.reinterpret())
if (metalView == null) {
println("SDL_Metal_CreateView failed: ${SDL_GetError()?.toKString()}")
@@ -111,7 +126,7 @@ class SDL3Backend(
// blocks - so Metal is vsync-paced and the loop skips its delay.
vsyncEnabled = true
}
is GpuMode.Software -> {
is GpuMode.CpuRaster -> {
// Skia CPU raster: SkiaSurfaceBridge paints a host buffer that
// this SDL_Renderer uploads as a texture each frame.
renderer = SDL_CreateRenderer(window?.reinterpret(), null)
@@ -5,7 +5,6 @@ import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import com.compose.sdl.input.LegacyPointerEvent
import kotlinx.cinterop.*
import sdl3.*
@@ -17,7 +16,16 @@ import sdl3.*
events; the loop routes those to its first window). Quit is app-level. */
sealed class AppEvent {
data object Quit : AppEvent()
data class Pointer(val event: LegacyPointerEvent, val windowId: UInt = 0u) : AppEvent()
/** A single-pointer SDL mouse event. Flat like its MouseWheel sibling: the
fields used to sit behind a `LegacyPointerEvent` wrapper that the mapper
built and the window loop immediately took apart again. */
data class Pointer(
val x: Float,
val y: Float,
val type: PointerEventType,
val button: PointerButton = PointerButton.Primary,
val windowId: UInt = 0u,
) : AppEvent()
data class Key(val event: KeyEvent, val windowId: UInt = 0u) : AppEvent()
data class TextInput(val text: String, val windowId: UInt = 0u) : AppEvent()
/** IME preedit / composition (SDL_EVENT_TEXT_EDITING). Empty text = composition
@@ -81,28 +89,31 @@ private fun mapEvent(e: SDL_Event): AppEvent? {
SDL_EVENT_MOUSE_BUTTON_DOWN -> {
val mb = e.button
AppEvent.Pointer(LegacyPointerEvent(
AppEvent.Pointer(
x = mb.x, y = mb.y,
type = PointerEventType.Press,
button = mapButton(mb.button)
), mb.windowID)
button = mapButton(mb.button),
windowId = mb.windowID,
)
}
SDL_EVENT_MOUSE_BUTTON_UP -> {
val mb = e.button
AppEvent.Pointer(LegacyPointerEvent(
AppEvent.Pointer(
x = mb.x, y = mb.y,
type = PointerEventType.Release,
button = mapButton(mb.button)
), mb.windowID)
button = mapButton(mb.button),
windowId = mb.windowID,
)
}
SDL_EVENT_MOUSE_MOTION -> {
val mm = e.motion
AppEvent.Pointer(LegacyPointerEvent(
AppEvent.Pointer(
x = mm.x, y = mm.y,
type = PointerEventType.Move
), mm.windowID)
type = PointerEventType.Move,
windowId = mm.windowID,
)
}
SDL_EVENT_KEY_DOWN -> mapKey(e.key, KeyEventType.KeyDown)
@@ -1,35 +0,0 @@
package com.compose.sdl.input
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
// ==================
// MARK: LegacyPointerEvent (project-only)
// ==================
/** Project-only legacy event class used by the SDL3 input pipeline
(`SDL3EventMapper` → `AppEvent.Pointer` → `ComposeWindow`'s event loop).
The name `PointerEvent` is now reserved for upstream's multi-touch
vendored event (in androidx.compose.ui.input.pointer.PointerEvent);
this single-pointer (x, y, type, button) shape is the bridge between
raw SDL events and upstream-shaped PointerInputChange dispatch.
ComposeWindow constructs an upstream PointerEvent from this for
delivery to Modifier.pointerInput blocks. */
class LegacyPointerEvent(
// Kept as Float: SDL delivers sub-pixel cursor coordinates, and on HiDPI the
// value is multiplied by DPR before dispatch, so truncating to Int here would
// quantize the caret to whole-DPR steps (2px on a 2x display) near glyph edges.
val x: Float,
val y: Float,
val type: PointerEventType,
val button: PointerButton = PointerButton.Primary,
) {
override fun equals(other: Any?): Boolean = other is LegacyPointerEvent &&
other.x == x && other.y == y && other.type == type && other.button == button
override fun hashCode(): Int {
var h = x.hashCode(); h = 31 * h + y.hashCode(); h = 31 * h + type.hashCode(); h = 31 * h + button.hashCode(); return h
}
override fun toString(): String =
"LegacyPointerEvent(x=$x, y=$y, type=$type, button=$button)"
}
@@ -29,12 +29,12 @@ class ComposeRootHost(inDensity: Float = 1f) {
// the root keeps LayoutNode's ErrorMeasurePolicy ("Undefined measure").
measurePolicy = androidx.compose.ui.layout.RootMeasurePolicy
}
private val fOwner = ComposeOwner(rootNode, Density(inDensity), LayoutDirection.Ltr)
private val fApplier = NodeApplier(rootNode)
private val mOwner = ComposeOwner(rootNode, Density(inDensity), LayoutDirection.Ltr)
private val mApplier = NodeApplier(rootNode)
// Upcast to the public supertype so the internal NodeApplier / LayoutNode
// don't leak across the module boundary; Composition accepts Applier<*>.
val applier: Applier<*> get() = fApplier
val applier: Applier<*> get() = mApplier
fun attach() {
// Install the focus root + drag-and-drop root on the root LayoutNode
@@ -43,8 +43,8 @@ class ComposeRootHost(inDensity: Float = 1f) {
// focus root the focus tree has no origin and requestFocus never
// sticks; without the DnD root the root DragAndDropNode never receives
// events from Sdl3DragAndDropOwner's dispatch calls.
rootNode.modifier = fOwner.focusOwner.modifier.then(fOwner.dragAndDropManager.modifier)
fOwner.attach()
rootNode.modifier = mOwner.focusOwner.modifier.then(mOwner.dragAndDropManager.modifier)
mOwner.attach()
// Turn on snapshot observation so writes to `mutableStateOf` (ScrollState.value,
// LazyList firstVisibleItemIndex, etc.) fire the appropriate invalidation
// callbacks - requestRelayout / requestRemeasure - routing through the vendored
@@ -52,53 +52,53 @@ class ComposeRootHost(inDensity: Float = 1f) {
// state but the placement lambda inside ScrollNode.measure never re-runs, so
// the child layer's move(IntOffset(0, -scroll)) never happens → scroll offset
// is invisible even though the state is updating.
fOwner.snapshotObserver.startObserving()
mOwner.snapshotObserver.startObserving()
}
fun setConstraints(inWidth: Int, inHeight: Int) {
fOwner.setRootConstraints(Constraints.fixed(inWidth, inHeight))
mOwner.setRootConstraints(Constraints.fixed(inWidth, inHeight))
}
// Pump the owner's node-animation frame clock (scroll fling / animateScrollToItem / node
// Animatables). Called once per frame by ComposeWindow with a monotonic nanos timestamp.
fun sendAnimationFrame(inNanos: Long) {
fOwner.animationFrameClock.sendFrame(inNanos)
mOwner.animationFrameClock.sendFrame(inNanos)
}
// True while a node-level animation (scroll fling, node Animatable) awaits the next
// animation frame - the node half of the window's quiescence signal (render-to-quiescence
// screenshot capture asks the window whether ANY work is still pending).
fun hasAnimationAwaiters(): Boolean = fOwner.animationFrameClock.hasAwaiters
fun hasAnimationAwaiters(): Boolean = mOwner.animationFrameClock.hasAwaiters
fun measureAndLayout() {
// Flush deferred end-of-apply work (focus invalidation etc.) before measuring, so
// requestFocus() from composition OR from a pointer event this frame takes effect -
// e.g. focus-on-click, which schedules a focus invalidation during event dispatch.
fOwner.onEndApplyChanges()
fOwner.measureAndLayout()
mOwner.onEndApplyChanges()
mOwner.measureAndLayout()
// Drop snapshot observations whose scope is no longer valid (detached nodes,
// destroyed layers, disposed draw scopes). Upstream RootNodeOwner does this after
// the measure pass; without it, every disposed subtree's measure/layout/DRAW
// observation scopes linger in the OwnerSnapshotObserver forever - each pins its
// observed object graph (a leak the P2.2 soak caught on ripple/indication draws).
fOwner.snapshotObserver.clearInvalidObservations()
mOwner.snapshotObserver.clearInvalidObservations()
}
// Draw the composed tree into [canvas] - re-records dirty layers, then walks
// the root so clean layers replay their cached content. Backends call this
// instead of rootNode.draw so retained-layer bookkeeping runs each frame.
fun drawRoot(canvas: androidx.compose.ui.graphics.Canvas) {
fOwner.renderRoot(canvas)
mOwner.renderRoot(canvas)
}
// The window wires this to set needsFrame - a layer whose content changed
// (OwnedLayer.invalidate) schedules a frame even with nothing else pending.
fun setInvalidationCallback(callback: () -> Unit) {
fOwner.onInvalidate = callback
mOwner.onInvalidate = callback
}
// The owner's FocusOwner IS a FocusManager - the window provides it as LocalFocusManager.
val focusManager: androidx.compose.ui.focus.FocusManager get() = fOwner.focusOwner
val focusManager: androidx.compose.ui.focus.FocusManager get() = mOwner.focusOwner
// ==================
// MARK: Owner-backed values for the composition-locals seed
@@ -107,19 +107,19 @@ class ComposeRootHost(inDensity: Float = 1f) {
// Provider; ComposeWindow wraps setContent in a CompositionLocalProvider whose
// values come from the ComposeOwner attached below. These accessors expose the
// right fields without leaking the `internal Owner` interface across modules.
val density: androidx.compose.ui.unit.Density get() = fOwner.density
val layoutDirection: androidx.compose.ui.unit.LayoutDirection get() = fOwner.layoutDirection
val viewConfiguration: androidx.compose.ui.platform.ViewConfiguration get() = fOwner.viewConfiguration
val graphicsContext: androidx.compose.ui.graphics.GraphicsContext get() = fOwner.graphicsContext
val inputModeManager: androidx.compose.ui.input.InputModeManager get() = fOwner.inputModeManager
val hapticFeedback: androidx.compose.ui.hapticfeedback.HapticFeedback get() = fOwner.hapticFeedBack
val textToolbar: androidx.compose.ui.platform.TextToolbar get() = fOwner.textToolbar
val windowInfo: androidx.compose.ui.platform.WindowInfo get() = fOwner.windowInfo
val density: androidx.compose.ui.unit.Density get() = mOwner.density
val layoutDirection: androidx.compose.ui.unit.LayoutDirection get() = mOwner.layoutDirection
val viewConfiguration: androidx.compose.ui.platform.ViewConfiguration get() = mOwner.viewConfiguration
val graphicsContext: androidx.compose.ui.graphics.GraphicsContext get() = mOwner.graphicsContext
val inputModeManager: androidx.compose.ui.input.InputModeManager get() = mOwner.inputModeManager
val hapticFeedback: androidx.compose.ui.hapticfeedback.HapticFeedback get() = mOwner.hapticFeedBack
val textToolbar: androidx.compose.ui.platform.TextToolbar get() = mOwner.textToolbar
val windowInfo: androidx.compose.ui.platform.WindowInfo get() = mOwner.windowInfo
// Push SDL window focus into windowInfo.isWindowFocused (focus-reactive UI).
fun setWindowFocused(inFocused: Boolean) = fOwner.setWindowFocused(inFocused)
fun setWindowFocused(inFocused: Boolean) = mOwner.setWindowFocused(inFocused)
val softwareKeyboardController: androidx.compose.ui.platform.SoftwareKeyboardController
get() = fOwner.softwareKeyboardController
get() = mOwner.softwareKeyboardController
// PointerIconService and its composition local are :ui-internal, so ComposeWindow
// can't provide the local directly. This wrapper seeds it from the owner so
@@ -127,7 +127,7 @@ class ComposeRootHost(inDensity: Float = 1f) {
@androidx.compose.runtime.Composable
fun ProvidePointerIconService(content: @androidx.compose.runtime.Composable () -> Unit) {
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.ui.platform.LocalPointerIconService provides fOwner.pointerIconService,
androidx.compose.ui.platform.LocalPointerIconService provides mOwner.pointerIconService,
content = content,
)
}
@@ -140,7 +140,7 @@ class ComposeRootHost(inDensity: Float = 1f) {
onKeyEvent) - drives text-field editing keys (backspace / arrows / enter) and
clickable Enter/Space activation. Returns true if some node consumed it. */
fun dispatchKeyEvent(inEvent: androidx.compose.ui.input.key.KeyEvent): Boolean =
runCatching { fOwner.focusOwner.dispatchKeyEvent(inEvent) }.getOrDefault(false)
runCatching { mOwner.focusOwner.dispatchKeyEvent(inEvent) }.getOrDefault(false)
// ==================
// MARK: Input - feed the vendored PointerInputEventProcessor
@@ -152,13 +152,13 @@ class ComposeRootHost(inDensity: Float = 1f) {
// `expect PointerInputEvent` has no commonMain constructor, so the actual build+dispatch
// lives in nativeMain (feedPointerToProcessor).
fun onPointerRaw(inX: Float, inY: Float, inType: Int, inButton: Int, inUptime: Long) {
feedPointerToProcessor(fOwner, inType, inButton, inUptime, inX, inY)
feedPointerToProcessor(mOwner, inType, inButton, inUptime, inX, inY)
}
// Mouse wheel - feed a scroll PointerInputEvent to the processor so the vendored
// Modifier.scrollable (MouseWheelScrollingLogic) handles it, exactly like upstream.
fun onWheel(inX: Float, inY: Float, inDeltaX: Float, inDeltaY: Float, inUptime: Long) {
feedScrollToProcessor(fOwner, inX, inY, inDeltaX, inDeltaY, inUptime)
feedScrollToProcessor(mOwner, inX, inY, inDeltaX, inDeltaY, inUptime)
}
// ==================
@@ -169,11 +169,11 @@ class ComposeRootHost(inDensity: Float = 1f) {
// COMPLETE to these methods; Sdl3DragAndDropOwner accumulates the drop
// session and dispatches through the root DragAndDropNode on COMPLETE.
fun onDropBegin() = fOwner.dragAndDropManager.dropBegin()
fun onDropPosition(inX: Float, inY: Float) = fOwner.dragAndDropManager.dropPosition(inX, inY)
fun onDropFile(inPath: String) = fOwner.dragAndDropManager.dropFile(inPath)
fun onDropText(inText: String) = fOwner.dragAndDropManager.dropText(inText)
fun onDropComplete() = fOwner.dragAndDropManager.dropComplete()
fun onDropBegin() = mOwner.dragAndDropManager.dropBegin()
fun onDropPosition(inX: Float, inY: Float) = mOwner.dragAndDropManager.dropPosition(inX, inY)
fun onDropFile(inPath: String) = mOwner.dragAndDropManager.dropFile(inPath)
fun onDropText(inText: String) = mOwner.dragAndDropManager.dropText(inText)
fun onDropComplete() = mOwner.dragAndDropManager.dropComplete()
}
// NOTE: feedPointerToProcessor / feedScrollToProcessor are plain nativeMain
@@ -18,9 +18,9 @@ import com.compose.sdl.node.impl.ComposeOwner
mouse down unless `buttons.isPrimaryPressed`, and drag/scroll gestures need the held button to
persist across Move events, so the synthesized PointerInputEvent must carry a live button mask -
not just the changed button. A single mouse => module-level flags are enough. */
private var fPrimaryDown = false
private var fSecondaryDown = false
private var fTertiaryDown = false
private var mPrimaryDown = false
private var mSecondaryDown = false
private var mTertiaryDown = false
/** Builds the internal PointerInputEvent (its constructor is native-only) from a single mouse
pointer and drives the owner's PointerInputEventProcessor. inType: 0=Move 1=Press 2=Release 3=Exit;
@@ -34,14 +34,14 @@ internal fun feedPointerToProcessor(
inY: Float,
) {
when (inType) {
1 -> when (inButton) { 0 -> fPrimaryDown = true; 1 -> fSecondaryDown = true; 2 -> fTertiaryDown = true }
2 -> when (inButton) { 0 -> fPrimaryDown = false; 1 -> fSecondaryDown = false; 2 -> fTertiaryDown = false }
1 -> when (inButton) { 0 -> mPrimaryDown = true; 1 -> mSecondaryDown = true; 2 -> mTertiaryDown = true }
2 -> when (inButton) { 0 -> mPrimaryDown = false; 1 -> mSecondaryDown = false; 2 -> mTertiaryDown = false }
}
val vButtons = PointerButtons(
isPrimaryPressed = fPrimaryDown,
isSecondaryPressed = fSecondaryDown,
isTertiaryPressed = fTertiaryDown,
isPrimaryPressed = mPrimaryDown,
isSecondaryPressed = mSecondaryDown,
isTertiaryPressed = mTertiaryDown,
)
val vButton = if (inType == 1 || inType == 2) {
when (inButton) {
@@ -57,7 +57,7 @@ internal fun feedPointerToProcessor(
uptime = inUptime,
positionOnScreen = vPos,
position = vPos,
down = fPrimaryDown || fSecondaryDown || fTertiaryDown,
down = mPrimaryDown || mSecondaryDown || mTertiaryDown,
pressure = 1f,
type = PointerType.Mouse,
activeHover = inType == 0, // Exit (3) also reports no active hover
@@ -80,28 +80,28 @@ internal class ComposeOwner(
) : Owner, OwnedLayerManager {
// The vendored measure/layout state machine, rooted at [root].
private val fDelegate = MeasureAndLayoutDelegate(root)
private val mDelegate = MeasureAndLayoutDelegate(root)
// The vendored pointer-input dispatcher. Routes a PointerInputEvent through
// HitPathTracker to the tree's PointerInputModifierNodes (hover / gesture),
// synthesizing Enter/Exit. Fed from ComposeWindow via ComposeRootHost.
private val fPointerProcessor = androidx.compose.ui.input.pointer.PointerInputEventProcessor(root)
private val mPointerProcessor = androidx.compose.ui.input.pointer.PointerInputEventProcessor(root)
// Window focus + container size backing [windowInfo], snapshot-backed so
// focus-reactive UI and LocalWindowInfo.containerSize readers recompose. Fed by
// the window: focus via setWindowFocused (SDL activation), size from setRootConstraints.
private val fWindowFocused = androidx.compose.runtime.mutableStateOf(true)
private val fContainerSize = androidx.compose.runtime.mutableStateOf(androidx.compose.ui.unit.IntSize.Zero)
private val mWindowFocused = androidx.compose.runtime.mutableStateOf(true)
private val mContainerSize = androidx.compose.runtime.mutableStateOf(androidx.compose.ui.unit.IntSize.Zero)
// Called by the window on SDL focus gain/loss.
internal fun setWindowFocused(inFocused: Boolean) { fWindowFocused.value = inFocused }
internal fun setWindowFocused(inFocused: Boolean) { mWindowFocused.value = inFocused }
// Dispatch one pointer event to the upstream PointerInputModifierNode tree.
// [this] is the PositionCalculator (Owner : PositionCalculator).
internal fun processPointerInput(inEvent: androidx.compose.ui.input.pointer.PointerInputEvent): Boolean {
val vResult = fPointerProcessor.process(inEvent, this)
val vResult = mPointerProcessor.process(inEvent, this)
// Apply the cursor the hover pipeline just set for this event (deduped in SdlCursors).
com.compose.sdl.SdlCursors.apply(fPointerIcon)
com.compose.sdl.SdlCursors.apply(mPointerIcon)
return vResult.dispatchedToAPointerInputModifier
}
@@ -115,11 +115,11 @@ internal class ComposeOwner(
// Sets the constraints the root is measured against (window pixel size in
// logical points). Call each frame before [measureAndLayout].
fun setRootConstraints(inConstraints: Constraints) {
fDelegate.updateRootConstraints(inConstraints)
mDelegate.updateRootConstraints(inConstraints)
// Report the container size (physical px, the port's layout coord space) to
// windowInfo; updates on every resize since the window re-measures per frame.
if (inConstraints.hasBoundedWidth && inConstraints.hasBoundedHeight) {
fContainerSize.value =
mContainerSize.value =
androidx.compose.ui.unit.IntSize(inConstraints.maxWidth, inConstraints.maxHeight)
}
}
@@ -138,8 +138,8 @@ internal class ComposeOwner(
forceRequest: Boolean,
scheduleMeasureAndLayout: Boolean,
) {
if (affectsLookahead) fDelegate.requestLookaheadRemeasure(layoutNode, forceRequest)
else fDelegate.requestRemeasure(layoutNode, forceRequest)
if (affectsLookahead) mDelegate.requestLookaheadRemeasure(layoutNode, forceRequest)
else mDelegate.requestRemeasure(layoutNode, forceRequest)
}
override fun onRequestRelayout(
@@ -147,25 +147,25 @@ internal class ComposeOwner(
affectsLookahead: Boolean,
forceRequest: Boolean,
) {
if (affectsLookahead) fDelegate.requestLookaheadRelayout(layoutNode, forceRequest)
else fDelegate.requestRelayout(layoutNode, forceRequest)
if (affectsLookahead) mDelegate.requestLookaheadRelayout(layoutNode, forceRequest)
else mDelegate.requestRelayout(layoutNode, forceRequest)
}
override fun measureAndLayout(sendPointerUpdate: Boolean) {
fDelegate.measureAndLayout()
fDelegate.dispatchOnPositionedCallbacks()
mDelegate.measureAndLayout()
mDelegate.dispatchOnPositionedCallbacks()
}
override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) {
fDelegate.measureAndLayout(layoutNode, constraints)
mDelegate.measureAndLayout(layoutNode, constraints)
}
override fun forceMeasureTheSubtree(layoutNode: LayoutNode, affectsLookahead: Boolean) {
fDelegate.forceMeasureTheSubtree(layoutNode, affectsLookahead)
mDelegate.forceMeasureTheSubtree(layoutNode, affectsLookahead)
}
override fun onDetach(node: LayoutNode) {
fDelegate.onNodeDetached(node)
mDelegate.onNodeDetached(node)
// Drop the detached node's read-observation scopes, exactly as upstream
// RootNodeOwner.onDetach does. Without this, every disposed node's measure/
// layout/draw observation scopes accumulate in the snapshot observer forever
@@ -174,7 +174,7 @@ internal class ComposeOwner(
}
override fun registerOnLayoutCompletedListener(listener: Owner.OnLayoutCompletedListener) {
fDelegate.registerOnLayoutCompletedListener(listener)
mDelegate.registerOnLayoutCompletedListener(listener)
}
override fun requestOnPositionedCallback(layoutNode: LayoutNode) = Unit
@@ -345,12 +345,12 @@ internal class ComposeOwner(
}
// Desired hover cursor, set by the vendored HoverIconModifierNode
// (Modifier.pointerHoverIcon) and applied via SDL in processPointerInput. null = default.
private var fPointerIcon: androidx.compose.ui.input.pointer.PointerIcon =
private var mPointerIcon: androidx.compose.ui.input.pointer.PointerIcon =
androidx.compose.ui.input.pointer.PointerIcon.Default
override val pointerIconService: PointerIconService = object : PointerIconService {
override fun getIcon(): androidx.compose.ui.input.pointer.PointerIcon = fPointerIcon
override fun getIcon(): androidx.compose.ui.input.pointer.PointerIcon = mPointerIcon
override fun setIcon(value: androidx.compose.ui.input.pointer.PointerIcon?) {
fPointerIcon = value ?: androidx.compose.ui.input.pointer.PointerIcon.Default
mPointerIcon = value ?: androidx.compose.ui.input.pointer.PointerIcon.Default
}
override fun getStylusHoverIcon(): androidx.compose.ui.input.pointer.PointerIcon? = null
override fun setStylusHoverIcon(value: androidx.compose.ui.input.pointer.PointerIcon?) {}
@@ -368,13 +368,13 @@ internal class ComposeOwner(
owner = this,
)
override val windowInfo: WindowInfo = object : WindowInfo {
override val isWindowFocused: Boolean get() = fWindowFocused.value
override val containerSize: androidx.compose.ui.unit.IntSize get() = fContainerSize.value
override val isWindowFocused: Boolean get() = mWindowFocused.value
override val containerSize: androidx.compose.ui.unit.IntSize get() = mContainerSize.value
override val containerDpSize: androidx.compose.ui.unit.DpSize
get() = with(density) {
androidx.compose.ui.unit.DpSize(
fContainerSize.value.width.toDp(),
fContainerSize.value.height.toDp(),
mContainerSize.value.width.toDp(),
mContainerSize.value.height.toDp(),
)
}
}
@@ -412,16 +412,16 @@ internal class ComposeOwner(
// End-of-apply-changes listeners - the focus system (FocusInvalidationManager) and other
// engine parts register here to defer work until changes are applied; without this the
// focus invalidation never flushes and requestFocus() (e.g. focus-on-click) never sticks.
private val fEndApplyChangesListeners = mutableListOf<() -> Unit>()
private val mEndApplyChangesListeners = mutableListOf<() -> Unit>()
override fun registerOnEndApplyChangesListener(listener: () -> Unit) {
if (listener !in fEndApplyChangesListeners) fEndApplyChangesListeners.add(listener)
if (listener !in mEndApplyChangesListeners) mEndApplyChangesListeners.add(listener)
}
override fun onEndApplyChanges() {
// Drain in order; listeners may re-register, so loop until empty.
while (fEndApplyChangesListeners.isNotEmpty()) {
val vListener = fEndApplyChangesListeners.removeAt(0)
while (mEndApplyChangesListeners.isNotEmpty()) {
val vListener = mEndApplyChangesListeners.removeAt(0)
vListener()
}
}
@@ -17,10 +17,10 @@ import com.compose.sdl.loadComposeResourceBytes
*
* Idempotent; called from ComposeWindow.installGlobals (data.kres is loadable by then).
*/
private var fRegistered = false
private var mRegistered = false
fun registerGenericFonts() {
if (fRegistered) return
fRegistered = true
if (mRegistered) return
mRegistered = true
loadComposeResourceBytes("font/NotoSansMono.ttf")?.let { IconFont.register("generic:monospace", it) }
}
@@ -38,15 +38,15 @@ import sdl3.SDL_Metal_GetLayer
(cheap - the DirectContext + device + queue persist). */
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
private var fDevice: MTLDeviceProtocol? = null
private var fQueue: MTLCommandQueueProtocol? = null
private var fLayer: CAMetalLayer? = null
private var fContext: DirectContext? = null
private var fSurface: Surface? = null
private var fRT: BackendRenderTarget? = null
private var fDrawable: CAMetalDrawableProtocol? = null
private var fWidth = 0
private var fHeight = 0
private var mDevice: MTLDeviceProtocol? = null
private var mQueue: MTLCommandQueueProtocol? = null
private var mLayer: CAMetalLayer? = null
private var mContext: DirectContext? = null
private var mSurface: Surface? = null
private var mRT: BackendRenderTarget? = null
private var mDrawable: CAMetalDrawableProtocol? = null
private var mWidth = 0
private var mHeight = 0
fun init(): Boolean {
val vView = backend.metalView ?: run {
@@ -84,22 +84,22 @@ internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
return false
}
fDevice = vDevice
fQueue = vQueue
fLayer = vLayer
fContext = vContext
mDevice = vDevice
mQueue = vQueue
mLayer = vLayer
mContext = vContext
return true
}
override val canvas: Canvas
get() = requireNotNull(fSurface) { "SkiaMetalBridge not initialised" }.canvas
get() = requireNotNull(mSurface) { "SkiaMetalBridge not initialised" }.canvas
override fun ensureSize(inWidth: Int, inHeight: Int): Boolean {
if (inWidth <= 0 || inHeight <= 0) return false
val vLayer = fLayer ?: return false
val vContext = fContext ?: return false
val vLayer = mLayer ?: return false
val vContext = mContext ?: return false
if (inWidth != fWidth || inHeight != fHeight) {
if (inWidth != mWidth || inHeight != mHeight) {
vLayer.drawableSize = CGSizeMake(inWidth.toDouble(), inHeight.toDouble())
// Keep contentsScale in lock-step with the live backing density.
// Setting it only in init() can leave it stale at 1.0 when the
@@ -107,18 +107,18 @@ internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
// drawable then gets downscaled to the layer's logical backing and
// re-upscaled by the display, softening text and aliasing edges.
vLayer.contentsScale = backend.pixelDensity.toDouble()
fWidth = inWidth
fHeight = inHeight
mWidth = inWidth
mHeight = inHeight
println("SkiaMetalBridge: drawable ${inWidth}x${inHeight} @ contentsScale ${backend.pixelDensity}")
}
// Per-frame: drop the previous frame's drawable wrappers and grab a
// fresh one. nextDrawable() may block (~16ms vsync) when 3 drawables
// are already in flight - that's the natural pacing mechanism.
fSurface?.close()
fRT?.close()
fSurface = null
fRT = null
mSurface?.close()
mRT?.close()
mSurface = null
mRT = null
val vDrawable = vLayer.nextDrawable() ?: run {
println("SkiaMetalBridge: nextDrawable returned null")
@@ -142,38 +142,38 @@ internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
println("SkiaMetalBridge: Surface.makeFromBackendRenderTarget returned null")
return false
}
fRT = vRT
fSurface = vSurface
fDrawable = vDrawable
mRT = vRT
mSurface = vSurface
mDrawable = vDrawable
return true
}
override fun present() {
val vSurface = fSurface ?: return
val vQueue = fQueue ?: return
val vDrawable = fDrawable ?: return
val vSurface = mSurface ?: return
val vQueue = mQueue ?: return
val vDrawable = mDrawable ?: return
vSurface.flushAndSubmit()
val vCommandBuffer = vQueue.commandBuffer() ?: return
vCommandBuffer.presentDrawable(vDrawable)
vCommandBuffer.commit()
fDrawable = null
mDrawable = null
}
override fun snapshot(): Image? = fSurface?.makeImageSnapshot()
override fun snapshot(): Image? = mSurface?.makeImageSnapshot()
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? =
readBackBgra(fSurface, fWidth, fHeight)
readBackBgra(mSurface, mWidth, mHeight)
override fun destroy() {
fSurface?.close()
fRT?.close()
fContext?.close()
fSurface = null
fRT = null
fContext = null
fLayer = null
fQueue = null
fDevice = null
fDrawable = null
mSurface?.close()
mRT?.close()
mContext?.close()
mSurface = null
mRT = null
mContext = null
mLayer = null
mQueue = null
mDevice = null
mDrawable = null
}
}
@@ -12,12 +12,12 @@ internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge?
actual fun rendererPreferredGpuMode(): GpuMode {
@OptIn(ExperimentalNativeApi::class)
return when (Platform.osFamily) {
OsFamily.MACOSX -> GpuMode.Skia.Metal
OsFamily.LINUX -> GpuMode.Skia.OpenGL
OsFamily.MACOSX -> GpuMode.Metal
OsFamily.LINUX -> GpuMode.OpenGL
// Windows (mingwX64, Route 1a): GPU via Skia's GL backend on an SDL WGL
// context (SkiaGLBridge, shared with Linux). CPU raster stays available
// as --gpu=software. (A native D3D12 bridge is the milestone-2 upgrade.)
OsFamily.WINDOWS -> GpuMode.Skia.OpenGL
else -> GpuMode.Software
OsFamily.WINDOWS -> GpuMode.OpenGL
else -> GpuMode.CpuRaster
}
}
@@ -13,7 +13,7 @@ import org.jetbrains.skia.Image
per frame; the concrete implementation owns whatever GPU / CPU
resources it needs (raster buffer + SDL_Texture, or a GL/Metal backend
render target). */
interface SkiaBridge {
internal interface SkiaBridge {
val canvas: Canvas
fun ensureSize(inWidth: Int, inHeight: Int): Boolean
fun present()
@@ -14,6 +14,7 @@ import org.jetbrains.skia.ImageInfo
import org.jetbrains.skia.Surface
import org.jetbrains.skia.SurfaceColorFormat
import org.jetbrains.skia.SurfaceOrigin
import sdl3.SDL_GL_MakeCurrent
import sdl3.SDL_GL_SwapWindow
// ==================
@@ -28,16 +29,37 @@ import sdl3.SDL_GL_SwapWindow
DirectContext is reused. The default GL framebuffer (id 0) is wrapped
directly; we don't manage an offscreen FBO. */
internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge {
private var fContext: DirectContext? = null
private var fRT: BackendRenderTarget? = null
private var fSurface: Surface? = null
private var fWidth = 0
private var fHeight = 0
private var mContext: DirectContext? = null
private var mRT: BackendRenderTarget? = null
private var mSurface: Surface? = null
private var mWidth = 0
private var mHeight = 0
/**
Binds THIS window's GL context to the calling thread.
A GL context is per-window but "current" is per-THREAD state, so with more
than one window open the last one to touch GL owns the thread. SDL makes a
context current once at creation (SDL3Backend.init) and nothing re-bound it
per frame, so window A issued its draw and swap against window B's context:
silent corruption while both live, and a hard crash (SIGSEGV in present)
the moment B is destroyed and the current context is left dangling.
Every entry point that touches GL calls this first. Cheap when already
current - SDL short-circuits a redundant bind, so the single-window path
pays nothing.
*/
private fun makeCurrent() {
val vWindow = backend.window ?: return
val vContext = backend.glContext ?: return
SDL_GL_MakeCurrent(vWindow.reinterpret(), vContext.reinterpret())
}
fun init(): Boolean {
return try {
fContext = DirectContext.makeGL()
fContext != null
makeCurrent()
mContext = DirectContext.makeGL()
mContext != null
} catch (t: Throwable) {
println("SkiaGLBridge: DirectContext.makeGL failed: ${t.message}")
false
@@ -45,17 +67,20 @@ internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge {
}
override val canvas: Canvas
get() = requireNotNull(fSurface) { "SkiaGLBridge not initialised" }.canvas
get() = requireNotNull(mSurface) { "SkiaGLBridge not initialised" }.canvas
override fun ensureSize(inWidth: Int, inHeight: Int): Boolean {
if (inWidth == fWidth && inHeight == fHeight && fSurface != null) return true
// BEFORE the unchanged-size early return: this runs once per frame and is
// what re-binds this window's context for the draw phase that follows.
makeCurrent()
if (inWidth == mWidth && inHeight == mHeight && mSurface != null) return true
if (inWidth <= 0 || inHeight <= 0) return false
val vContext = fContext ?: return false
val vContext = mContext ?: return false
fSurface?.close()
fRT?.close()
fSurface = null
fRT = null
mSurface?.close()
mRT?.close()
mSurface = null
mRT = null
// GL_RGBA8 = 0x8058. fbId = 0 binds the default framebuffer.
// sampleCnt = 0 means no MSAA; stencilBits = 8 is what Skia recommends.
@@ -77,31 +102,35 @@ internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge {
println("SkiaGLBridge: Surface.makeFromBackendRenderTarget returned null")
return false
}
fRT = vRT
fSurface = vSurface
fWidth = inWidth
fHeight = inHeight
mRT = vRT
mSurface = vSurface
mWidth = inWidth
mHeight = inHeight
return true
}
override fun present() {
val vSurface = fSurface ?: return
val vSurface = mSurface ?: return
val vWindow = backend.window ?: return
makeCurrent()
vSurface.flushAndSubmit()
SDL_GL_SwapWindow(vWindow.reinterpret())
}
override fun snapshot(): Image? = fSurface?.makeImageSnapshot()
override fun snapshot(): Image? = mSurface?.makeImageSnapshot()
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? =
readBackBgra(fSurface, fWidth, fHeight)
readBackBgra(mSurface, mWidth, mHeight)
override fun destroy() {
fSurface?.close()
fRT?.close()
fContext?.close()
fSurface = null
fRT = null
fContext = null
// Free this window's GPU objects against its OWN context, not whichever
// window happens to be current.
makeCurrent()
mSurface?.close()
mRT?.close()
mContext?.close()
mSurface = null
mRT = null
mContext = null
}
}
@@ -29,9 +29,9 @@ internal class SkiaRenderBackend(
private val gpuMode: GpuMode,
) : RenderBackend {
private val fBridge: SkiaBridge = buildBridge()
private val fSkiaImageCache = SkiaImageCache()
private var fCurrentCanvas: Canvas? = null
private val mBridge: SkiaBridge = buildBridge()
private val mSkiaImageCache = SkiaImageCache()
private var mCurrentCanvas: Canvas? = null
init {
// skiko's paragraph engine always supports variable-font axes (Material
@@ -60,40 +60,40 @@ internal class SkiaRenderBackend(
// Setting it once in the ctor left it dangling at a CLOSED window's (destroyed)
// image cache after a multi-window teardown → crash on the surviving window's
// next frame, so it is re-pointed at THIS backend before every frame.
private val fLeafDrawer = SkiaLeafDrawer(fSkiaImageCache)
private val mLeafDrawer = SkiaLeafDrawer(mSkiaImageCache)
override val imageLoader: ImageLoader = object : ImageLoader {
override fun intrinsicSize(inPath: String, inKind: ResourceKind): Size =
fSkiaImageCache.intrinsicSize(inPath, inKind)
mSkiaImageCache.intrinsicSize(inPath, inKind)
override fun readBytes(inPath: String): ByteArray? =
loadComposeResourceBytes(inPath)
}
private fun buildBridge(): SkiaBridge = when (gpuMode) {
is GpuMode.Skia.Metal -> makeMetalBridge(sdl) ?: error("Skia.Metal isn't supported on this target")
is GpuMode.Skia.OpenGL -> SkiaGLBridge(sdl).also { require(it.init()) { "Skia.OpenGL init failed" } }
is GpuMode.Software -> SkiaSurfaceBridge(sdl)
is GpuMode.Metal -> makeMetalBridge(sdl) ?: error("Skia.Metal isn't supported on this target")
is GpuMode.OpenGL -> SkiaGLBridge(sdl).also { require(it.init()) { "Skia.OpenGL init failed" } }
is GpuMode.CpuRaster -> SkiaSurfaceBridge(sdl)
is GpuMode.Auto -> error("SkiaRenderBackend received non-Skia gpuMode $gpuMode")
}
override fun ensureSize(inPixelWidth: Int, inPixelHeight: Int): Boolean =
fBridge.ensureSize(inPixelWidth, inPixelHeight)
mBridge.ensureSize(inPixelWidth, inPixelHeight)
override fun beginFrame(inDpr: Float) {
val canvas = fBridge.canvas
val canvas = mBridge.canvas
// Clear the surface to Material dark. Without this the Metal back
// buffer shows uninitialized GPU memory (pink). Clearing before scale
// so it covers the whole physical target.
canvas.clear(SkColor.makeARGB(0xFF, 0x12, 0x12, 0x12))
canvas.save()
if (inDpr != 1f) canvas.scale(inDpr, inDpr)
fCurrentCanvas = canvas
mCurrentCanvas = canvas
}
override fun drawRoot(inDraw: (androidx.compose.ui.graphics.Canvas) -> Unit) {
val canvas = fCurrentCanvas ?: return
val canvas = mCurrentCanvas ?: return
// Point the leaf-draw global at THIS window's renderers before drawing.
skiaLeafDrawer = fLeafDrawer
skiaLeafDrawer = mLeafDrawer
// Wrap the live skia canvas as upstream's SkiaBackedCanvas (real gradients/
// paint/shader). Its port draw contracts forward to skiaLeafDrawer. The
// ImageBitmap-backed offscreen (VectorPainter / DrawCache) now goes through
@@ -104,18 +104,18 @@ internal class SkiaRenderBackend(
}
override fun endFrame() {
fCurrentCanvas?.restore()
fCurrentCanvas = null
fBridge.present()
mCurrentCanvas?.restore()
mCurrentCanvas = null
mBridge.present()
}
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = fBridge.snapshotBgra()
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = mBridge.snapshotBgra()
override fun destroy() {
// Drop the global if it still points at this (about-to-be-destroyed) backend,
// so a stray draw before the next window's drawRoot can't hit freed renderers.
if (skiaLeafDrawer === fLeafDrawer) skiaLeafDrawer = null
fSkiaImageCache.destroy()
fBridge.destroy()
if (skiaLeafDrawer === mLeafDrawer) skiaLeafDrawer = null
mSkiaImageCache.destroy()
mBridge.destroy()
}
}
@@ -18,21 +18,21 @@ import sdl3.*
/** Owns the CPU pixel buffer Skia draws into, plus the streaming SDL_Texture
used to push that buffer onto the window. All three (buffer, Skia surface,
SDL texture) get reallocated when the window resizes. */
class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
private var fWidth = 0
private var fHeight = 0
private var fPixels: CPointer<UByteVar>? = null
private var fSurface: Surface? = null
private var fTexture: COpaquePointer? = null
internal class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
private var mWidth = 0
private var mHeight = 0
private var mPixels: CPointer<UByteVar>? = null
private var mSurface: Surface? = null
private var mTexture: COpaquePointer? = null
override val canvas: Canvas
get() = requireNotNull(fSurface) { "SkiaSurfaceBridge not initialised" }.canvas
get() = requireNotNull(mSurface) { "SkiaSurfaceBridge not initialised" }.canvas
val width: Int get() = fWidth
val height: Int get() = fHeight
val width: Int get() = mWidth
val height: Int get() = mHeight
override fun ensureSize(inWidth: Int, inHeight: Int): Boolean {
if (inWidth == fWidth && inHeight == fHeight && fSurface != null) return true
if (inWidth == mWidth && inHeight == mHeight && mSurface != null) return true
if (inWidth <= 0 || inHeight <= 0) return false
destroy()
@@ -60,34 +60,34 @@ class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
}
SDL_SetTextureBlendMode(vTexture.reinterpret(), SDL_BLENDMODE_NONE)
fPixels = vPixels
fSurface = vSurface
fTexture = vTexture
fWidth = inWidth
fHeight = inHeight
mPixels = vPixels
mSurface = vSurface
mTexture = vTexture
mWidth = inWidth
mHeight = inHeight
return true
}
/** Skia → SDL_Texture → screen, once per frame. */
override fun present() {
val vRenderer = backend.renderer ?: return
val vTexture = fTexture ?: return
val vSurface = fSurface ?: return
val vPixels = fPixels ?: return
val vTexture = mTexture ?: return
val vSurface = mSurface ?: return
val vPixels = mPixels ?: return
vSurface.flushAndSubmit()
SDL_UpdateTexture(vTexture.reinterpret(), null, vPixels, fWidth * 4)
SDL_UpdateTexture(vTexture.reinterpret(), null, vPixels, mWidth * 4)
SDL_RenderTexture(vRenderer.reinterpret(), vTexture.reinterpret(), null, null)
SDL_RenderPresent(vRenderer.reinterpret())
}
override fun snapshot(): Image? = fSurface?.makeImageSnapshot()
override fun snapshot(): Image? = mSurface?.makeImageSnapshot()
/** CPU bridge already has the raw pixels in fPixels (RGBA8888 premul).
/** CPU bridge already has the raw pixels in mPixels (RGBA8888 premul).
Repack to BGRA so we share one BMP writer with the GPU bridges. */
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? {
val vPixels = fPixels ?: return null
val vCount = fWidth * fHeight
val vPixels = mPixels ?: return null
val vCount = mWidth * mHeight
val vBytes = ByteArray(vCount * 4)
for (i in 0 until vCount) {
val vR = vPixels[i * 4 + 0]
@@ -99,17 +99,17 @@ class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
vBytes[i * 4 + 2] = vR.toByte()
vBytes[i * 4 + 3] = vA.toByte()
}
return Triple(fWidth, fHeight, vBytes)
return Triple(mWidth, mHeight, vBytes)
}
override fun destroy() {
fTexture?.let { SDL_DestroyTexture(it.reinterpret()) }
fSurface?.close()
fPixels?.let { nativeHeap.free(it) }
fTexture = null
fSurface = null
fPixels = null
fWidth = 0
fHeight = 0
mTexture?.let { SDL_DestroyTexture(it.reinterpret()) }
mSurface?.close()
mPixels?.let { nativeHeap.free(it) }
mTexture = null
mSurface = null
mPixels = null
mWidth = 0
mHeight = 0
}
}
@@ -6,7 +6,7 @@ import com.compose.sdl.renderer.skia.SkiaBridge
// MARK: Windows (mingwX64) GPU defaults
// ==================
// No Metal on Windows. The default Skia bridge is CPU raster (GpuMode.Software →
// No Metal on Windows. The default Skia bridge is CPU raster (GpuMode.CpuRaster →
// SkiaSurfaceBridge), so no GPU-context bridge is provided here yet. A D3D/Vulkan
// bridge can be added later (Route 1a milestone 2).
internal actual fun makeMetalBridge(backend: SDL3Backend): SkiaBridge? = null
+6 -957
View File
@@ -2,7 +2,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -10,29 +9,17 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.decodeToImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.compose.sdl.Window
import com.compose.sdl.nativeComposeApp
import com.compose.sdl.nativeComposeWindow
import demo.registry.allCategories
import demo.shell.App
import screens.ExtraWindows
import utils.encodeBmpBgra32
import utils.parseArgs
import utils.writeFile
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.toKString
// ==================
// MARK: Entry point
@@ -79,6 +66,12 @@ fun main(args: Array<String>) {
runSearchEscTest()
return
}
// Verifies the Compose Desktop window attributes (undecorated / resizable /
// alwaysOnTop / focusable / enabled) reach SDL, plus the raw SDL escape hatch.
if (args.any { it == "--winattrtest" }) {
runWindowAttrTest()
return
}
// Verifies nativeComposeApp multi-window: two Windows render concurrently,
// one closes via state, the app keeps running on the survivor.
if (args.any { it == "--multiwintest" }) {
@@ -288,947 +281,3 @@ private fun com.compose.sdl.ComposeWindowScope.ExtraWindowContent(inId: Int) {
}
}
}
/** Boots a window with a BackHandler, injects an Escape key through the live SDL
path, and asserts the handler fired - proving ComposeWindow's
BackNavigationInput drives the NavigationEventDispatcher (the mechanism that
collapses an expanded m3 SearchBar on Escape). */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
private fun runBackTest() {
val vBackNoFocus = mutableStateOf(false)
val vBackFocused = mutableStateOf(false)
val vText = mutableStateOf("")
nativeComposeWindow(
title = "backtest",
width = 400,
height = 200,
onFrame = { _, frameIndex ->
when (frameIndex) {
// Phase 1: Escape with NOTHING focused.
20 -> {
com.compose.sdl.injectKey(41, true) // SDL_SCANCODE_ESCAPE
com.compose.sdl.injectKey(41, false)
true
}
// Phase 2: click the text field to focus it, then Escape -
// the user-facing SearchBar scenario (field focused while
// the back handler should collapse the bar).
30 -> { com.compose.sdl.injectMouseEvent(1, 200f, 100f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 100f); true }
44 -> {
com.compose.sdl.injectKey(41, true)
com.compose.sdl.injectKey(41, false)
true
}
70 -> {
println("backtest: noFocus=${vBackNoFocus.value} focused=${vBackFocused.value}")
println(
if (vBackNoFocus.value && vBackFocused.value)
"backtest: PASS (Escape completed back navigation with and without a focused field)"
else
"backtest: FAIL (noFocus=${vBackNoFocus.value} focusedField=${vBackFocused.value})"
)
false
}
else -> true
}
},
) {
@Suppress("DEPRECATION")
androidx.compose.ui.backhandler.BackHandler(enabled = true) {
if (!vBackNoFocus.value) vBackNoFocus.value = true else vBackFocused.value = true
}
MaterialTheme(colorScheme = darkColorScheme()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// The NEW state-based field - the one m3 SearchBar's InputField
// uses - so the probe exercises its key handler, not the legacy
// value-based one.
androidx.compose.foundation.text.BasicTextField(
state = androidx.compose.foundation.text.input.rememberTextFieldState(),
)
}
}
}
}
/** Drives a SharedTransitionLayout shared-element morph both directions from
the frame counter (no clicks) - it requires the LOOKAHEAD pass, which dies
with "LookaheadDelegate has not been measured yet" if the owner drops
affectsLookahead measure/relayout requests. Reaching the end frame = PASS. */
@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
private fun runSharedTest() {
val vBig = mutableStateOf(false)
nativeComposeWindow(
title = "sharedtest",
width = 300,
height = 300,
onFrame = { _, vFrame ->
when (vFrame) {
20 -> { vBig.value = true; true } // small → big morph
70 -> { vBig.value = false; true } // big → small morph
130 -> {
println("sharedtest: PASS (shared-element morph ran both directions)")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
androidx.compose.animation.SharedTransitionLayout {
Column {
androidx.compose.animation.AnimatedVisibility(visible = !vBig.value) {
Box(
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(key = "box"),
animatedVisibilityScope = this@AnimatedVisibility,
)
.size(48.dp)
.background(Color(0xFF7C4DFF), RoundedCornerShape(8.dp)),
)
}
androidx.compose.animation.AnimatedVisibility(visible = vBig.value) {
Box(
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(key = "box"),
animatedVisibilityScope = this@AnimatedVisibility,
)
.size(160.dp)
.background(Color(0xFF26A69A), RoundedCornerShape(24.dp)),
)
}
}
}
}
}
}
/** Boots TWO windows via nativeComposeApp, asserts both render, closes the
second by flipping the state that composes its Window(), and asserts the app
keeps running on the first - the multi-window lifecycle end-to-end. */
private fun runMultiWindowTest() {
val vShowSecond = mutableStateOf(true)
var vSecondFrames = 0
var vResult = "FAIL (main window never reached the end frame)"
nativeComposeApp {
Window(
onCloseRequest = ::exitApplication,
title = "multiwin main",
width = 400,
height = 200,
onFrame = { _, vFrame ->
when (vFrame) {
40 -> {
if (vSecondFrames == 0) {
vResult = "FAIL (second window never rendered)"
false
} else {
vShowSecond.value = false // close the second window via state
true
}
}
80 -> {
vResult =
if (!vShowSecond.value && vSecondFrames > 0)
"PASS (both windows rendered; second closed via state; app survived on the first)"
else "FAIL (second=$vSecondFrames showSecond=${vShowSecond.value})"
false
}
else -> true
}
},
) {
Text("main window", color = Color.White)
}
if (vShowSecond.value) {
Window(
onCloseRequest = { vShowSecond.value = false },
title = "multiwin second",
width = 300,
height = 150,
onFrame = { _, _ -> vSecondFrames++; true },
) {
Text("second window", color = Color.White)
}
}
}
println("multiwintest: secondFrames=$vSecondFrames")
println("multiwintest: $vResult")
}
/** Boots the REAL Search screen, clicks the first SearchBar's input field
(expands it), presses Escape, and writes esc_before.bmp / esc_after.bmp -
the expanded overlay must be visible in `before` and gone in `after`. */
private fun runSearchEscTest() {
fun snap(inBridge: com.compose.sdl.RenderBackend, inName: String) {
val vSnap = inBridge.snapshotBgra() ?: return
val (vW, vH, vBgra) = vSnap
writeFile(inName, encodeBmpBgra32(vW, vH, vBgra))
println("searchesctest: wrote $inName")
}
nativeComposeWindow(
title = "searchesctest",
width = 1000,
height = 700,
onFrame = { vBridge, vFrame ->
when (vFrame) {
30 -> { com.compose.sdl.injectMouseEvent(1, 200f, 230f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 230f); true }
70 -> { snap(vBridge, "esc_before.bmp"); true }
80 -> {
com.compose.sdl.injectKey(41, true) // Escape
com.compose.sdl.injectKey(41, false)
true
}
130 -> { snap(vBridge, "esc_after.bmp"); false }
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
val vScroll = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(vScroll)
.padding(24.dp),
) {
screens.M3SearchScreen()
}
}
}
}
/* Boots an empty window, then composes screens.Navigation3Screen() at frame 30 -
AFTER the window lifecycle reached RESUMED - mirroring the real sidebar flow.
Crashes here (e.g. enableSavedStateHandles' INITIALIZED/CREATED contract) never
reproduce under --screen, which composes during the initial CREATED composition.
PASS = a screenshot gets written and the app exits cleanly. */
/** P2.2 soak - cycle through EVERY registered screen kCycles times in ONE process,
disposing each via a changing key() so composition + layer (skiko RenderNode)
allocate-and-release is exercised repeatedly. After each full cycle, GC then
record peak RSS (getrusage ru_maxrss). Peak RSS is monotonic, so after cycle 1 it
already reflects visiting every screen once; if there is NO leak it plateaus, if
there IS one it keeps climbing each cycle. PASS iff last-cycle peak stays within a
ceiling of the first-cycle peak. */
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class, ExperimentalForeignApi::class)
private fun runSoakTest() {
// Disable never-settling animations so RSS reflects composition/layer lifetime,
// not live animation-state churn (same seed the screenshot path uses).
com.compose.sdl.disableInfiniteAnimations = true
com.compose.sdl.useVirtualFrameTime = true
val vAll = allCategories().flatMap { it.screens }
// CDN_SOAK_SCREEN=<name> repeats ONE screen 40x/cycle (bisect a specific screen);
// default cycles through every screen.
val vTarget = platform.posix.getenv("CDN_SOAK_SCREEN")?.toKString()
val vScreens = if (vTarget != null) { val vS = vAll.first { it.name.equals(vTarget, ignoreCase = true) }; List(40) { vS } }
else vAll
val vIndex = mutableStateOf(0)
// CDN_SOAK_STATIC=1: mount ONE screen once, never remount - measure RSS every 120 frames.
// Isolates a per-FRAME leak (RSS climbs with no remounts) from a per-MOUNT leak.
val vStatic = platform.posix.getenv("CDN_SOAK_STATIC")?.toKString() == "1"
val kFramesPerScreen = if (vStatic) 120 else 3
val kCycles = platform.posix.getenv("CDN_SOAK_CYCLES")?.toKString()?.toIntOrNull() ?: 3
val vRssPerCycleMb = mutableListOf<Long>()
var vShown = 0
nativeComposeWindow(
title = "soaktest",
width = 1000,
height = 700,
onFrame = { _, vFrame ->
if (vFrame > 0 && vFrame % kFramesPerScreen == 0) {
if (vStatic) {
// No remount - just pump frames on the one mounted screen.
kotlin.native.runtime.GC.collect()
vRssPerCycleMb.add(currentResidentMb())
println("soaktest[static]: measure ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
} else {
vShown++
vIndex.value = vShown % vScreens.size
if (vShown % vScreens.size == 0) {
kotlin.native.runtime.GC.collect()
vRssPerCycleMb.add(currentResidentMb())
println("soaktest: cycle ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
}
}
}
if (vRssPerCycleMb.size >= kCycles) {
val vFirst = vRssPerCycleMb.first()
val vLast = vRssPerCycleMb.last()
val vGrowth = vLast - vFirst
val vCeiling = maxOf(48L, vFirst / 4) // 25% or 48MB, whichever larger
println("soaktest: currentRSS/cycle(MB)=$vRssPerCycleMb (${vScreens.size} screens x $kCycles cycles)")
if (vGrowth <= vCeiling) {
println("soaktest: PASS (current RSS grew ${vGrowth}MB over cycles 1->$kCycles, within ${vCeiling}MB ceiling)")
} else {
println("soaktest: FAIL (current RSS grew ${vGrowth}MB > ${vCeiling}MB ceiling - possible leak)")
}
false
} else true
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
val vScroll = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(vScroll)
.padding(24.dp),
) {
// key(vShown) forces a FULL dispose+recompose each advance (even when the
// target screen repeats) - the allocate/release churn we soak. Reading
// vIndex.value (a State) is what triggers the recomposition each mount.
key(vShown) {
vScreens[vIndex.value].content()
}
}
}
}
}
/** CURRENT resident set (MB): current RSS can DROP after GC, so it distinguishes a
true leak (ratchets up) from K/N allocator high-water, unlike getrusage's
monotonic peak. posix reads it from `ps`; mingw has no `ps`/popen and returns
-1 (the soak gate runs on macOS/Linux - see scripts/verify-mac.sh). */
internal expect fun currentResidentMb(): Long
/** --localetest: prints Locale.current / LocaleList.current (should reflect the OS
preferred locales via SDL) and screenshots an M3 DatePicker, whose headline and
navigation labels are M3-translated by Locale.current. Run under a forced locale,
e.g. `demo.kexe --localetest -AppleLanguages "(fr-FR)"` on macOS. */
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
private fun runLocaleTest() {
nativeComposeWindow(
title = "localetest",
width = 420,
height = 560,
onFrame = { vBridge, vFrame ->
when (vFrame) {
2 -> {
val vCur = androidx.compose.ui.text.intl.Locale.current
val vList = androidx.compose.ui.text.intl.LocaleList.current
println("localetest: Locale.current=${vCur.toLanguageTag()}")
println("localetest: LocaleList.current=[${vList.localeList.joinToString { it.toLanguageTag() }}]")
true
}
40 -> {
val vSnap = vBridge.snapshotBgra()
if (vSnap != null) {
val (vW, vH, vBgra) = vSnap
writeFile("localetest.bmp", encodeBmpBgra32(vW, vH, vBgra))
println("localetest: wrote localetest.bmp (${vW}x${vH})")
} else println("localetest: FAIL (no snapshot)")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
androidx.compose.material3.Surface(modifier = Modifier.fillMaxSize()) {
val vState = androidx.compose.material3.rememberDatePickerState()
androidx.compose.material3.DatePicker(state = vState)
}
}
}
}
/** --cursortest: injects hover moves over pointerHoverIcon(Text) / (Hand) regions
and a bare background, printing the applied SDL system cursor after each. Proves
PointerIcon -> SDL_SetCursor end-to-end through the live hover pipeline. */
private fun runCursorTest() {
var vBg = "?"; var vText = "?"; var vHand = "?"
nativeComposeWindow(
title = "cursortest",
width = 400,
height = 400,
onFrame = { _, vFrame ->
when (vFrame) {
10 -> { com.compose.sdl.injectMouseEvent(0, 200f, 300f); true } // background (below both boxes)
14 -> { vBg = com.compose.sdl.appliedCursorName(); true }
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 50f); true } // Text box
24 -> { vText = com.compose.sdl.appliedCursorName(); true }
30 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true } // Hand box
34 -> {
vHand = com.compose.sdl.appliedCursorName()
println("cursortest: background=$vBg text=$vText hand=$vHand")
val vOk = vBg.endsWith("DEFAULT") && vText.endsWith("TEXT") && vHand.endsWith("POINTER")
println(if (vOk) "cursortest: PASS" else "cursortest: FAIL")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
Box(Modifier.fillMaxWidth().height(100.dp).pointerHoverIcon(PointerIcon.Text))
Box(Modifier.fillMaxWidth().height(100.dp).pointerHoverIcon(PointerIcon.Hand))
}
}
}
}
/** --windowinfotest: prints LocalWindowInfo.isWindowFocused / containerSize /
containerDpSize. containerSize starts Zero and becomes the window pixel size
after the first measure - proof it's fed from the live root constraints. */
private fun runWindowInfoTest() {
nativeComposeWindow(
title = "windowinfotest",
width = 400,
height = 300,
onFrame = { _, vFrame -> vFrame < 6 },
) {
val vWi = androidx.compose.ui.platform.LocalWindowInfo.current
androidx.compose.runtime.LaunchedEffect(vWi.containerSize, vWi.isWindowFocused) {
println("windowinfotest: focused=${vWi.isWindowFocused} containerSize=${vWi.containerSize} containerDpSize=${vWi.containerDpSize}")
}
MaterialTheme(colorScheme = darkColorScheme()) {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
}
}
}
/** --imetest: focuses a BasicTextField, injects an IME composition (SDL TEXT_EDITING
"ni"), then a commit (SDL TEXT_INPUT "に"). Verifies the preedit shows a
composing region and the commit REPLACES it (not appends) - the real IME path. */
private fun runImeTest() {
val vField = mutableStateOf(androidx.compose.ui.text.input.TextFieldValue(""))
nativeComposeWindow(
title = "imetest",
width = 500,
height = 200,
onFrame = { _, vFrame ->
when (vFrame) {
12 -> { com.compose.sdl.injectMouseEvent(1, 250f, 100f); true } // focus centered field
14 -> { com.compose.sdl.injectMouseEvent(2, 250f, 100f); true }
34 -> { com.compose.sdl.injectTextEditing("ni"); true } // composing (preedit)
40 -> { println("imetest: composing text='${vField.value.text}' composition=${vField.value.composition}"); true }
50 -> { com.compose.sdl.injectTextInput("に"); true } // commit "に"
60 -> {
println("imetest: committed text='${vField.value.text}' composition=${vField.value.composition}")
val vOk = vField.value.text == "に" && vField.value.composition == null
println(if (vOk) "imetest: PASS" else "imetest: FAIL")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
androidx.compose.foundation.text.BasicTextField(
value = vField.value,
onValueChange = { vField.value = it },
textStyle = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 24.sp),
cursorBrush = androidx.compose.ui.graphics.SolidColor(Color.White),
)
}
}
}
}
/** --imagebytestest: decodes an in-memory 2x2 BMP via ByteArray.decodeToImageBitmap()
(routes to createImageBitmap(bytes)) through Skia's image decoder. Needs a window
so the render backend installs the encoded-image decoder. */
private fun runImageBytesTest() {
nativeComposeWindow(
title = "imagebytestest",
width = 200,
height = 200,
onFrame = { _, vFrame ->
if (vFrame >= 2) {
try {
val vBitmap = tinyRedBmp().decodeToImageBitmap()
println("imagebytestest: decoded ${vBitmap.width}x${vBitmap.height}")
println(if (vBitmap.width == 2 && vBitmap.height == 2) "imagebytestest: PASS" else "imagebytestest: FAIL (wrong size)")
} catch (e: Throwable) {
println("imagebytestest: FAIL (${e::class.simpleName}: ${e.message})")
}
false
} else true
},
) {}
}
/** --fonttest: same sample in FontFamily.Default (sans) and FontFamily.Monospace.
Monospace used to collapse to the default sans; now it renders NotoSansMono
(bundled because this source references FontFamily.Monospace). */
private fun runFontTest() {
nativeComposeWindow(
title = "fonttest",
width = 560,
height = 220,
onFrame = { vBridge, vFrame ->
if (vFrame >= 10) {
val vSnap = vBridge.snapshotBgra()
if (vSnap != null) {
val (vW, vH, vBgra) = vSnap
writeFile("fonttest.bmp", encodeBmpBgra32(vW, vH, vBgra))
println("fonttest: wrote fonttest.bmp (${vW}x${vH})")
}
false
} else true
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Column(Modifier.fillMaxSize().background(Color(0xFF202020)).padding(20.dp)) {
val vSample = "Illegal1 lIO0 {}=>"
Text(vSample, color = Color.White, fontSize = 30.sp)
Text(
vSample, color = Color.Cyan, fontSize = 30.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
)
}
}
}
}
// A minimal 2x2 24bpp red BMP (no compression) - valid decoder input.
private fun tinyRedBmp(): ByteArray {
fun le16(v: Int) = byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte())
fun le32(v: Int) = byteArrayOf(
(v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(),
((v shr 16) and 0xFF).toByte(), ((v shr 24) and 0xFF).toByte(),
)
val vPixel = byteArrayOf(0, 0, 0xFF.toByte()) // BGR red
val vRow = vPixel + vPixel + byteArrayOf(0, 0) // 2px + 2 pad = 8 bytes (4-byte aligned)
val vPixels = vRow + vRow // 2 rows = 16 bytes
val vFileHeader = byteArrayOf('B'.code.toByte(), 'M'.code.toByte()) + le32(70) + le32(0) + le32(54)
val vDib = le32(40) + le32(2) + le32(2) + le16(1) + le16(24) + le32(0) + le32(16) +
le32(2835) + le32(2835) + le32(0) + le32(0)
return vFileHeader + vDib + vPixels
}
private fun runNav3Test() {
val vShow = mutableStateOf(false)
nativeComposeWindow(
title = "nav3test",
width = 1000,
height = 700,
onFrame = { vBridge, vFrame ->
when (vFrame) {
30 -> { vShow.value = true; true }
// Push Detail #1 (click its card) → per-entry lifecycle goes
// CREATED/STARTED during the slide, RESUMED once settled.
60 -> { com.compose.sdl.injectMouseEvent(1, 500f, 216f); true }
62 -> { com.compose.sdl.injectMouseEvent(2, 500f, 216f); true }
// Pop with ESC → ON_PAUSE, then CREATED while animating out,
// then the entry disposes.
120 -> {
com.compose.sdl.injectKey(41, true)
com.compose.sdl.injectKey(41, false)
true
}
180 -> {
val vSnap = vBridge.snapshotBgra()
if (vSnap != null) {
val (vW, vH, vBgra) = vSnap
writeFile("nav3late.bmp", encodeBmpBgra32(vW, vH, vBgra))
println("nav3test: wrote nav3late.bmp (${vW}x${vH})")
println("nav3test: PASS (Navigation3 composed at RESUMED; push + pop ran)")
} else println("nav3test: FAIL (no snapshot)")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
if (vShow.value) {
Box(modifier = Modifier.padding(24.dp)) { screens.Navigation3Screen() }
}
}
}
}
}
/** Frame-by-frame trace of AnimatedVisibility(fadeIn+expandVertically / fadeOut+
shrinkVertically): logs the AV container's animated size and the window-Y of a
marker Box below it every frame, toggling visibility three times. Diagnoses
(a) a size snap between just-before-end and end of the animation and (b)
instant (non-animated) transitions on subsequent toggles.
Expected healthy trace: ~24 smooth frames per toggle ending exactly at 0/60,
PLUS one 16px marker jump when the fully-shrunk node unmounts (exit end) or
mounts (enter start) - that's the parent's spacedBy(16) collapsing, inherent
upstream behaviour (spacing applies to zero-height children too), NOT a bug. */
private fun runAnimVisTest() {
val vShown = mutableStateOf(true)
var vAvSize = androidx.compose.ui.unit.IntSize(-1, -1)
var vMarkerY = -1f
var vLastLog = ""
nativeComposeWindow(
title = "animvistest",
width = 600,
height = 500,
onFrame = { _, vFrame ->
// Log only when something moved - keeps the trace readable.
val vLine = "size=$vAvSize markerY=$vMarkerY shown=${vShown.value}"
if (vLine != vLastLog) {
println("animvistest: f=$vFrame $vLine")
vLastLog = vLine
}
when (vFrame) {
60 -> { println("animvistest: === HIDE 1 ==="); vShown.value = false; true }
140 -> { println("animvistest: === SHOW 2 ==="); vShown.value = true; true }
220 -> { println("animvistest: === HIDE 3 ==="); vShown.value = false; true }
300 -> false
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
// spacedBy mirrors the demo's Section column - the end-of-exit jump
// reported on the FoundationExtra screen involves the spacing around
// the AnimatedVisibility node collapsing when the node unmounts.
Column(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(Modifier.size(30.dp).background(androidx.compose.ui.graphics.Color(0xFFC07040)))
androidx.compose.animation.AnimatedVisibility(
visible = vShown.value,
modifier = Modifier.onSizeChanged { vAvSize = it },
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.background(androidx.compose.ui.graphics.Color(0xFF7040C0)),
) {
Text("Animated content", modifier = Modifier.padding(14.dp))
}
}
Box(
modifier = Modifier
.size(20.dp)
.background(androidx.compose.ui.graphics.Color(0xFF40C070))
.onGloballyPositioned { vMarkerY = it.positionInRoot().y },
)
}
}
}
}
/** Boots a window whose full-size surface opens an m3 AlertDialog on click (the real
material3 → ui.window.Dialog path, default DialogProperties ⇒ animateTransition on),
injects the click, then dumps screenshots during the 0.2s appearance animation and
after it settles; then injects Escape and dumps mid-disappearance (0.1s reverse,
via the popup host's exit deferral) + after. Visual check: mid shots must show the
dialog semi-transparent, slightly scaled-down and shifted down vs the settled shot,
and exit_end must show no dialog at all - skiko-parity animation both ways. */
private fun runDialogAnimTest() {
fun snap(inBridge: com.compose.sdl.RenderBackend, inName: String) {
val vSnap = inBridge.snapshotBgra() ?: return
val (vW, vH, vBgra) = vSnap
writeFile(inName, encodeBmpBgra32(vW, vH, vBgra))
println("dialoganimtest: wrote $inName")
}
nativeComposeWindow(
title = "dialoganimtest",
width = 1000,
height = 700,
onFrame = { vBridge, vFrame ->
when (vFrame) {
30 -> { com.compose.sdl.injectMouseEvent(1, 500f, 350f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 500f, 350f); true }
36 -> { snap(vBridge, "dialog_mid1.bmp"); true }
40 -> { snap(vBridge, "dialog_mid2.bmp"); true }
90 -> { snap(vBridge, "dialog_end.bmp"); true }
92 -> {
com.compose.sdl.injectKey(41, true) // Escape → dismiss
com.compose.sdl.injectKey(41, false)
true
}
97 -> { snap(vBridge, "dialog_exit_mid.bmp"); true }
140 -> { snap(vBridge, "dialog_exit_end.bmp"); false }
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
var vShow by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.clickable { vShow = true },
)
if (vShow) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { vShow = false },
title = { Text("Animated dialog") },
text = { Text("Appearance animation parity with the JVM (skiko) Dialog.") },
confirmButton = {
androidx.compose.material3.TextButton(onClick = { vShow = false }) { Text("OK") }
},
)
}
}
}
}
/** Boots a real window, then builds an upstream Paragraph via the
vendored factory (→ SkiaParagraph) for a long string constrained to a narrow width. Verifies it
wrapped to multiple lines, has positive size, and that getHorizontalPosition/getOffsetForPosition
round-trip - proving the paragraph-engine measurement bridge works. */
private fun runParagraphTest() {
nativeComposeWindow(
title = "paragraphtest",
width = 400,
height = 200,
onFrame = { _, frameIndex ->
if (frameIndex == 10) {
val vP = androidx.compose.ui.text.Paragraph(
text = "Hello world foo bar baz qux quux corge grault garply waldo",
style = androidx.compose.ui.text.TextStyle(fontSize = 16.sp),
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 120),
density = androidx.compose.ui.unit.Density(1f),
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
)
val vHpos = vP.getHorizontalPosition(3, true)
val vOffBack = vP.getOffsetForPosition(androidx.compose.ui.geometry.Offset(vHpos, 2f))
val vLine = vP.getLineForOffset(30)
println("paragraphtest: lineCount=${vP.lineCount} w=${vP.width} h=${vP.height} hpos(3)=$vHpos offBack=$vOffBack lineFor(30)=$vLine")
val vPass = vP.lineCount >= 2 && vP.height > 0f && vP.width > 0f && vOffBack in 2..4
println(if (vPass) "paragraphtest: PASS (real width-wrapped Paragraph via SkiaParagraph)" else "paragraphtest: FAIL")
false
} else true
},
) {
Box(modifier = Modifier.fillMaxSize()) {}
}
}
/** Prints paragraph metrics (single-line cell, lineHeight-styled single + triple line,
first baseline) for a font-size sweep at density 1 - the native half of the
metrics-alignment probe. Mirror of MainJvm's `--metrics`; both must print the same
numbers for the parity text drift to vanish. */
private fun runMetricsProbe() {
fun paragraph(inText: String, inSize: Int, inLineHeight: Int?, inM3Style: Boolean): androidx.compose.ui.text.Paragraph =
androidx.compose.ui.text.Paragraph(
text = inText,
style = androidx.compose.ui.text.TextStyle(
fontSize = inSize.sp,
lineHeight = inLineHeight?.sp ?: androidx.compose.ui.unit.TextUnit.Unspecified,
lineHeightStyle = if (inM3Style) {
androidx.compose.ui.text.style.LineHeightStyle(
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
)
} else null,
),
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 10_000),
density = androidx.compose.ui.unit.Density(1f),
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
)
nativeComposeWindow(
title = "metricsprobe",
width = 300,
height = 200,
onFrame = { _, frameIndex ->
if (frameIndex == 10) {
for (vSize in listOf(11, 12, 14, 16, 22, 24)) {
val vLh = vSize + 6
val vCell = paragraph("Hg", vSize, null, false)
val vOne = paragraph("Hg", vSize, vLh, false)
val vThree = paragraph("Hg\nHg\nHg", vSize, vLh, false)
val vOneM3 = paragraph("Hg", vSize, vLh, true)
val vThreeM3 = paragraph("Hg\nHg\nHg", vSize, vLh, true)
println(
"metrics: size=$vSize lh=$vLh cell=${vCell.height} " +
"one=${vOne.height} three=${vThree.height} " +
"base1=${vOne.firstBaseline} base3=${vThree.lastBaseline} " +
"oneM3=${vOneM3.height} threeM3=${vThreeM3.height} base1M3=${vOneM3.firstBaseline}"
)
}
for ((vS, vL) in listOf(24 to 24, 24 to 25, 32 to 24, 16 to 16)) {
val vB = paragraph("Hg", vS, vL, true)
println("metrics: boundary $vS/$vL m3=${vB.height} base=${vB.firstBaseline}")
}
val vBig = paragraph("42", 48, 24, false)
val vBigM3 = paragraph("42", 48, 24, true)
println("metrics: big48/lh24 raw=${vBig.height} base=${vBig.firstBaseline} m3=${vBigM3.height} baseM3=${vBigM3.firstBaseline}")
println("metricsprobe: DONE")
false
} else true
},
) {
Box(modifier = Modifier.fillMaxSize()) {}
}
}
// ==================
// MARK: clicktest
// ==================
/** Boots a real nativeComposeWindow with a full-size clickable box, then injects
move→press→release SDL mouse events a few frames apart (giving the upstream
clickable's gesture coroutine time to launch + await between frames), and prints
PASS/FAIL based on whether onClick fired. Proves the whole vendored interaction
pipeline works under the real Sdl3MainDispatcher + frame loop. */
private fun runClickTest() {
var vClicks = 0
nativeComposeWindow(
title = "clicktest",
width = 400,
height = 300,
onFrame = { _, frameIndex ->
when (frameIndex) {
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true }
26 -> { com.compose.sdl.injectMouseEvent(1, 200f, 150f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 150f); true }
70 -> {
println(
if (vClicks > 0) "clicktest: PASS ($vClicks click(s) via upstream clickable)"
else "clicktest: FAIL (0 clicks - upstream clickable did not fire)"
)
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF303040))
.clickable { vClicks++; println("clicktest: onClick fired -> $vClicks") },
) {
Text("Click test", color = Color.White, fontSize = 16.sp)
}
}
}
}
/** Same injection harness as clicktest, but the target is a Material Switch (Modifier.toggleable
from the vendored foundation.selection). Asserts onCheckedChange flipped the state - proving
toggleable rides the same verified upstream interaction path as clickable. */
private fun runToggleTest() {
var vChecked = false
var vChanges = 0
nativeComposeWindow(
title = "toggletest",
width = 400,
height = 300,
onFrame = { _, frameIndex ->
when (frameIndex) {
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true }
26 -> { com.compose.sdl.injectMouseEvent(1, 200f, 150f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 150f); true }
70 -> {
println(
if (vChanges > 0 && vChecked) "toggletest: PASS (switch toggled to $vChecked via toggleable)"
else "toggletest: FAIL (changes=$vChanges checked=$vChecked)"
)
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
androidx.compose.material3.Switch(
checked = vChecked,
onCheckedChange = { vChecked = it; vChanges++; println("toggletest: onCheckedChange -> $it") },
)
}
}
}
}
/** Boots a window with a real BasicTextField, clicks it to focus (focus-on-click via the
FocusOwner), then injects TEXT_INPUT ("A","B") and a Backspace key through the live SDL path.
Asserts the field edits to "A" - proving click-to-focus + typing + editing keys route to the
focused field via ComposeRootHost.dispatchKeyEvent + the synthesised typed-key path. */
private fun runKeyTest() {
val vText = mutableStateOf("")
nativeComposeWindow(
title = "keytest",
width = 400,
height = 200,
onFrame = { _, frameIndex ->
when (frameIndex) {
12 -> { com.compose.sdl.injectMouseEvent(1, 200f, 100f); true } // click field to focus
14 -> { com.compose.sdl.injectMouseEvent(2, 200f, 100f); true }
24 -> { com.compose.sdl.injectTextInput("A"); true }
28 -> { com.compose.sdl.injectTextInput("B"); true }
32 -> { com.compose.sdl.injectKey(42, true); com.compose.sdl.injectKey(42, false); true } // Backspace → "A"
70 -> {
println("keytest: real BasicTextField value='${vText.value}'")
println(
if (vText.value == "A") "keytest: PASS (click-to-focus + type 'AB' + backspace = 'A')"
else "keytest: FAIL (expected 'A')"
)
false
}
else -> true
}
},
) {
// The exact regression path: a real BasicTextField, focused by clicking it,
// receiving typed text (SDL TEXT_INPUT) + editing keys (Backspace).
MaterialTheme(colorScheme = darkColorScheme()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
androidx.compose.foundation.text.BasicTextField(
value = vText.value,
onValueChange = { vText.value = it },
)
}
}
}
}
/** Boots a tall Column wrapped in the vendored Modifier.verticalScroll, injects several wheel-down
events through the live SDL path (→ processor → MouseWheelScrollingLogic), and asserts the
ScrollState offset advanced - proving upstream scrolling works end-to-end. */
private fun runScrollTest() {
val vScroll = androidx.compose.foundation.ScrollState(0)
nativeComposeWindow(
title = "scrolltest",
width = 400,
height = 300,
onFrame = { _, frameIndex ->
when {
frameIndex in 20..40 && frameIndex % 2 == 0 -> {
com.compose.sdl.injectWheel(200f, 150f, 0f, -3f) // wheel down
true
}
frameIndex == 90 -> {
println("scrolltest: ScrollState.value=${vScroll.value} maxValue=${vScroll.maxValue}")
println(
if (vScroll.value > 0) "scrolltest: PASS (scrolled to ${vScroll.value}px)"
else "scrolltest: FAIL (did not scroll)"
)
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(vScroll)) {
repeat(40) { i ->
Text("Scroll row $i - lorem ipsum dolor sit", color = Color.White, fontSize = 16.sp)
}
}
}
}
}
@@ -0,0 +1,81 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import com.compose.sdl.ComposeNativeWindow
import com.compose.sdl.nativeComposeWindow
/**
`--winattrtest`: opens a window with the non-default Compose Desktop attributes
set, then checks four things and exits -
1. the attributes reach SDL and read back off the facade,
2. the raw SDL escape hatch hands out the pointers the resolved renderer owns,
3. onPreviewKeyEvent sees a key BEFORE the focused content does,
4. `enabled = false` drops input entirely.
*/
fun runWindowAttrTest() {
var vPreviewSaw = 0
var vKeySaw = 0
var vFacade: ComposeNativeWindow? = null
var vFrames = 0
nativeComposeWindow(
title = "attr probe",
width = 420,
height = 240,
undecorated = true,
resizable = false,
alwaysOnTop = true,
focusable = true,
enabled = true,
// Returning false lets the event continue down the chain, so this also
// proves preview runs first without swallowing anything.
onPreviewKeyEvent = { vPreviewSaw++; false },
onKeyEvent = { vKeySaw++; false },
onFrame = { _, _ ->
vFrames++
// Let the first frames settle so SDL has applied everything.
if (vFrames < 3) return@nativeComposeWindow true
if (vFrames == 3) {
com.compose.sdl.injectKey(41, true) // Escape down
com.compose.sdl.injectKey(41, false)
return@nativeComposeWindow true
}
if (vFrames == 4) {
// Nothing is focused, so the content declines and BOTH window
// handlers must have seen the key.
println("KEYCHAIN preview=$vPreviewSaw onKey=$vKeySaw")
// Now disable the window and inject again: neither may fire.
vFacade?.setEnabled(false)
com.compose.sdl.injectKey(41, true)
com.compose.sdl.injectKey(41, false)
return@nativeComposeWindow true
}
if (vFrames == 5) {
println("DISABLED preview=$vPreviewSaw onKey=$vKeySaw (must be unchanged)")
vFacade?.setEnabled(true)
}
val vW = vFacade
if (vW == null) {
println("ATTR FAIL: window facade never captured")
} else {
println(
"ATTR undecorated=${vW.isUndecorated} resizable=${vW.isResizable} " +
"alwaysOnTop=${vW.isAlwaysOnTop} focusable=${vW.isFocusable} " +
"enabled=${vW.isEnabled}"
)
val vRaw = vW.rawSdlHandles()
println(
"RAW gpu=${vW.gpuMode} window=${vRaw.window != null} " +
"renderer=${vRaw.renderer != null} glContext=${vRaw.glContext != null} " +
"metalView=${vRaw.metalView != null}"
)
}
false
},
) {
SideEffect { vFacade = window }
Box(Modifier.fillMaxSize()) { Text("attr probe") }
}
}
@@ -0,0 +1,207 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.dp
import com.compose.sdl.nativeComposeWindow
import utils.encodeBmpBgra32
import utils.writeFile
// ==================
// MARK: Animation probes - shared transitions, AnimatedVisibility, dialog entry.
// ==================
//
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
// argument dispatch still reaches it.
/** Drives a SharedTransitionLayout shared-element morph both directions from
the frame counter (no clicks) - it requires the LOOKAHEAD pass, which dies
with "LookaheadDelegate has not been measured yet" if the owner drops
affectsLookahead measure/relayout requests. Reaching the end frame = PASS. */
@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
internal fun runSharedTest() {
val vBig = mutableStateOf(false)
nativeComposeWindow(
title = "sharedtest",
width = 300,
height = 300,
onFrame = { _, vFrame ->
when (vFrame) {
20 -> { vBig.value = true; true } // small → big morph
70 -> { vBig.value = false; true } // big → small morph
130 -> {
println("sharedtest: PASS (shared-element morph ran both directions)")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
androidx.compose.animation.SharedTransitionLayout {
Column {
androidx.compose.animation.AnimatedVisibility(visible = !vBig.value) {
Box(
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(key = "box"),
animatedVisibilityScope = this@AnimatedVisibility,
)
.size(48.dp)
.background(Color(0xFF7C4DFF), RoundedCornerShape(8.dp)),
)
}
androidx.compose.animation.AnimatedVisibility(visible = vBig.value) {
Box(
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(key = "box"),
animatedVisibilityScope = this@AnimatedVisibility,
)
.size(160.dp)
.background(Color(0xFF26A69A), RoundedCornerShape(24.dp)),
)
}
}
}
}
}
}
/** Frame-by-frame trace of AnimatedVisibility(fadeIn+expandVertically / fadeOut+
shrinkVertically): logs the AV container's animated size and the window-Y of a
marker Box below it every frame, toggling visibility three times. Diagnoses
(a) a size snap between just-before-end and end of the animation and (b)
instant (non-animated) transitions on subsequent toggles.
Expected healthy trace: ~24 smooth frames per toggle ending exactly at 0/60,
PLUS one 16px marker jump when the fully-shrunk node unmounts (exit end) or
mounts (enter start) - that's the parent's spacedBy(16) collapsing, inherent
upstream behaviour (spacing applies to zero-height children too), NOT a bug. */
internal fun runAnimVisTest() {
val vShown = mutableStateOf(true)
var vAvSize = androidx.compose.ui.unit.IntSize(-1, -1)
var vMarkerY = -1f
var vLastLog = ""
nativeComposeWindow(
title = "animvistest",
width = 600,
height = 500,
onFrame = { _, vFrame ->
// Log only when something moved - keeps the trace readable.
val vLine = "size=$vAvSize markerY=$vMarkerY shown=${vShown.value}"
if (vLine != vLastLog) {
println("animvistest: f=$vFrame $vLine")
vLastLog = vLine
}
when (vFrame) {
60 -> { println("animvistest: === HIDE 1 ==="); vShown.value = false; true }
140 -> { println("animvistest: === SHOW 2 ==="); vShown.value = true; true }
220 -> { println("animvistest: === HIDE 3 ==="); vShown.value = false; true }
300 -> false
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
// spacedBy mirrors the demo's Section column - the end-of-exit jump
// reported on the FoundationExtra screen involves the spacing around
// the AnimatedVisibility node collapsing when the node unmounts.
Column(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(Modifier.size(30.dp).background(androidx.compose.ui.graphics.Color(0xFFC07040)))
androidx.compose.animation.AnimatedVisibility(
visible = vShown.value,
modifier = Modifier.onSizeChanged { vAvSize = it },
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.background(androidx.compose.ui.graphics.Color(0xFF7040C0)),
) {
Text("Animated content", modifier = Modifier.padding(14.dp))
}
}
Box(
modifier = Modifier
.size(20.dp)
.background(androidx.compose.ui.graphics.Color(0xFF40C070))
.onGloballyPositioned { vMarkerY = it.positionInRoot().y },
)
}
}
}
}
/** Boots a window whose full-size surface opens an m3 AlertDialog on click (the real
material3 → ui.window.Dialog path, default DialogProperties ⇒ animateTransition on),
injects the click, then dumps screenshots during the 0.2s appearance animation and
after it settles; then injects Escape and dumps mid-disappearance (0.1s reverse,
via the popup host's exit deferral) + after. Visual check: mid shots must show the
dialog semi-transparent, slightly scaled-down and shifted down vs the settled shot,
and exit_end must show no dialog at all - skiko-parity animation both ways. */
internal fun runDialogAnimTest() {
fun snap(inBridge: com.compose.sdl.RenderBackend, inName: String) {
val vSnap = inBridge.snapshotBgra() ?: return
val (vW, vH, vBgra) = vSnap
writeFile(inName, encodeBmpBgra32(vW, vH, vBgra))
println("dialoganimtest: wrote $inName")
}
nativeComposeWindow(
title = "dialoganimtest",
width = 1000,
height = 700,
onFrame = { vBridge, vFrame ->
when (vFrame) {
30 -> { com.compose.sdl.injectMouseEvent(1, 500f, 350f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 500f, 350f); true }
36 -> { snap(vBridge, "dialog_mid1.bmp"); true }
40 -> { snap(vBridge, "dialog_mid2.bmp"); true }
90 -> { snap(vBridge, "dialog_end.bmp"); true }
92 -> {
com.compose.sdl.injectKey(41, true) // Escape → dismiss
com.compose.sdl.injectKey(41, false)
true
}
97 -> { snap(vBridge, "dialog_exit_mid.bmp"); true }
140 -> { snap(vBridge, "dialog_exit_end.bmp"); false }
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
var vShow by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.clickable { vShow = true },
)
if (vShow) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { vShow = false },
title = { Text("Animated dialog") },
text = { Text("Appearance animation parity with the JVM (skiko) Dialog.") },
confirmButton = {
androidx.compose.material3.TextButton(onClick = { vShow = false }) { Text("OK") }
},
)
}
}
}
}
@@ -0,0 +1,258 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.compose.sdl.nativeComposeWindow
// ==================
// MARK: Input probes - pointer, keyboard, wheel, cursor and IME.
// ==================
//
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
// argument dispatch still reaches it.
/** Boots a real nativeComposeWindow with a full-size clickable box, then injects
move→press→release SDL mouse events a few frames apart (giving the upstream
clickable's gesture coroutine time to launch + await between frames), and prints
PASS/FAIL based on whether onClick fired. Proves the whole vendored interaction
pipeline works under the real Sdl3MainDispatcher + frame loop. */
internal fun runClickTest() {
var vClicks = 0
nativeComposeWindow(
title = "clicktest",
width = 400,
height = 300,
onFrame = { _, frameIndex ->
when (frameIndex) {
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true }
26 -> { com.compose.sdl.injectMouseEvent(1, 200f, 150f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 150f); true }
70 -> {
println(
if (vClicks > 0) "clicktest: PASS ($vClicks click(s) via upstream clickable)"
else "clicktest: FAIL (0 clicks - upstream clickable did not fire)"
)
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF303040))
.clickable { vClicks++; println("clicktest: onClick fired -> $vClicks") },
) {
Text("Click test", color = Color.White, fontSize = 16.sp)
}
}
}
}
/** Same injection harness as clicktest, but the target is a Material Switch (Modifier.toggleable
from the vendored foundation.selection). Asserts onCheckedChange flipped the state - proving
toggleable rides the same verified upstream interaction path as clickable. */
internal fun runToggleTest() {
var vChecked = false
var vChanges = 0
nativeComposeWindow(
title = "toggletest",
width = 400,
height = 300,
onFrame = { _, frameIndex ->
when (frameIndex) {
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true }
26 -> { com.compose.sdl.injectMouseEvent(1, 200f, 150f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 150f); true }
70 -> {
println(
if (vChanges > 0 && vChecked) "toggletest: PASS (switch toggled to $vChecked via toggleable)"
else "toggletest: FAIL (changes=$vChanges checked=$vChecked)"
)
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
androidx.compose.material3.Switch(
checked = vChecked,
onCheckedChange = { vChecked = it; vChanges++; println("toggletest: onCheckedChange -> $it") },
)
}
}
}
}
/** Boots a window with a real BasicTextField, clicks it to focus (focus-on-click via the
FocusOwner), then injects TEXT_INPUT ("A","B") and a Backspace key through the live SDL path.
Asserts the field edits to "A" - proving click-to-focus + typing + editing keys route to the
focused field via ComposeRootHost.dispatchKeyEvent + the synthesised typed-key path. */
internal fun runKeyTest() {
val vText = mutableStateOf("")
nativeComposeWindow(
title = "keytest",
width = 400,
height = 200,
onFrame = { _, frameIndex ->
when (frameIndex) {
12 -> { com.compose.sdl.injectMouseEvent(1, 200f, 100f); true } // click field to focus
14 -> { com.compose.sdl.injectMouseEvent(2, 200f, 100f); true }
24 -> { com.compose.sdl.injectTextInput("A"); true }
28 -> { com.compose.sdl.injectTextInput("B"); true }
32 -> { com.compose.sdl.injectKey(42, true); com.compose.sdl.injectKey(42, false); true } // Backspace → "A"
70 -> {
println("keytest: real BasicTextField value='${vText.value}'")
println(
if (vText.value == "A") "keytest: PASS (click-to-focus + type 'AB' + backspace = 'A')"
else "keytest: FAIL (expected 'A')"
)
false
}
else -> true
}
},
) {
// The exact regression path: a real BasicTextField, focused by clicking it,
// receiving typed text (SDL TEXT_INPUT) + editing keys (Backspace).
MaterialTheme(colorScheme = darkColorScheme()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
androidx.compose.foundation.text.BasicTextField(
value = vText.value,
onValueChange = { vText.value = it },
)
}
}
}
}
/** Boots a tall Column wrapped in the vendored Modifier.verticalScroll, injects several wheel-down
events through the live SDL path (→ processor → MouseWheelScrollingLogic), and asserts the
ScrollState offset advanced - proving upstream scrolling works end-to-end. */
internal fun runScrollTest() {
val vScroll = androidx.compose.foundation.ScrollState(0)
nativeComposeWindow(
title = "scrolltest",
width = 400,
height = 300,
onFrame = { _, frameIndex ->
when {
frameIndex in 20..40 && frameIndex % 2 == 0 -> {
com.compose.sdl.injectWheel(200f, 150f, 0f, -3f) // wheel down
true
}
frameIndex == 90 -> {
println("scrolltest: ScrollState.value=${vScroll.value} maxValue=${vScroll.maxValue}")
println(
if (vScroll.value > 0) "scrolltest: PASS (scrolled to ${vScroll.value}px)"
else "scrolltest: FAIL (did not scroll)"
)
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(vScroll)) {
repeat(40) { i ->
Text("Scroll row $i - lorem ipsum dolor sit", color = Color.White, fontSize = 16.sp)
}
}
}
}
}
/** --cursortest: injects hover moves over pointerHoverIcon(Text) / (Hand) regions
and a bare background, printing the applied SDL system cursor after each. Proves
PointerIcon -> SDL_SetCursor end-to-end through the live hover pipeline. */
internal fun runCursorTest() {
var vBg = "?"; var vText = "?"; var vHand = "?"
nativeComposeWindow(
title = "cursortest",
width = 400,
height = 400,
onFrame = { _, vFrame ->
when (vFrame) {
10 -> { com.compose.sdl.injectMouseEvent(0, 200f, 300f); true } // background (below both boxes)
14 -> { vBg = com.compose.sdl.appliedCursorName(); true }
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 50f); true } // Text box
24 -> { vText = com.compose.sdl.appliedCursorName(); true }
30 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true } // Hand box
34 -> {
vHand = com.compose.sdl.appliedCursorName()
println("cursortest: background=$vBg text=$vText hand=$vHand")
val vOk = vBg.endsWith("DEFAULT") && vText.endsWith("TEXT") && vHand.endsWith("POINTER")
println(if (vOk) "cursortest: PASS" else "cursortest: FAIL")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
Box(Modifier.fillMaxWidth().height(100.dp).pointerHoverIcon(PointerIcon.Text))
Box(Modifier.fillMaxWidth().height(100.dp).pointerHoverIcon(PointerIcon.Hand))
}
}
}
}
/** --imetest: focuses a BasicTextField, injects an IME composition (SDL TEXT_EDITING
"ni"), then a commit (SDL TEXT_INPUT "に"). Verifies the preedit shows a
composing region and the commit REPLACES it (not appends) - the real IME path. */
internal fun runImeTest() {
val vField = mutableStateOf(androidx.compose.ui.text.input.TextFieldValue(""))
nativeComposeWindow(
title = "imetest",
width = 500,
height = 200,
onFrame = { _, vFrame ->
when (vFrame) {
12 -> { com.compose.sdl.injectMouseEvent(1, 250f, 100f); true } // focus centered field
14 -> { com.compose.sdl.injectMouseEvent(2, 250f, 100f); true }
34 -> { com.compose.sdl.injectTextEditing("ni"); true } // composing (preedit)
40 -> { println("imetest: composing text='${vField.value.text}' composition=${vField.value.composition}"); true }
50 -> { com.compose.sdl.injectTextInput("に"); true } // commit "に"
60 -> {
println("imetest: committed text='${vField.value.text}' composition=${vField.value.composition}")
val vOk = vField.value.text == "に" && vField.value.composition == null
println(if (vOk) "imetest: PASS" else "imetest: FAIL")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
androidx.compose.foundation.text.BasicTextField(
value = vField.value,
onValueChange = { vField.value = it },
textStyle = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 24.sp),
cursorBrush = androidx.compose.ui.graphics.SolidColor(Color.White),
)
}
}
}
}
@@ -0,0 +1,125 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.compose.sdl.nativeComposeWindow
import utils.encodeBmpBgra32
import utils.writeFile
// ==================
// MARK: Lifecycle probes - back navigation and Navigation3.
// ==================
//
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
// argument dispatch still reaches it.
/** Boots a window with a BackHandler, injects an Escape key through the live SDL
path, and asserts the handler fired - proving ComposeWindow's
BackNavigationInput drives the NavigationEventDispatcher (the mechanism that
collapses an expanded m3 SearchBar on Escape). */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
internal fun runBackTest() {
val vBackNoFocus = mutableStateOf(false)
val vBackFocused = mutableStateOf(false)
val vText = mutableStateOf("")
nativeComposeWindow(
title = "backtest",
width = 400,
height = 200,
onFrame = { _, frameIndex ->
when (frameIndex) {
// Phase 1: Escape with NOTHING focused.
20 -> {
com.compose.sdl.injectKey(41, true) // SDL_SCANCODE_ESCAPE
com.compose.sdl.injectKey(41, false)
true
}
// Phase 2: click the text field to focus it, then Escape -
// the user-facing SearchBar scenario (field focused while
// the back handler should collapse the bar).
30 -> { com.compose.sdl.injectMouseEvent(1, 200f, 100f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 100f); true }
44 -> {
com.compose.sdl.injectKey(41, true)
com.compose.sdl.injectKey(41, false)
true
}
70 -> {
println("backtest: noFocus=${vBackNoFocus.value} focused=${vBackFocused.value}")
println(
if (vBackNoFocus.value && vBackFocused.value)
"backtest: PASS (Escape completed back navigation with and without a focused field)"
else
"backtest: FAIL (noFocus=${vBackNoFocus.value} focusedField=${vBackFocused.value})"
)
false
}
else -> true
}
},
) {
@Suppress("DEPRECATION")
androidx.compose.ui.backhandler.BackHandler(enabled = true) {
if (!vBackNoFocus.value) vBackNoFocus.value = true else vBackFocused.value = true
}
MaterialTheme(colorScheme = darkColorScheme()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// The NEW state-based field - the one m3 SearchBar's InputField
// uses - so the probe exercises its key handler, not the legacy
// value-based one.
androidx.compose.foundation.text.BasicTextField(
state = androidx.compose.foundation.text.input.rememberTextFieldState(),
)
}
}
}
}
internal fun runNav3Test() {
val vShow = mutableStateOf(false)
nativeComposeWindow(
title = "nav3test",
width = 1000,
height = 700,
onFrame = { vBridge, vFrame ->
when (vFrame) {
30 -> { vShow.value = true; true }
// Push Detail #1 (click its card) → per-entry lifecycle goes
// CREATED/STARTED during the slide, RESUMED once settled.
60 -> { com.compose.sdl.injectMouseEvent(1, 500f, 216f); true }
62 -> { com.compose.sdl.injectMouseEvent(2, 500f, 216f); true }
// Pop with ESC → ON_PAUSE, then CREATED while animating out,
// then the entry disposes.
120 -> {
com.compose.sdl.injectKey(41, true)
com.compose.sdl.injectKey(41, false)
true
}
180 -> {
val vSnap = vBridge.snapshotBgra()
if (vSnap != null) {
val (vW, vH, vBgra) = vSnap
writeFile("nav3late.bmp", encodeBmpBgra32(vW, vH, vBgra))
println("nav3test: wrote nav3late.bmp (${vW}x${vH})")
println("nav3test: PASS (Navigation3 composed at RESUMED; push + pop ran)")
} else println("nav3test: FAIL (no snapshot)")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
if (vShow.value) {
Box(modifier = Modifier.padding(24.dp)) { screens.Navigation3Screen() }
}
}
}
}
}
@@ -0,0 +1,151 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.decodeToImageBitmap
import androidx.compose.ui.unit.dp
import com.compose.sdl.nativeComposeWindow
import demo.registry.allCategories
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
// ==================
// MARK: Renderer probes - the soak run and encoded-image decoding.
// ==================
//
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
// argument dispatch still reaches it.
/** P2.2 soak - cycle through EVERY registered screen kCycles times in ONE process,
disposing each via a changing key() so composition + layer (skiko RenderNode)
allocate-and-release is exercised repeatedly. After each full cycle, GC then
record peak RSS (getrusage ru_maxrss). Peak RSS is monotonic, so after cycle 1 it
already reflects visiting every screen once; if there is NO leak it plateaus, if
there IS one it keeps climbing each cycle. PASS iff last-cycle peak stays within a
ceiling of the first-cycle peak. */
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class, ExperimentalForeignApi::class)
internal fun runSoakTest() {
// Disable never-settling animations so RSS reflects composition/layer lifetime,
// not live animation-state churn (same seed the screenshot path uses).
com.compose.sdl.disableInfiniteAnimations = true
com.compose.sdl.useVirtualFrameTime = true
val vAll = allCategories().flatMap { it.screens }
// CDN_SOAK_SCREEN=<name> repeats ONE screen 40x/cycle (bisect a specific screen);
// default cycles through every screen.
val vTarget = platform.posix.getenv("CDN_SOAK_SCREEN")?.toKString()
val vScreens = if (vTarget != null) { val vS = vAll.first { it.name.equals(vTarget, ignoreCase = true) }; List(40) { vS } }
else vAll
val vIndex = mutableStateOf(0)
// CDN_SOAK_STATIC=1: mount ONE screen once, never remount - measure RSS every 120 frames.
// Isolates a per-FRAME leak (RSS climbs with no remounts) from a per-MOUNT leak.
val vStatic = platform.posix.getenv("CDN_SOAK_STATIC")?.toKString() == "1"
val kFramesPerScreen = if (vStatic) 120 else 3
val kCycles = platform.posix.getenv("CDN_SOAK_CYCLES")?.toKString()?.toIntOrNull() ?: 3
val vRssPerCycleMb = mutableListOf<Long>()
var vShown = 0
nativeComposeWindow(
title = "soaktest",
width = 1000,
height = 700,
onFrame = { _, vFrame ->
if (vFrame > 0 && vFrame % kFramesPerScreen == 0) {
if (vStatic) {
// No remount - just pump frames on the one mounted screen.
kotlin.native.runtime.GC.collect()
vRssPerCycleMb.add(currentResidentMb())
println("soaktest[static]: measure ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
} else {
vShown++
vIndex.value = vShown % vScreens.size
if (vShown % vScreens.size == 0) {
kotlin.native.runtime.GC.collect()
vRssPerCycleMb.add(currentResidentMb())
println("soaktest: cycle ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
}
}
}
if (vRssPerCycleMb.size >= kCycles) {
val vFirst = vRssPerCycleMb.first()
val vLast = vRssPerCycleMb.last()
val vGrowth = vLast - vFirst
val vCeiling = maxOf(48L, vFirst / 4) // 25% or 48MB, whichever larger
println("soaktest: currentRSS/cycle(MB)=$vRssPerCycleMb (${vScreens.size} screens x $kCycles cycles)")
if (vGrowth <= vCeiling) {
println("soaktest: PASS (current RSS grew ${vGrowth}MB over cycles 1->$kCycles, within ${vCeiling}MB ceiling)")
} else {
println("soaktest: FAIL (current RSS grew ${vGrowth}MB > ${vCeiling}MB ceiling - possible leak)")
}
false
} else true
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
val vScroll = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(vScroll)
.padding(24.dp),
) {
// key(vShown) forces a FULL dispose+recompose each advance (even when the
// target screen repeats) - the allocate/release churn we soak. Reading
// vIndex.value (a State) is what triggers the recomposition each mount.
key(vShown) {
vScreens[vIndex.value].content()
}
}
}
}
}
/** CURRENT resident set (MB): current RSS can DROP after GC, so it distinguishes a
true leak (ratchets up) from K/N allocator high-water, unlike getrusage's
monotonic peak. posix reads it from `ps`; mingw has no `ps`/popen and returns
-1 (the soak gate runs on macOS/Linux - see scripts/verify-mac.sh). */
internal expect fun currentResidentMb(): Long
/** --imagebytestest: decodes an in-memory 2x2 BMP via ByteArray.decodeToImageBitmap()
(routes to createImageBitmap(bytes)) through Skia's image decoder. Needs a window
so the render backend installs the encoded-image decoder. */
internal fun runImageBytesTest() {
nativeComposeWindow(
title = "imagebytestest",
width = 200,
height = 200,
onFrame = { _, vFrame ->
if (vFrame >= 2) {
try {
val vBitmap = tinyRedBmp().decodeToImageBitmap()
println("imagebytestest: decoded ${vBitmap.width}x${vBitmap.height}")
println(if (vBitmap.width == 2 && vBitmap.height == 2) "imagebytestest: PASS" else "imagebytestest: FAIL (wrong size)")
} catch (e: Throwable) {
println("imagebytestest: FAIL (${e::class.simpleName}: ${e.message})")
}
false
} else true
},
) {}
}
// A minimal 2x2 24bpp red BMP (no compression) - valid decoder input.
internal fun tinyRedBmp(): ByteArray {
fun le16(v: Int) = byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte())
fun le32(v: Int) = byteArrayOf(
(v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(),
((v shr 16) and 0xFF).toByte(), ((v shr 24) and 0xFF).toByte(),
)
val vPixel = byteArrayOf(0, 0, 0xFF.toByte()) // BGR red
val vRow = vPixel + vPixel + byteArrayOf(0, 0) // 2px + 2 pad = 8 bytes (4-byte aligned)
val vPixels = vRow + vRow // 2 rows = 16 bytes
val vFileHeader = byteArrayOf('B'.code.toByte(), 'M'.code.toByte()) + le32(70) + le32(0) + le32(54)
val vDib = le32(40) + le32(2) + le32(2) + le16(1) + le16(24) + le32(0) + le32(16) +
le32(2835) + le32(2835) + le32(0) + le32(0)
return vFileHeader + vDib + vPixels
}
@@ -0,0 +1,151 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.compose.sdl.nativeComposeWindow
import utils.encodeBmpBgra32
import utils.writeFile
// ==================
// MARK: Text probes - font resolution, paragraph layout, metrics.
// ==================
//
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
// argument dispatch still reaches it.
/** --fonttest: same sample in FontFamily.Default (sans) and FontFamily.Monospace.
Monospace used to collapse to the default sans; now it renders NotoSansMono
(bundled because this source references FontFamily.Monospace). */
internal fun runFontTest() {
nativeComposeWindow(
title = "fonttest",
width = 560,
height = 220,
onFrame = { vBridge, vFrame ->
if (vFrame >= 10) {
val vSnap = vBridge.snapshotBgra()
if (vSnap != null) {
val (vW, vH, vBgra) = vSnap
writeFile("fonttest.bmp", encodeBmpBgra32(vW, vH, vBgra))
println("fonttest: wrote fonttest.bmp (${vW}x${vH})")
}
false
} else true
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
Column(Modifier.fillMaxSize().background(Color(0xFF202020)).padding(20.dp)) {
val vSample = "Illegal1 lIO0 {}=>"
Text(vSample, color = Color.White, fontSize = 30.sp)
Text(
vSample, color = Color.Cyan, fontSize = 30.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
)
}
}
}
}
/** Boots a real window, then builds an upstream Paragraph via the
vendored factory (→ SkiaParagraph) for a long string constrained to a narrow width. Verifies it
wrapped to multiple lines, has positive size, and that getHorizontalPosition/getOffsetForPosition
round-trip - proving the paragraph-engine measurement bridge works. */
internal fun runParagraphTest() {
nativeComposeWindow(
title = "paragraphtest",
width = 400,
height = 200,
onFrame = { _, frameIndex ->
if (frameIndex == 10) {
val vP = androidx.compose.ui.text.Paragraph(
text = "Hello world foo bar baz qux quux corge grault garply waldo",
style = androidx.compose.ui.text.TextStyle(fontSize = 16.sp),
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 120),
density = androidx.compose.ui.unit.Density(1f),
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
)
val vHpos = vP.getHorizontalPosition(3, true)
val vOffBack = vP.getOffsetForPosition(androidx.compose.ui.geometry.Offset(vHpos, 2f))
val vLine = vP.getLineForOffset(30)
println("paragraphtest: lineCount=${vP.lineCount} w=${vP.width} h=${vP.height} hpos(3)=$vHpos offBack=$vOffBack lineFor(30)=$vLine")
val vPass = vP.lineCount >= 2 && vP.height > 0f && vP.width > 0f && vOffBack in 2..4
println(if (vPass) "paragraphtest: PASS (real width-wrapped Paragraph via SkiaParagraph)" else "paragraphtest: FAIL")
false
} else true
},
) {
Box(modifier = Modifier.fillMaxSize()) {}
}
}
/** Prints paragraph metrics (single-line cell, lineHeight-styled single + triple line,
first baseline) for a font-size sweep at density 1 - the native half of the
metrics-alignment probe. Mirror of MainJvm's `--metrics`; both must print the same
numbers for the parity text drift to vanish. */
internal fun runMetricsProbe() {
fun paragraph(inText: String, inSize: Int, inLineHeight: Int?, inM3Style: Boolean): androidx.compose.ui.text.Paragraph =
androidx.compose.ui.text.Paragraph(
text = inText,
style = androidx.compose.ui.text.TextStyle(
fontSize = inSize.sp,
lineHeight = inLineHeight?.sp ?: androidx.compose.ui.unit.TextUnit.Unspecified,
lineHeightStyle = if (inM3Style) {
androidx.compose.ui.text.style.LineHeightStyle(
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
)
} else null,
),
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 10_000),
density = androidx.compose.ui.unit.Density(1f),
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
)
nativeComposeWindow(
title = "metricsprobe",
width = 300,
height = 200,
onFrame = { _, frameIndex ->
if (frameIndex == 10) {
for (vSize in listOf(11, 12, 14, 16, 22, 24)) {
val vLh = vSize + 6
val vCell = paragraph("Hg", vSize, null, false)
val vOne = paragraph("Hg", vSize, vLh, false)
val vThree = paragraph("Hg\nHg\nHg", vSize, vLh, false)
val vOneM3 = paragraph("Hg", vSize, vLh, true)
val vThreeM3 = paragraph("Hg\nHg\nHg", vSize, vLh, true)
println(
"metrics: size=$vSize lh=$vLh cell=${vCell.height} " +
"one=${vOne.height} three=${vThree.height} " +
"base1=${vOne.firstBaseline} base3=${vThree.lastBaseline} " +
"oneM3=${vOneM3.height} threeM3=${vThreeM3.height} base1M3=${vOneM3.firstBaseline}"
)
}
for ((vS, vL) in listOf(24 to 24, 24 to 25, 32 to 24, 16 to 16)) {
val vB = paragraph("Hg", vS, vL, true)
println("metrics: boundary $vS/$vL m3=${vB.height} base=${vB.firstBaseline}")
}
val vBig = paragraph("42", 48, 24, false)
val vBigM3 = paragraph("42", 48, 24, true)
println("metrics: big48/lh24 raw=${vBig.height} base=${vBig.firstBaseline} m3=${vBigM3.height} baseM3=${vBigM3.firstBaseline}")
println("metricsprobe: DONE")
false
} else true
},
) {
Box(modifier = Modifier.fillMaxSize()) {}
}
}
// ==================
// MARK: clicktest
// ==================
@@ -0,0 +1,188 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.compose.sdl.Window
import com.compose.sdl.nativeComposeApp
import com.compose.sdl.nativeComposeWindow
import utils.encodeBmpBgra32
import utils.writeFile
// ==================
// MARK: Window probes - multi-window lifecycle, window info, locale.
// ==================
//
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
// argument dispatch still reaches it.
/** Boots TWO windows via nativeComposeApp, asserts both render, closes the
second by flipping the state that composes its Window(), and asserts the app
keeps running on the first - the multi-window lifecycle end-to-end. */
internal fun runMultiWindowTest() {
val vShowSecond = mutableStateOf(true)
var vSecondFrames = 0
var vResult = "FAIL (main window never reached the end frame)"
nativeComposeApp {
Window(
onCloseRequest = ::exitApplication,
title = "multiwin main",
width = 400,
height = 200,
onFrame = { _, vFrame ->
when (vFrame) {
40 -> {
if (vSecondFrames == 0) {
vResult = "FAIL (second window never rendered)"
false
} else {
vShowSecond.value = false // close the second window via state
true
}
}
80 -> {
vResult =
if (!vShowSecond.value && vSecondFrames > 0)
"PASS (both windows rendered; second closed via state; app survived on the first)"
else "FAIL (second=$vSecondFrames showSecond=${vShowSecond.value})"
false
}
else -> true
}
},
) {
Text("main window", color = Color.White)
}
if (vShowSecond.value) {
Window(
onCloseRequest = { vShowSecond.value = false },
title = "multiwin second",
width = 300,
height = 150,
onFrame = { _, _ -> vSecondFrames++; true },
) {
Text("second window", color = Color.White)
}
}
}
println("multiwintest: secondFrames=$vSecondFrames")
println("multiwintest: $vResult")
}
/** --windowinfotest: prints LocalWindowInfo.isWindowFocused / containerSize /
containerDpSize. containerSize starts Zero and becomes the window pixel size
after the first measure - proof it's fed from the live root constraints. */
internal fun runWindowInfoTest() {
nativeComposeWindow(
title = "windowinfotest",
width = 400,
height = 300,
onFrame = { _, vFrame -> vFrame < 6 },
) {
val vWi = androidx.compose.ui.platform.LocalWindowInfo.current
androidx.compose.runtime.LaunchedEffect(vWi.containerSize, vWi.isWindowFocused) {
println("windowinfotest: focused=${vWi.isWindowFocused} containerSize=${vWi.containerSize} containerDpSize=${vWi.containerDpSize}")
}
MaterialTheme(colorScheme = darkColorScheme()) {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
}
}
}
/** Boots the REAL Search screen, clicks the first SearchBar's input field
(expands it), presses Escape, and writes esc_before.bmp / esc_after.bmp -
the expanded overlay must be visible in `before` and gone in `after`. */
internal fun runSearchEscTest() {
fun snap(inBridge: com.compose.sdl.RenderBackend, inName: String) {
val vSnap = inBridge.snapshotBgra() ?: return
val (vW, vH, vBgra) = vSnap
writeFile(inName, encodeBmpBgra32(vW, vH, vBgra))
println("searchesctest: wrote $inName")
}
nativeComposeWindow(
title = "searchesctest",
width = 1000,
height = 700,
onFrame = { vBridge, vFrame ->
when (vFrame) {
30 -> { com.compose.sdl.injectMouseEvent(1, 200f, 230f); true }
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 230f); true }
70 -> { snap(vBridge, "esc_before.bmp"); true }
80 -> {
com.compose.sdl.injectKey(41, true) // Escape
com.compose.sdl.injectKey(41, false)
true
}
130 -> { snap(vBridge, "esc_after.bmp"); false }
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
val vScroll = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(vScroll)
.padding(24.dp),
) {
screens.M3SearchScreen()
}
}
}
}
/* Boots an empty window, then composes screens.Navigation3Screen() at frame 30 -
AFTER the window lifecycle reached RESUMED - mirroring the real sidebar flow.
Crashes here (e.g. enableSavedStateHandles' INITIALIZED/CREATED contract) never
reproduce under --screen, which composes during the initial CREATED composition.
PASS = a screenshot gets written and the app exits cleanly. */
/** --localetest: prints Locale.current / LocaleList.current (should reflect the OS
preferred locales via SDL) and screenshots an M3 DatePicker, whose headline and
navigation labels are M3-translated by Locale.current. Run under a forced locale,
e.g. `demo.kexe --localetest -AppleLanguages "(fr-FR)"` on macOS. */
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
internal fun runLocaleTest() {
nativeComposeWindow(
title = "localetest",
width = 420,
height = 560,
onFrame = { vBridge, vFrame ->
when (vFrame) {
2 -> {
val vCur = androidx.compose.ui.text.intl.Locale.current
val vList = androidx.compose.ui.text.intl.LocaleList.current
println("localetest: Locale.current=${vCur.toLanguageTag()}")
println("localetest: LocaleList.current=[${vList.localeList.joinToString { it.toLanguageTag() }}]")
true
}
40 -> {
val vSnap = vBridge.snapshotBgra()
if (vSnap != null) {
val (vW, vH, vBgra) = vSnap
writeFile("localetest.bmp", encodeBmpBgra32(vW, vH, vBgra))
println("localetest: wrote localetest.bmp (${vW}x${vH})")
} else println("localetest: FAIL (no snapshot)")
false
}
else -> true
}
},
) {
MaterialTheme(colorScheme = darkColorScheme()) {
androidx.compose.material3.Surface(modifier = Modifier.fillMaxSize()) {
val vState = androidx.compose.material3.rememberDatePickerState()
androidx.compose.material3.DatePicker(state = vState)
}
}
}
}
@@ -22,10 +22,10 @@ import com.compose.sdl.LocalComposeNativeWindow
own id, so closing one removes exactly THAT id (a count would always drop the
last-declared window, closing the wrong one). */
val ExtraWindows = mutableStateListOf<Int>()
private var fNextExtraWindowId = 1
private var mNextExtraWindowId = 1
fun openExtraWindow() {
ExtraWindows.add(fNextExtraWindowId++)
ExtraWindows.add(mNextExtraWindowId++)
}
@Composable
+7 -8
View File
@@ -8,7 +8,7 @@ import com.compose.sdl.GpuMode
/** Parsed view of the demo's command line.
--gpu=auto | software | skia.metal | skia.opengl (default: auto)
--gpu=auto | cpu | metal | opengl (default: auto)
--screen=Buttons | TextField | ... (default: full app w/ sidebar)
--screenshot=path.bmp capture at quiescence and quit
--width=W --height=H (default 1000 / 700)
@@ -25,15 +25,14 @@ internal data class CliArgs(
val maxFrames: Int = 300,
)
/** Translates the --gpu= string to the right GpuMode sealed instance.
Accepts both dotted (`skia.metal`) and dashed (`skia-metal`) forms,
plus the bare driver names (`metal`, `opengl`, …) as Skia aliases
for backwards compatibility. */
/** Translates the --gpu= string to a GpuMode. Accepts the bare driver names
(`metal`, `opengl`, `cpu`) plus the retired `skia.*` / `skia-*` spellings, so
existing scripts and docs keep working after GpuMode was flattened. */
private fun parseGpu(inValue: String): GpuMode = when (inValue.lowercase().replace('-', '.')) {
"auto" -> GpuMode.Auto
"none", "cpu", "software" -> GpuMode.Software
"metal", "skia.metal" -> GpuMode.Skia.Metal
"opengl", "gl", "skia.opengl" -> GpuMode.Skia.OpenGL
"none", "cpu", "software", "raster" -> GpuMode.CpuRaster
"metal", "skia.metal" -> GpuMode.Metal
"opengl", "gl", "skia.opengl" -> GpuMode.OpenGL
else -> {
println("Unknown --gpu=$inValue, using auto")
GpuMode.Auto
@@ -10,6 +10,7 @@ import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.work.DisableCachingByDefault
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.util.zip.Inflater
@@ -91,10 +92,23 @@ internal fun installAppIcon(inProject: Project, inExt: ComposeDesktopNativeExten
}
// Link the object last: the mingw link tasks must run windres first (the
// path is referenced via linkerOpts, added reflectively in NativeApplication).
//
// `inputs.files(task)` rather than a bare `dependsOn`: dependsOn only orders
// the two, it does NOT make the object's CONTENT part of the link's up-to-date
// check. The .o path reaches the linker as a linkerOpts STRING, which is an
// input only as a string - so editing an icon PNG rebuilt the .ico and the .o
// and then left the link UP-TO-DATE, silently shipping an .exe with the OLD
// icon embedded. Declaring the task's outputs as link inputs carries the
// dependency AND invalidates on content.
inProject.tasks.matching {
it.name == "linkDebugExecutableMingwX64" || it.name == "linkReleaseExecutableMingwX64" ||
it.name == "runDebugExecutableMingwX64" || it.name == "runReleaseExecutableMingwX64"
}.configureEach { it.dependsOn(vWindresTask) }
}.configureEach { task ->
task.inputs.files(vWindresTask)
.withPropertyName("composeNativeAppIconObject")
.withPathSensitivity(PathSensitivity.NONE)
task.dependsOn(vWindresTask)
}
}
}
@@ -102,6 +116,7 @@ internal fun installAppIcon(inProject: Project, inExt: ComposeDesktopNativeExten
// MARK: Tasks
// ==================
@DisableCachingByDefault(because = "trivial PNG decode; caching would cost more than it saves")
abstract class GenerateRgbaIconsTask : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
@@ -124,6 +139,7 @@ abstract class GenerateRgbaIconsTask : DefaultTask() {
}
}
@DisableCachingByDefault(because = "trivial byte concatenation of already-local PNGs")
abstract class GenerateIcoTask : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
@@ -140,6 +156,7 @@ abstract class GenerateIcoTask : DefaultTask() {
}
}
@DisableCachingByDefault(because = "shells out to the host windres; not portable across machines")
abstract class CompileWindowsIconResourceTask : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
@@ -88,6 +88,26 @@ private fun registerDataKresTasks(project: Project) {
val compress = project.providers.gradleProperty("compressResources").orNull?.toBoolean() ?: false
task.entryCompression = if (compress) ZipEntryCompression.DEFLATED else ZipEntryCompression.STORED
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
// MULTI-MODULE: source from Kotlin's per-target resource AGGREGATE, which
// already merges this project's OWN composeResources with those of every
// dependency module (each under its own `composeResources/<package>/`).
// Reading the project's `preparedResources/` instead - as this did - silently
// dropped every library module's resources from data.kres, and forced a
// single `resPackage` onto the lot, which is wrong the moment more than one
// module contributes.
//
// The aggregate directory is already rooted at `composeResources/`, so it is
// copied in with NO `into()` remapping.
val aggregateName = "${target.replaceFirstChar { it.lowercase() }}AggregateResources"
if (aggregateName in project.tasks.names) {
val aggregated = project.layout.buildDirectory.dir(
"kotlin-multiplatform-resources/aggregated-resources/${target.replaceFirstChar { it.lowercase() }}"
)
task.from(aggregated)
task.dependsOn(project.tasks.named(aggregateName))
} else {
// Fallback for Kotlin versions without the resources-aggregation tasks:
// this project's own resources only (the pre-existing behaviour).
val resPackage = project.resolveResourcePackage()
val prepareNames = sourceSets
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
@@ -102,6 +122,7 @@ private fun registerDataKresTasks(project: Project) {
task.dependsOn(project.tasks.named(prepareName))
}
}
}
project.tasks.matching { it.name == linkName }.configureEach { it.dependsOn(zipTask) }
}
}
@@ -20,7 +20,7 @@ import org.gradle.api.tasks.Copy
//
// Text rendering goes through Skia's skparagraph (HarfBuzz + skunicode). On
// Windows, Skia loads its ICU data (icudtl.dat) from a file next to the binary at
// runtime - without it ParagraphBuilder fatal-aborts (check(fUnicode)). Unlike
// runtime - without it ParagraphBuilder fatal-aborts (check(mUnicode)). Unlike
// macOS/Linux skiko, which bake the ICU data in, the Windows build ships it as a
// sidecar. The official skiko-awt-runtime-windows-x64 jar already contains a
// compatible icudtl.dat (same Skia base as the fork), so we source it from there