Compare commits

...
10 Commits
Author SHA1 Message Date
Bitsy eb44db915e refactor: finish the SDL3* -> Sdl3* rename and regenerate the API dumps
Picks up the file/type renames (SDL3Backend -> Sdl3Backend, SDL3FrameClock ->
Sdl3FrameClock, SDL3EventMapper.kt -> AppEvent.kt) and unifies the 15 references
the rename left behind. All were prose or strings, which is why it still
compiled:

  - a user-visible error message ("SDL3Backend received GpuMode.Auto ...")
  - two user-visible demo UI strings ("driven by the SDL3FrameClock ...")
  - the MARK headers in AppEvent.kt and Sdl3Backend.kt
  - KDoc cross-references, including to `SDL3EventMapper.kt` which no longer
    exists - repointed at AppEvent.kt
  - 3 PLAN.md references

The codebase now uses Sdl3* uniformly, matching the Sdl3MainDispatcher /
Sdl3Clipboard / Sdl3DragAndDropOwner / Sdl3ScrollConfig that already followed
that form. `sdl3.*` (the cinterop package) and SDL_* (the C API) are untouched.

API dumps regenerated: a clean 1:1 rename, 41 SDL3Backend -> 41 Sdl3Backend in
:ui and nothing else - the other renamed types were not public. Breaking, but
com.compose.sdl only; no androidx.compose.* / org.jetbrains.compose.* change.

Verified beyond compiling: apiCheck green, both metadata publish gates compile,
nav3test/backtest/multiwintest PASS, raw SDL handles still resolve
(gpu=OpenGL window=true glContext=true), and the Images screen renders.
2026-09-19 00:12:36 +02:00
Bitsy 1120abc488 Update README to remove Infrastructure and add Support
Removed the Infrastructure section and added a Support section with a Discord link.
2026-09-18 23:54:32 +02:00
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
79 changed files with 2907 additions and 2484 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 ## Key files by area - start here when you need to find something
### Renderer + main loop ### Renderer + main loop
- `compose/desktop/native/desktop-native-window/src/nativeMain/…/ComposeWindow.kt` - main loop, - `compose/desktop/native/desktop-native-window/src/nativeMain/…/` - the window
recomposer lifecycle, SDL event dispatch, composition-local seeding. 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/…/RenderBackend.kt` - the interface.
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` - sealed driver picker - `compose/ui/ui/src/nativeMain/…/GpuMode.kt` - sealed driver picker
(`Auto` / `Software` / `Skia.OpenGL` / `Skia.Metal`). (`Auto` / `Software` / `Skia.OpenGL` / `Skia.Metal`).
@@ -632,9 +642,23 @@ Open renderer / fidelity / release work is tracked in [PLAN.md](PLAN.md).
## Conventions ## Conventions
Kotlin standard style - plain `camelCase` for parameters, local variables, Kotlin standard style for formatting (braces, wrapping, indentation).
and fields. **No `f`/`in`/`v` prefixes**, no SPIRTECH scheme (see the global
CLAUDE.md - that scheme is C-only). 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: 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` / - The google `LocalViewModelStoreOwner` / `LocalSavedStateRegistryOwner` /
`LocalLifecycleOwner` are PLAIN composition locals - the JB HostDefault `LocalLifecycleOwner` are PLAIN composition locals - the JB HostDefault
mechanism (`compositionLocalWithHostDefaultOf`) does not exist in google 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, per window, mirrors upstream desktop's DefaultArchitectureComponentsOwner,
calls `enableSavedStateHandles()` at construction, and follows SDL focus / calls `enableSavedStateHandles()` at construction, and follows SDL focus /
visibility (focused → RESUMED, unfocused → STARTED, minimised → CREATED). visibility (focused → RESUMED, unfocused → STARTED, minimised → CREATED).
+37 -13
View File
@@ -391,21 +391,21 @@ re-vendoring.** The debt is **completing native-actual stubs**.
### P0 - blocks fidelity/correctness ### P0 - blocks fidelity/correctness
- [~] **P0** Text context menu (right-click copy/paste/select-all) - **RECONCILED: already - [~] **P0** Text context menu (right-click copy/paste/select-all) - **RECONCILED: already
works, no reimplementation.** Static trace confirmed the menu is wired end-to-end works, no reimplementation.** Static trace confirmed the menu is wired end-to-end
through the vendored LEGACY path: text-level `ContextMenuArea` (`ContextMenu.native.kt`) through the vendored LEGACY path: text-level `ContextMenuArea` (`ContextMenu.native.kt`)
→ `CommonContextMenuArea` (`vendor/common/.../text/CommonContextMenuArea.kt`) → (native → `CommonContextMenuArea` (`vendor/common/.../text/CommonContextMenuArea.kt`) → (native
`ComposeFoundationFlags.isNewContextMenuEnabled = false`, so the legacy branch) → `ComposeFoundationFlags.isNewContextMenuEnabled = false`, so the legacy branch) →
`contextmenu.ContextMenuArea` → `contextMenuGestures`/`onRightClickDown` `contextmenu.ContextMenuArea` → `contextMenuGestures`/`onRightClickDown`
(`isSecondaryPressed`) → `ContextMenuPopup` → `Popup.native.kt` (hosted by (`isSecondaryPressed`) → `ContextMenuPopup` → `Popup.native.kt` (hosted by
`LocalPopupHost` at the app root). Items are real: `TextFieldSelectionManager` / `LocalPopupHost` at the app root). Items are real: `TextFieldSelectionManager` /
`SelectionManager.contextMenuBuilder` emit Cut/Copy/Paste/SelectAll with real actions. `SelectionManager.contextMenuBuilder` emit Cut/Copy/Paste/SelectAll with real actions.
SDL right-click → `PointerButton.Secondary` (`SDL3EventMapper.kt`) → SDL right-click → `PointerButton.Secondary` (`AppEvent.kt`) →
`isSecondaryPressed` (`PointerEventBridge`). The three "NOP" seams belong to the `isSecondaryPressed` (`PointerEventBridge`). The three "NOP" seams belong to the
DISABLED *new* context-menu API and are unreachable - **DONE:** their misleading DISABLED *new* context-menu API and are unreachable - **DONE:** their misleading
`TODO(CMP-7819)` comments corrected to say so, in all three files. `TODO(CMP-7819)` comments corrected to say so, in all three files.
**REMAINING:** an interactive right-click smoke check (no headless driver here - fold **REMAINING:** an interactive right-click smoke check (no headless driver here - fold
into manual/WIN-SMOKE). Known limitation, not a breakage: Paste enablement is into manual/WIN-SMOKE). Known limitation, not a breakage: Paste enablement is
plain-text-only (`ClipboardPasteState.hasClip = hasText`, see P2 below). plain-text-only (`ClipboardPasteState.hasClip = hasText`, see P2 below).
- [ ] **P1** DatePicker/TimePicker localization - VERIFIED current state: - [ ] **P1** DatePicker/TimePicker localization - VERIFIED current state:
`material3/.../internal/PlatformDateFormat.native.kt` is a real kotlinx-datetime `material3/.../internal/PlatformDateFormat.native.kt` is a real kotlinx-datetime
formatter that DOES honor CLDR patterns/skeletons (renders "Jul 29, 2026" / formatter that DOES honor CLDR patterns/skeletons (renders "Jul 29, 2026" /
@@ -417,14 +417,14 @@ re-vendoring.** The debt is **completing native-actual stubs**.
(author):** bundle a CLDR subset or K/N i18n lib for localized names + locale-aware (author):** bundle a CLDR subset or K/N i18n lib for localized names + locale-aware
first-day/24h; also unblocks `CalendarLocale.native.kt:20` (fixed `"en"`). Ships first-day/24h; also unblocks `CalendarLocale.native.kt:20` (fixed `"en"`). Ships
readable English dates today - polish, not a P0 gate. readable English dates today - polish, not a P0 gate.
- [x] **P0** Float pointer coordinates - `SDL3EventMapper.kt` `.toInt()`-truncated SDL's - [x] **P0** Float pointer coordinates - `AppEvent.kt` `.toInt()`-truncated SDL's
Float `mb.x`/`mb.y`/`mm.x`/`mm.y` and the wheel `mouse_x/y` before the DPR multiply in Float `mb.x`/`mb.y`/`mm.x`/`mm.y` and the wheel `mouse_x/y` before the DPR multiply in
`ComposeWindow.kt`. On 2× displays a click at logical 100.9 → physical 200 not ~201, `ComposeWindow.kt`. On 2× displays a click at logical 100.9 → physical 200 not ~201,
quantizing the caret to 2px steps near glyph edges. **DONE:** widened quantizing the caret to 2px steps near glyph edges. **DONE:** widened
`LegacyPointerEvent.x/y` and `AppEvent.MouseWheel.x/y` to `Float`, dropped the `LegacyPointerEvent.x/y` and `AppEvent.MouseWheel.x/y` to `Float`, dropped the
`.toInt()` at the three mapper construction sites, and removed the now-redundant `.toInt()` at the three mapper construction sites, and removed the now-redundant
`.toFloat()` at the two `ComposeWindow` read sites. Consumers fully contained (verified `.toFloat()` at the two `ComposeWindow` read sites. Consumers fully contained (verified
by grep). Builds + runs clean on macosArm64. by grep). Builds + runs clean on macosArm64.
### P1 - quality / parity ### P1 - quality / parity
@@ -521,7 +521,7 @@ re-vendoring.** The debt is **completing native-actual stubs**.
Static lib built from source per host (`scripts/build-sdl/build-all.py`, ref Static lib built from source per host (`scripts/build-sdl/build-all.py`, ref
`release-3.4.12`); linked into the exe via `sdl3.def` (`staticLibraries=libSDL3.a`). Today `release-3.4.12`); linked into the exe via `sdl3.def` (`staticLibraries=libSDL3.a`). Today
only tests/examples + Windows D3D12/GPU are off (`build-all.py:311-319`). `SDL_Init` uses only tests/examples + Windows D3D12/GPU are off (`build-all.py:311-319`). `SDL_Init` uses
**`SDL_INIT_VIDEO` only** (`SDL3Backend.kt:49`). **`SDL_INIT_VIDEO` only** (`Sdl3Backend.kt:49`).
**USED (keep ON):** Video/window, Events, Clipboard, Dialog (file open/save - no cheaper **USED (keep ON):** Video/window, Events, Clipboard, Dialog (file open/save - no cheaper
substitute; Linux uses portal/zenity, keep deps), OpenGL + Metal contexts, Render (2D CPU- substitute; Linux uses portal/zenity, keep deps), OpenGL + Metal contexts, Render (2D CPU-
@@ -554,7 +554,7 @@ joystick.
disabled SDL subsystems.) disabled SDL subsystems.)
- [ ] **P2** (higher-risk, flag-don't-apply) Render-driver pruning to software-only. Only - [ ] **P2** (higher-risk, flag-don't-apply) Render-driver pruning to software-only. Only
the CPU-raster fallback uses `SDL_Render`; GL/Metal go direct. But the CPU-raster fallback uses `SDL_Render`; GL/Metal go direct. But
`SDL_CreateRenderer(window, null)` (`SDL3Backend.kt:117`) lets SDL pick the first `SDL_CreateRenderer(window, null)` (`Sdl3Backend.kt:117`) lets SDL pick the first
driver - with only software present it picks software (fine) but couples the fallback driver - with only software present it picks software (fine) but couples the fallback
to that assumption. **Conservative: leave render drivers alone.** to that assumption. **Conservative: leave render drivers alone.**
@@ -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 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). 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) ## Accepted 1.0.0 gaps (documented, not fixed)
Drag-OUT of window (SDL platform limit; drop-IN works), full accessibility pipeline (out of Drag-OUT of window (SDL platform limit; drop-IN works), full accessibility pipeline (out of
+21 -11
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 owners, driven by real SDL focus and visibility events, so ViewModels and
saved state behave as they do on Android. 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 ### Building in this repo
```kotlin ```kotlin
@@ -256,17 +273,6 @@ mirrors or you will have every class twice.
| Collection | `androidx.collection:collection:1.5.0` | | Collection | `androidx.collection:collection:1.5.0` |
| Graphics Shapes | `androidx.graphics:graphics-shapes:1.1.0` | | Graphics Shapes | `androidx.graphics:graphics-shapes:1.1.0` |
### Infrastructure
| Library | Version |
|---|---|
| kotlinx-coroutines | 1.11.0 |
| kotlinx-serialization | 1.11.0 |
| kotlinx-datetime | 0.8.0 |
| kotlinx-io-okio | 0.9.1 |
| Okio | 3.18.2 |
| Ktor | 3.5.2 |
## Building ## Building
Build the native libraries once per machine, then build any app target: Build the native libraries once per machine, then build any app target:
@@ -281,6 +287,10 @@ command line tools; Linux needs gcc/g++ and the X11 / Wayland / audio dev
headers; Windows needs a mingw-w64 g++ on PATH. See [TOOLING.md](TOOLING.md) for headers; Windows needs a mingw-w64 g++ on PATH. See [TOOLING.md](TOOLING.md) for
the full build and verification workflow. the full build and verification workflow.
## Support
[Bitsycore's Discord](https://discord.gg/kGnraVAtDR)
## Documentation ## Documentation
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification. - [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
+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 // A linked copy mirrors its source's requests read-only; non-linked packs own
// their requests. `requests` resolves to whichever applies. // their requests. `requests` resolves to whichever applies.
var linkedSource by mutableStateOf<PackState?>(null) 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 inPack.requests.forEach { add(ReqState(it)) } // packs (and the loose root) may be empty
} }
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState> val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
get() = linkedSource?.requests ?: fOwnRequests get() = linkedSource?.requests ?: mOwnRequests
val isLinked: Boolean get() = linkedSource != null val isLinked: Boolean get() = linkedSource != null
var parent: PackState? = var parent: PackState? =
null // enclosing pack (null = top-level / root) 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 // respects the editor's "tab size" (TextLayoutConfig.tabWidth), exactly like
// typed tabs. Lenient so slightly-off JSON (single quotes, trailing commas) // typed tabs. Lenient so slightly-off JSON (single quotes, trailing commas)
// still reformats instead of failing. // still reformats instead of failing.
private val fPrettyJson = Json { private val mPrettyJson = Json {
prettyPrint = true prettyPrint = true
prettyPrintIndent = "\t" prettyPrintIndent = "\t"
isLenient = true isLenient = true
@@ -23,7 +23,7 @@ private val fPrettyJson = Json {
fun formatBody(inText: String, inFormat: BodyFormat): String = fun formatBody(inText: String, inFormat: BodyFormat): String =
when (inFormat) { when (inFormat) {
BodyFormat.JSON -> runCatching { BodyFormat.JSON -> runCatching {
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText)) mPrettyJson.encodeToString(JsonElement.serializer(), mPrettyJson.parseToJsonElement(inText))
}.getOrDefault(inText) }.getOrDefault(inText)
BodyFormat.XML -> runCatching { formatXml(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. */ run() is a suspend fun - call it off the UI dispatcher. */
class HttpRunner { class HttpRunner {
private val fClient = createApiHttpClient() private val mClient = createApiHttpClient()
init { init {
// Clear any temporary client certs a prior crash left in the Windows // Clear any temporary client certs a prior crash left in the Windows
@@ -36,7 +36,7 @@ class HttpRunner {
if (inReq.hasClientCert) return curlSendWithClientCert(inReq) if (inReq.hasClientCert) return curlSendWithClientCert(inReq)
val vMark = TimeSource.Monotonic.markNow() val vMark = TimeSource.Monotonic.markNow()
return try { return try {
val vResp: HttpResponse = fClient.request(inReq.url.trim()) { val vResp: HttpResponse = mClient.request(inReq.url.trim()) {
method = HttpMethod.parse(inReq.method.name) method = HttpMethod.parse(inReq.method.name)
inReq.params inReq.params
.filter { it.enabled && it.key.isNotBlank() } .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, /** 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) // MARK: Pack persistence (okio + kotlinx.serialization)
// ================== // ==================
private val fJson = Json { private val mJson = Json {
prettyPrint = true prettyPrint = true
prettyPrintIndent = " " prettyPrintIndent = " "
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -20,7 +20,7 @@ private val fJson = Json {
error message. */ error message. */
fun exportPack(inPack: Pack, inPath: String): String? = try { fun exportPack(inPack: Pack, inPath: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) { systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inPack)) writeUtf8(mJson.encodeToString(inPack))
} }
null null
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -30,7 +30,7 @@ fun exportPack(inPack: Pack, inPath: String): String? = try {
/** Read a pack back from inPath. */ /** Read a pack back from inPath. */
fun importPack(inPath: String): Result<Pack> = try { fun importPack(inPath: String): Result<Pack> = try {
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() } val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
Result.success(fJson.decodeFromString<Pack>(vText)) Result.success(mJson.decodeFromString<Pack>(vText))
} catch (e: Throwable) { } catch (e: Throwable) {
Result.failure(e) 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. */ /** Write a whole self-contained session to inPath. Returns null on success. */
fun exportSession(inSession: Session, inPath: String): String? = try { fun exportSession(inSession: Session, inPath: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) { systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inSession)) writeUtf8(mJson.encodeToString(inSession))
} }
null null
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -48,7 +48,7 @@ fun exportSession(inSession: Session, inPath: String): String? = try {
/** Read a session back from inPath. */ /** Read a session back from inPath. */
fun importSession(inPath: String): Result<Session> = try { fun importSession(inPath: String): Result<Session> = try {
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() } val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
Result.success(fJson.decodeFromString<Session>(vText)) Result.success(mJson.decodeFromString<Session>(vText))
} catch (e: Throwable) { } catch (e: Throwable) {
Result.failure(e) 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 /** Re-indent a JSON response for display; returns the input unchanged if it
isn't valid JSON (so plain-text / HTML responses still show). */ isn't valid JSON (so plain-text / HTML responses still show). */
fun prettyJsonOrRaw(inText: String): String = try { fun prettyJsonOrRaw(inText: String): String = try {
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText)) mJson.encodeToString(JsonElement.serializer(), mJson.parseToJsonElement(inText))
} catch (_: Throwable) { } catch (_: Throwable) {
inText inText
} }
+3 -3
View File
@@ -64,7 +64,7 @@ data class Session(
// MARK: Load / save (app-data dir via okio) // MARK: Load / save (app-data dir via okio)
// ================== // ==================
private val fStateJson = Json { private val mStateJson = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
encodeDefaults = true encodeDefaults = true
prettyPrint = true prettyPrint = true
@@ -88,7 +88,7 @@ fun loadAppState(): AppState {
val vPath = stateFilePath() ?: return AppState() val vPath = stateFilePath() ?: return AppState()
return try { return try {
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() } val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
fStateJson.decodeFromString<AppState>(vText) mStateJson.decodeFromString<AppState>(vText)
} catch (_: Throwable) { } catch (_: Throwable) {
AppState() AppState()
} }
@@ -99,7 +99,7 @@ the session, not the user's exported .json packs). */
fun saveAppState(inState: AppState) { fun saveAppState(inState: AppState) {
val vPath = stateFilePath() ?: return val vPath = stateFilePath() ?: return
try { try {
systemFileSystem.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) } systemFileSystem.write(vPath.toPath()) { writeUtf8(mStateJson.encodeToString(inState)) }
} catch (_: Throwable) { } catch (_: Throwable) {
// ignore - nothing the user can act on, and packs can still be exported // ignore - nothing the user can act on, and packs can still be exported
} }
@@ -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 /** 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. */ 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 actual var editorTabWidth: Int
get() = fTabWidth get() = mTabWidth
set(value) { set(value) {
fTabWidth = value mTabWidth = value
} }
@@ -11,12 +11,6 @@ plugins {
alias(libs.plugins.kotlin.plugin.compose) alias(libs.plugins.kotlin.plugin.compose)
} }
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin { kotlin {
@@ -51,20 +51,20 @@ private class SvgPainter(private val bytes: ByteArray) : Painter() {
override val intrinsicSize: Size = svgIntrinsicSize(bytes) ?: Size.Unspecified override val intrinsicSize: Size = svgIntrinsicSize(bytes) ?: Size.Unspecified
private var fAlpha: Float = 1f private var mAlpha: Float = 1f
private var fColorFilter: ColorFilter? = null private var mColorFilter: ColorFilter? = null
private var fCacheKey: Long = -1L private var mCacheKey: Long = -1L
private var fCacheBitmap: ImageBitmap? = null private var mCacheBitmap: ImageBitmap? = null
override fun applyAlpha(alpha: Float): Boolean { fAlpha = alpha; return true } override fun applyAlpha(alpha: Float): Boolean { mAlpha = alpha; return true }
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { fColorFilter = colorFilter; return true } override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { mColorFilter = colorFilter; return true }
private fun rasterFor(inWidthPx: Int, inHeightPx: Int): ImageBitmap? { private fun rasterFor(inWidthPx: Int, inHeightPx: Int): ImageBitmap? {
val vKey = (inWidthPx.toLong() shl 32) or inHeightPx.toLong() 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 val vBitmap = decodeSvgAt(bytes, inWidthPx, inHeightPx) ?: return null
fCacheKey = vKey mCacheKey = vKey
fCacheBitmap = vBitmap mCacheBitmap = vBitmap
return vBitmap return vBitmap
} }
@@ -79,8 +79,8 @@ private class SvgPainter(private val bytes: ByteArray) : Painter() {
srcSize = IntSize(vBitmap.width, vBitmap.height), srcSize = IntSize(vBitmap.width, vBitmap.height),
dstOffset = IntOffset.Zero, dstOffset = IntOffset.Zero,
dstSize = IntSize(vWpx, vHpx), dstSize = IntSize(vWpx, vHpx),
alpha = fAlpha, alpha = mAlpha,
colorFilter = fColorFilter, 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" val qualifiedName: String get() = if (prefix.isEmpty()) localName else "$prefix:$localName"
} }
private class NodeListImpl(private val fNodes: List<Node>) : NodeList { private class NodeListImpl(private val mNodes: List<Node>) : NodeList {
override fun item(i: Int): Node = fNodes[i] override fun item(i: Int): Node = mNodes[i]
override val length: Int get() = fNodes.size override val length: Int get() = mNodes.size
} }
private class TextNodeImpl(private val fText: String) : Node { private class TextNodeImpl(private val mText: String) : Node {
override val textContent: String? get() = fText override val textContent: String? get() = mText
override val nodeName: String get() = "#text" override val nodeName: String get() = "#text"
override val localName: String get() = "" override val localName: String get() = ""
override val childNodes: NodeList = NodeListImpl(emptyList()) override val childNodes: NodeList = NodeListImpl(emptyList())
@@ -47,16 +47,16 @@ private class TextNodeImpl(private val fText: String) : Node {
} }
private class ElementImpl( private class ElementImpl(
private val fPrefix: String, private val mPrefix: String,
override val localName: String, override val localName: String,
override val namespaceURI: 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). // 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 { ) : Element {
var children: List<Node> = emptyList() 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) override val childNodes: NodeList get() = NodeListImpl(children)
// Concatenated descendant text - what DOM's textContent does. // Concatenated descendant text - what DOM's textContent does.
@@ -72,7 +72,7 @@ private class ElementImpl(
override fun lookupPrefix(namespaceURI: String): String { override fun lookupPrefix(namespaceURI: String): String {
if (namespaceURI.isEmpty()) return "" if (namespaceURI.isEmpty()) return ""
for (vScope in fNsScope) { for (vScope in mNsScope) {
for ((vPrefix, vUri) in vScope) { for ((vPrefix, vUri) in vScope) {
if (vUri == namespaceURI && vPrefix.isNotEmpty()) return vPrefix if (vUri == namespaceURI && vPrefix.isNotEmpty()) return vPrefix
} }
@@ -81,22 +81,22 @@ private class ElementImpl(
} }
override fun getAttributeNS(nameSpaceURI: String, localName: String): String = 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 = 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. // The parser - one pass over the string with a cursor.
private class XmlDomParser(private val fXml: String) { private class XmlDomParser(private val mXml: String) {
private var fPos = 0 private var mPos = 0
fun skipProlog() { fun skipProlog() {
while (true) { while (true) {
skipWhitespace() skipWhitespace()
when { when {
lookingAt("") -> fPos++ lookingAt("") -> mPos++
lookingAt("<?") -> skipUntil("?>") lookingAt("<?") -> skipUntil("?>")
lookingAt("<!--") -> skipUntil("-->") lookingAt("<!--") -> skipUntil("-->")
lookingAt("<!DOCTYPE") -> skipDoctype() lookingAt("<!DOCTYPE") -> skipDoctype()
@@ -112,7 +112,7 @@ private class XmlDomParser(private val fXml: String) {
if (!lookingAt("<") || lookingAt("</")) return null if (!lookingAt("<") || lookingAt("</")) return null
expect("<") expect("<")
val vQName = readName() 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 // Attributes (collect raw first - xmlns declarations shape the scope
// that THIS element's own prefix resolves against). // that THIS element's own prefix resolves against).
@@ -121,7 +121,7 @@ private class XmlDomParser(private val fXml: String) {
skipWhitespace() skipWhitespace()
if (lookingAt("/>") || lookingAt(">")) break if (lookingAt("/>") || lookingAt(">")) break
val vAttrName = readName() val vAttrName = readName()
if (vAttrName.isEmpty()) throw MalformedXMLException("bad attribute at $fPos") if (vAttrName.isEmpty()) throw MalformedXMLException("bad attribute at $mPos")
skipWhitespace(); expect("="); skipWhitespace() skipWhitespace(); expect("="); skipWhitespace()
vRawAttrs.add(vAttrName to readQuotedValue()) 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) val vElement = ElementImpl(vPrefix, vLocal, resolveNs(vPrefix), vAttrs, vScope)
if (lookingAt("/>")) { if (lookingAt("/>")) {
fPos += 2 mPos += 2
return vElement return vElement
} }
expect(">") expect(">")
@@ -169,9 +169,9 @@ private class XmlDomParser(private val fXml: String) {
val vChildren = ArrayList<Node>() val vChildren = ArrayList<Node>()
while (true) { while (true) {
when { when {
fPos >= fXml.length -> throw MalformedXMLException("unclosed <$vQName>") mPos >= mXml.length -> throw MalformedXMLException("unclosed <$vQName>")
lookingAt("</") -> { lookingAt("</") -> {
fPos += 2 mPos += 2
val vClose = readName() val vClose = readName()
skipWhitespace(); expect(">") skipWhitespace(); expect(">")
if (vClose != vQName) throw MalformedXMLException("mismatched </$vClose> for <$vQName>") if (vClose != vQName) throw MalformedXMLException("mismatched </$vClose> for <$vQName>")
@@ -180,18 +180,18 @@ private class XmlDomParser(private val fXml: String) {
} }
lookingAt("<!--") -> skipUntil("-->") lookingAt("<!--") -> skipUntil("-->")
lookingAt("<![CDATA[") -> { lookingAt("<![CDATA[") -> {
val vEnd = fXml.indexOf("]]>", fPos + 9) val vEnd = mXml.indexOf("]]>", mPos + 9)
if (vEnd < 0) throw MalformedXMLException("unclosed CDATA") if (vEnd < 0) throw MalformedXMLException("unclosed CDATA")
vChildren.add(TextNodeImpl(fXml.substring(fPos + 9, vEnd))) vChildren.add(TextNodeImpl(mXml.substring(mPos + 9, vEnd)))
fPos = vEnd + 3 mPos = vEnd + 3
} }
lookingAt("<?") -> skipUntil("?>") lookingAt("<?") -> skipUntil("?>")
lookingAt("<") -> parseElement(vScope)?.let { vChildren.add(it) } lookingAt("<") -> parseElement(vScope)?.let { vChildren.add(it) }
else -> { else -> {
val vNext = fXml.indexOf('<', fPos).let { if (it < 0) fXml.length else it } val vNext = mXml.indexOf('<', mPos).let { if (it < 0) mXml.length else it }
val vText = decodeEntities(fXml.substring(fPos, vNext)) val vText = decodeEntities(mXml.substring(mPos, vNext))
if (vText.isNotBlank()) vChildren.add(TextNodeImpl(vText)) if (vText.isNotBlank()) vChildren.add(TextNodeImpl(vText))
fPos = vNext mPos = vNext
} }
} }
} }
@@ -200,28 +200,28 @@ private class XmlDomParser(private val fXml: String) {
// ============ // ============
// Lexing helpers // 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) { private fun expect(inToken: String) {
if (!lookingAt(inToken)) throw MalformedXMLException("expected '$inToken' at $fPos") if (!lookingAt(inToken)) throw MalformedXMLException("expected '$inToken' at $mPos")
fPos += inToken.length mPos += inToken.length
} }
private fun skipWhitespace() { private fun skipWhitespace() {
while (fPos < fXml.length && fXml[fPos].isWhitespace()) fPos++ while (mPos < mXml.length && mXml[mPos].isWhitespace()) mPos++
} }
private fun skipUntil(inToken: String) { 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") 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 '>'. */ /* DOCTYPE may nest an internal subset in [ ] - skip to the matching '>'. */
private fun skipDoctype() { private fun skipDoctype() {
var vDepth = 0 var vDepth = 0
while (fPos < fXml.length) { while (mPos < mXml.length) {
val c = fXml[fPos++] val c = mXml[mPos++]
if (c == '[') vDepth++ if (c == '[') vDepth++
else if (c == ']') vDepth-- else if (c == ']') vDepth--
else if (c == '>' && vDepth <= 0) return else if (c == '>' && vDepth <= 0) return
@@ -230,24 +230,24 @@ private class XmlDomParser(private val fXml: String) {
} }
private fun readName(): String { private fun readName(): String {
val vStart = fPos val vStart = mPos
while (fPos < fXml.length) { while (mPos < mXml.length) {
val c = fXml[fPos] val c = mXml[mPos]
if (c.isWhitespace() || c == '=' || c == '>' || c == '/' || c == '<') break if (c.isWhitespace() || c == '=' || c == '>' || c == '/' || c == '<') break
fPos++ mPos++
} }
return fXml.substring(vStart, fPos) return mXml.substring(vStart, mPos)
} }
private fun readQuotedValue(): String { private fun readQuotedValue(): String {
if (fPos >= fXml.length) throw MalformedXMLException("unterminated attribute value") if (mPos >= mXml.length) throw MalformedXMLException("unterminated attribute value")
val vQuote = fXml[fPos] val vQuote = mXml[mPos]
if (vQuote != '"' && vQuote != '\'') throw MalformedXMLException("attribute value must be quoted at $fPos") if (vQuote != '"' && vQuote != '\'') throw MalformedXMLException("attribute value must be quoted at $mPos")
fPos++ mPos++
val vEnd = fXml.indexOf(vQuote, fPos) val vEnd = mXml.indexOf(vQuote, mPos)
if (vEnd < 0) throw MalformedXMLException("unterminated attribute value") if (vEnd < 0) throw MalformedXMLException("unterminated attribute value")
val vRaw = fXml.substring(fPos, vEnd) val vRaw = mXml.substring(mPos, vEnd)
fPos = vEnd + 1 mPos = vEnd + 1
return decodeEntities(vRaw) 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 <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 <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/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/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] 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,21 @@
package com.compose.sdl 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.runtime.Composable
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.runtime.Composition import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Recomposer import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshots.Snapshot 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.*
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.cinterop.toKString
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.alloc
import kotlinx.cinterop.ptr
import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.pointed
import sdl3.SDL_Delay 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_GetTicks
import sdl3.SDL_GetTicksNS
import sdl3.SDL_Quit import sdl3.SDL_Quit
import sdl3.SDL_SetWindowTitle
import sdl3.SDL_WaitEventTimeout import sdl3.SDL_WaitEventTimeout
// ================== // ==================
@@ -92,6 +67,14 @@ fun ApplicationScope.Window(
height: Int = 600, height: Int = 600,
gpu: GpuMode = GpuMode.Auto, gpu: GpuMode = GpuMode.Auto,
icon: AppWindowIcon? = null, 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, onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
content: @Composable ComposeWindowScope.() -> Unit, content: @Composable ComposeWindowScope.() -> Unit,
) { ) {
@@ -99,6 +82,16 @@ fun ApplicationScope.Window(
?: error("Window() must be called inside nativeComposeApp { ... }") ?: error("Window() must be called inside nativeComposeApp { ... }")
val vContent = rememberUpdatedState(content) val vContent = rememberUpdatedState(content)
val vClose = rememberUpdatedState(onCloseRequest) 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 { val vWindow = remember {
vScope.runtime.createWindow( vScope.runtime.createWindow(
inTitle = title, inTitle = title,
@@ -106,7 +99,12 @@ fun ApplicationScope.Window(
inHeight = height, inHeight = height,
inGpu = gpu, inGpu = gpu,
inIcon = icon, inIcon = icon,
inAttrs = vAttrs,
inOnFrame = onFrame, 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 // The window composition reads the holder each recomposition, so
// updated content lambdas propagate without recreating the window. // updated content lambdas propagate without recreating the window.
inContent = { vContent.value }, inContent = { vContent.value },
@@ -115,6 +113,12 @@ fun ApplicationScope.Window(
SideEffect { SideEffect {
if (vWindow.facade.title != title) vWindow.facade.setTitle(title) 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) { DisposableEffect(Unit) {
onDispose { vScope.runtime.scheduleDestroy(vWindow) } onDispose { vScope.runtime.scheduleDestroy(vWindow) }
} }
@@ -136,7 +140,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
runtime.scope = this runtime.scope = this
// App-level composition: no UI tree - it only declares Window()s. // App-level composition: no UI tree - it only declares Window()s.
val appClock = SDL3FrameClock() val appClock = Sdl3FrameClock()
val appRecomposer = Recomposer(coroutineContext + appClock) val appRecomposer = Recomposer(coroutineContext + appClock)
// The clock must be in the collector's context - runRecomposeAndApplyChanges // The clock must be in the collector's context - runRecomposeAndApplyChanges
// awaits parent frames through it. // awaits parent frames through it.
@@ -311,74 +315,6 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
SDL_Quit() 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. /** Single-window compatibility wrapper - the pre-multi-window entry point.
Closing the window exits the app, exactly as before. */ Closing the window exits the app, exactly as before. */
fun nativeComposeWindow( fun nativeComposeWindow(
@@ -387,6 +323,14 @@ fun nativeComposeWindow(
height: Int = 600, height: Int = 600,
gpu: GpuMode = GpuMode.Auto, gpu: GpuMode = GpuMode.Auto,
icon: AppWindowIcon? = null, 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, onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
content: @Composable ComposeWindowScope.() -> Unit, content: @Composable ComposeWindowScope.() -> Unit,
) { ) {
@@ -398,592 +342,16 @@ fun nativeComposeWindow(
height = height, height = height,
gpu = gpu, gpu = gpu,
icon = icon, icon = icon,
undecorated = undecorated,
transparent = transparent,
resizable = resizable,
enabled = enabled,
focusable = focusable,
alwaysOnTop = alwaysOnTop,
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent,
onFrame = onFrame, onFrame = onFrame,
content = content, 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) @OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal object FrameProfiler { internal object FrameProfiler {
// null until first checked; then true/false for the run's lifetime. // 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 // Output file - resolved once from CDN_PROFILE. Writing to a file (not
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so // 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 // it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
// the cwd; CDN_PROFILE=<path> → that path. // the cwd; CDN_PROFILE=<path> → that path.
private var fPath: String = "cdn_profile.log" private var mPath: String = "cdn_profile.log"
val enabled: Boolean val enabled: Boolean
get() = fEnabled ?: run { get() = mEnabled ?: run {
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString() val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) mPath = vEnv
(vEnv != null).also { fEnabled = it } (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. // Insertion-ordered so the printed line follows the call order.
private val fSum = LinkedHashMap<String, Double>() private val mSum = LinkedHashMap<String, Double>()
private val fMax = LinkedHashMap<String, Double>() private val mMax = LinkedHashMap<String, Double>()
private var fFrames = 0 private var mFrames = 0
private var fLastPrintMs = SDL_GetTicks() private var mLastPrintMs = SDL_GetTicks()
private var fMark = 0uL private var mMark = 0uL
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() } fun mark() { if (enabled) mMark = SDL_GetPerformanceCounter() }
fun phase(inName: String) { fun phase(inName: String) {
if (!enabled) return if (!enabled) return
val vNow = SDL_GetPerformanceCounter() val vNow = SDL_GetPerformanceCounter()
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq val vMs = (vNow - mMark).toDouble() * 1000.0 / mFreq
fSum[inName] = (fSum[inName] ?: 0.0) + vMs mSum[inName] = (mSum[inName] ?: 0.0) + vMs
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs if (vMs > (mMax[inName] ?: 0.0)) mMax[inName] = vMs
fMark = vNow mMark = vNow
} }
fun frameDone(inRendered: Boolean) { fun frameDone(inRendered: Boolean) {
if (!enabled) return if (!enabled) return
if (inRendered) fFrames++ if (inRendered) mFrames++
val vNowMs = SDL_GetTicks() val vNowMs = SDL_GetTicks()
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) { if (vNowMs - mLastPrintMs >= 2000u && mFrames > 0) {
val vParts = fSum.keys.map { vName -> val vParts = mSum.keys.map { vName ->
val vAvg = (fSum[vName] ?: 0.0) / fFrames val vAvg = (mSum[vName] ?: 0.0) / mFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms" "$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 vLine = "[profile] frames=$mFrames avg/max " + vParts.joinToString(" ") + "\n"
val vFile = platform.posix.fopen(fPath, "a") val vFile = platform.posix.fopen(mPath, "a")
if (vFile != null) { if (vFile != null) {
platform.posix.fputs(vLine, vFile) platform.posix.fputs(vLine, vFile)
platform.posix.fclose(vFile) platform.posix.fclose(vFile)
} }
fSum.clear(); fMax.clear() mSum.clear(); mMax.clear()
fFrames = 0 mFrames = 0
fLastPrintMs = vNowMs 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
}
@@ -20,7 +20,7 @@ import sdl3.SDL_GetTicksNS
Both come from delegating to the runtime's BroadcastFrameClock, which is Both come from delegating to the runtime's BroadcastFrameClock, which is
exactly upstream's dispatch structure. */ exactly upstream's dispatch structure. */
internal class SDL3FrameClock : MonotonicFrameClock { internal class Sdl3FrameClock : MonotonicFrameClock {
private val broadcast = BroadcastFrameClock() private val broadcast = BroadcastFrameClock()
override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R = override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R =
@@ -36,16 +36,16 @@ import sdl3.SDL_GetCurrentThreadID
writes land at the loop's drainPending(), observable on the same frame. */ writes land at the loop's drainPending(), observable on the same frame. */
internal class Sdl3MainDispatcher : MainCoroutineDispatcher() { 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 // The SDL main thread - the dispatcher is constructed by nativeComposeApp
// on the thread that then runs the loop. // on the thread that then runs the loop.
private val fMainThreadId = SDL_GetCurrentThreadID() private val mMainThreadId = SDL_GetCurrentThreadID()
override val immediate: MainCoroutineDispatcher = ImmediateDispatcher() override val immediate: MainCoroutineDispatcher = ImmediateDispatcher()
override fun dispatch(context: CoroutineContext, block: Runnable) { 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 /** Drains all pending tasks. Called from the SDL3 main loop once per
@@ -54,7 +54,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
composition. */ composition. */
fun drainPending() { fun drainPending() {
while (true) { while (true) {
val vTask = fQueue.tryReceive().getOrNull() ?: break val vTask = mQueue.tryReceive().getOrNull() ?: break
runCatching { vTask.run() }.onFailure { vErr -> runCatching { vTask.run() }.onFailure { vErr ->
println("Sdl3MainDispatcher: dispatched task threw: ${vErr.message}") 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. /** Closes the queue. After this, dispatch() drops new posts silently.
Called from nativeComposeWindow as part of shutdown. */ Called from nativeComposeWindow as part of shutdown. */
fun close() { fun close() {
fQueue.close() mQueue.close()
} }
override fun toString(): String = "Sdl3MainDispatcher" override fun toString(): String = "Sdl3MainDispatcher"
@@ -75,10 +75,10 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
override val immediate: MainCoroutineDispatcher get() = this override val immediate: MainCoroutineDispatcher get() = this
override fun isDispatchNeeded(context: CoroutineContext): Boolean = override fun isDispatchNeeded(context: CoroutineContext): Boolean =
SDL_GetCurrentThreadID() != fMainThreadId SDL_GetCurrentThreadID() != mMainThreadId
override fun dispatch(context: CoroutineContext, block: Runnable) { override fun dispatch(context: CoroutineContext, block: Runnable) {
fQueue.trySend(block) mQueue.trySend(block)
} }
override fun toString(): String = "Sdl3MainDispatcher.immediate" 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 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 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 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] 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_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_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 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 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] 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.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 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/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.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]
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]
@@ -61,7 +61,7 @@ fun IconFontIcon(
} }
} }
object IconDefaults { internal object IconDefaults {
val DefaultIconSize: Dp = 24.dp val DefaultIconSize: Dp = 24.dp
/** STATIC fallback tint for direct IconFontIcon use - :foundation cannot /** STATIC fallback tint for direct IconFontIcon use - :foundation cannot
read material3's LocalContentColor (module layering, same as upstream). 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 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 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 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 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 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] 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] 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] 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 object Companion { // androidx.compose.ui.graphics.colorspace/Adaptation.Companion|null[0]
final val Bradford // androidx.compose.ui.graphics.colorspace/Adaptation.Companion.Bradford|{}Bradford[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 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 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 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] 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 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 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] 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_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_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.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$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_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_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 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] 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_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/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.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$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_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_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): 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/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/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/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] 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 - the given rect, and this seam carries no ui-layout (ContentScale) dependency -
keeping :ui-graphics free of :ui. keeping :ui-graphics free of :ui.
*/ */
interface NativePainterCanvas { internal interface NativePainterCanvas {
fun drawNativePainter( fun drawNativePainter(
inResourcePath: String, inResourcePath: String,
inKind: ResourceKind, inKind: ResourceKind,
@@ -10,7 +10,7 @@ package com.compose.sdl.graphics
carries a `commands: List<PathCommand>` that the SDL3 / Skia renderers walk carries a `commands: List<PathCommand>` that the SDL3 / Skia renderers walk
directly. No official equivalent, so lives in com.compose.sdl directly. No official equivalent, so lives in com.compose.sdl
per FIDELITY.md's relocate rule. */ per FIDELITY.md's relocate rule. */
sealed interface PathCommand { internal sealed interface PathCommand {
class MoveTo(val x: Float, val y: Float) : PathCommand class MoveTo(val x: Float, val y: Float) : PathCommand
class LineTo(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 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 `Canvas.restore()` - the backend composites and pops it on the matching
`restore()`. `restore()`.
*/ */
interface NativeShapeClipCanvas { internal interface NativeShapeClipCanvas {
// Pushes a rounded-rect clip for subsequent draws until the matching // Pushes a rounded-rect clip for subsequent draws until the matching
// Canvas.restore(). [inRoundRect] is in the CURRENT canvas-local coordinate // Canvas.restore(). [inRoundRect] is in the CURRENT canvas-local coordinate
// space (the same space draw calls use after the enclosing translate). // 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), Not supported: <group> transforms (paths are flattened, transforms ignored),
gradients, clip-paths, trim-path. This covers the common single-/multi-path gradients, clip-paths, trim-path. This covers the common single-/multi-path
icon case such as exported Material icons. */ icon case such as exported Material icons. */
object AndroidVectorToSvg { internal object AndroidVectorToSvg {
// Tag values never contain '>' (pathData is letters/digits/.,- /space) and // Tag values never contain '>' (pathData is letters/digits/.,- /space) and
// quoted attribute values can't either, so [^>] within a tag is safe. // 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. */ /** Picks a ResourceKind from a file extension. */
fun resourceKindForPath(path: String): ResourceKind { internal fun resourceKindForPath(path: String): ResourceKind {
val vExt = path.substringAfterLast('.', "").lowercase() val vExt = path.substringAfterLast('.', "").lowercase()
return when (vExt) { return when (vExt) {
"svg" -> ResourceKind.Svg "svg" -> ResourceKind.Svg
@@ -27,17 +27,17 @@ import com.compose.sdl.graphics.NativePainterCanvas
If the Canvas doesn't implement NativePainterCanvas (offscreen layer capture If the Canvas doesn't implement NativePainterCanvas (offscreen layer capture
for graphics-layer effects) the paint is skipped. for graphics-layer effects) the paint is skipped.
*/ */
class ResourcePainter internal constructor( internal class ResourcePainter internal constructor(
val resourcePath: String, val resourcePath: String,
val kind: ResourceKind, val kind: ResourceKind,
) : Painter() { ) : Painter() {
private var fAlpha: Float = 1f private var mAlpha: Float = 1f
override val intrinsicSize: Size override val intrinsicSize: Size
get() = currentImageLoader.intrinsicSize(resourcePath, kind) 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() { override fun DrawScope.onDraw() {
drawIntoCanvas { vCanvas -> drawIntoCanvas { vCanvas ->
@@ -45,7 +45,7 @@ class ResourcePainter internal constructor(
// translate + scaledSize, so we paint the resource FillBounds into the // translate + scaledSize, so we paint the resource FillBounds into the
// full DrawScope size. Alpha is threaded via applyAlpha. // full DrawScope size. Alpha is threaded via applyAlpha.
(vCanvas as? NativePainterCanvas)?.drawNativePainter( (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 { object NativeReleaseQueue {
private val fLock = SynchronizedObject() private val mLock = SynchronizedObject()
private var fPending = ArrayList<() -> Unit>() private var mPending = ArrayList<() -> Unit>()
/** Enqueue a release action. Safe to call from any thread. */ /** Enqueue a release action. Safe to call from any thread. */
fun enqueue(action: () -> Unit) { 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. * renderer APIs that aren't thread-safe. Returns the number drained.
*/ */
fun drain(): Int { fun drain(): Int {
val vBatch = synchronized(fLock) { val vBatch = synchronized(mLock) {
if (fPending.isEmpty()) return 0 if (mPending.isEmpty()) return 0
val vTaken = fPending val vTaken = mPending
fPending = ArrayList() mPending = ArrayList()
vTaken vTaken
} }
for (vAction in vBatch) { for (vAction in vBatch) {
@@ -38,7 +38,7 @@ class SkiaImageCache {
// (registerMemoryResource - e.g. downloaded PNGs) from growing image memory // (registerMemoryResource - e.g. downloaded PNGs) from growing image memory
// without limit (issue #2). On-screen images stay hot; an evicted one just // without limit (issue #2). On-screen images stay hot; an evicted one just
// re-decodes on next use. // 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 // SVG / Android-vector kinds are RESOLUTION-INDEPENDENT: instead of caching one
// intrinsic-size raster and letting drawImageRect upscale it (blurry when an icon // 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 // destination pixel size and cache that raster keyed by "path@WxH". Mirrors
// upstream's size-driven DrawCache for SVGPainter. Intrinsic dims are cached // upstream's size-driven DrawCache for SVGPainter. Intrinsic dims are cached
// separately so the layout pass (intrinsicSize) doesn't force a raster. // separately so the layout pass (intrinsicSize) doesn't force a raster.
private val fSvgRasterCache = LinkedHashMap<String, Image?>() private val mSvgRasterCache = LinkedHashMap<String, Image?>()
private val fSvgIntrinsic = HashMap<String, androidx.compose.ui.geometry.Size>() private val mSvgIntrinsic = HashMap<String, androidx.compose.ui.geometry.Size>()
fun intrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size { fun intrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size {
if (inKind == ResourceKind.Svg || inKind == ResourceKind.AndroidVector) { if (inKind == ResourceKind.Svg || inKind == ResourceKind.AndroidVector) {
@@ -58,18 +58,18 @@ class SkiaImageCache {
} }
private fun get(inPath: String, inKind: ResourceKind): Image? { 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 = // Re-insert to mark most-recently-used (LinkedHashMap keeps first =
// least-recently-used for eviction below). // least-recently-used for eviction below).
val vExisting = fCache.remove(inPath) val vExisting = mCache.remove(inPath)
fCache[inPath] = vExisting mCache[inPath] = vExisting
return vExisting return vExisting
} }
val vImage = decode(inPath, inKind) val vImage = decode(inPath, inKind)
fCache[inPath] = vImage mCache[inPath] = vImage
if (fCache.size > MAX_CACHED_IMAGES) { if (mCache.size > MAX_CACHED_IMAGES) {
val vEldest = fCache.keys.firstOrNull() val vEldest = mCache.keys.firstOrNull()
if (vEldest != null) fCache.remove(vEldest)?.close() if (vEldest != null) mCache.remove(vEldest)?.close()
} }
return vImage return vImage
} }
@@ -136,7 +136,7 @@ class SkiaImageCache {
/** Intrinsic (viewport) size of the vector, parsed once and cached. */ /** Intrinsic (viewport) size of the vector, parsed once and cached. */
private fun svgIntrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size { 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 vBytes = svgBytes(inPath, inKind) ?: return androidx.compose.ui.geometry.Size.Unspecified
val vSize = runCatching { val vSize = runCatching {
val vDom = SVGDOM(Data.makeFromBytes(vBytes)) val vDom = SVGDOM(Data.makeFromBytes(vBytes))
@@ -146,7 +146,7 @@ class SkiaImageCache {
vDom.close() vDom.close()
androidx.compose.ui.geometry.Size(vW, vH) androidx.compose.ui.geometry.Size(vW, vH)
}.getOrDefault(androidx.compose.ui.geometry.Size.Unspecified) }.getOrDefault(androidx.compose.ui.geometry.Size.Unspecified)
fSvgIntrinsic[inPath] = vSize mSvgIntrinsic[inPath] = vSize
return vSize return vSize
} }
@@ -157,9 +157,9 @@ class SkiaImageCache {
val vWpx = inW.roundToInt().coerceAtLeast(1) val vWpx = inW.roundToInt().coerceAtLeast(1)
val vHpx = inH.roundToInt().coerceAtLeast(1) val vHpx = inH.roundToInt().coerceAtLeast(1)
val vKey = "$inPath@${vWpx}x$vHpx" val vKey = "$inPath@${vWpx}x$vHpx"
val vImg = if (fSvgRasterCache.containsKey(vKey)) { val vImg = if (mSvgRasterCache.containsKey(vKey)) {
val vExisting = fSvgRasterCache.remove(vKey) val vExisting = mSvgRasterCache.remove(vKey)
fSvgRasterCache[vKey] = vExisting mSvgRasterCache[vKey] = vExisting
vExisting vExisting
} else { } else {
val vIntrinsic = svgIntrinsicSize(inPath, inKind) val vIntrinsic = svgIntrinsicSize(inPath, inKind)
@@ -167,9 +167,9 @@ class SkiaImageCache {
val vRaster = if (vBytes != null && vIntrinsic.isSpecified) { val vRaster = if (vBytes != null && vIntrinsic.isSpecified) {
rasterizeSvgAt(vBytes, vIntrinsic.width, vIntrinsic.height, vWpx, vHpx) rasterizeSvgAt(vBytes, vIntrinsic.width, vIntrinsic.height, vWpx, vHpx)
} else null } else null
fSvgRasterCache[vKey] = vRaster mSvgRasterCache[vKey] = vRaster
if (fSvgRasterCache.size > MAX_CACHED_IMAGES) { if (mSvgRasterCache.size > MAX_CACHED_IMAGES) {
fSvgRasterCache.keys.firstOrNull()?.let { fSvgRasterCache.remove(it)?.close() } mSvgRasterCache.keys.firstOrNull()?.let { mSvgRasterCache.remove(it)?.close() }
} }
vRaster vRaster
} ?: return } ?: return
@@ -183,11 +183,11 @@ class SkiaImageCache {
} }
fun destroy() { fun destroy() {
for (vImg in fCache.values) vImg?.close() for (vImg in mCache.values) vImg?.close()
fCache.clear() mCache.clear()
for (vImg in fSvgRasterCache.values) vImg?.close() for (vImg in mSvgRasterCache.values) vImg?.close()
fSvgRasterCache.clear() mSvgRasterCache.clear()
fSvgIntrinsic.clear() mSvgIntrinsic.clear()
} }
} }
@@ -18,35 +18,35 @@ package com.compose.sdl.icons
the renderer where to find the bytes. */ the renderer where to find the bytes. */
object IconFont { object IconFont {
private val fFonts = mutableMapOf<String, ByteArray>() private val mFonts = mutableMapOf<String, ByteArray>()
// Families that render as single variable-axis glyphs (Material Symbols), // Families that render as single variable-axis glyphs (Material Symbols),
// vs ordinary text fonts - icon families are drawn one codepoint at a time, // vs ordinary text fonts - icon families are drawn one codepoint at a time,
// while text families (e.g. a bundled monospace) go through normal // while text families (e.g. a bundled monospace) go through normal
// full-string rendering. // 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 /** Register a TEXT font family's bytes (rendered as full strings; e.g. a
bundled monospace). Idempotent - a second call with the same family bundled monospace). Idempotent - a second call with the same family
replaces the previous bytes. */ replaces the previous bytes. */
fun register(inFamily: String, inBytes: ByteArray) { fun register(inFamily: String, inBytes: ByteArray) {
fFonts[inFamily] = inBytes mFonts[inFamily] = inBytes
} }
/** Register an ICON font family (Material Symbols et al.) - variable-axis. /** Register an ICON font family (Material Symbols et al.) - variable-axis.
Same byte store as register(), but also flags the family as an icon Same byte store as register(), but also flags the family as an icon
font. */ font. */
fun registerIcon(inFamily: String, inBytes: ByteArray) { fun registerIcon(inFamily: String, inBytes: ByteArray) {
fFonts[inFamily] = inBytes mFonts[inFamily] = inBytes
fIconFamilies += inFamily mIconFamilies += inFamily
} }
/** Bytes for a registered family, or null if not registered. Renderers /** Bytes for a registered family, or null if not registered. Renderers
fall back to the default font when this returns null. */ 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 /** True only for families registered via registerIcon() - icon fonts that
need single-glyph (variable-axis) rendering, not text fonts. */ 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
} }
+109 -146
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 interface com.compose.sdl/ComposeWindowScope { // com.compose.sdl/ComposeWindowScope|null[0]
abstract val window // com.compose.sdl/ComposeWindowScope.window|{}window[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] 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 beginFrame(kotlin/Float) // com.compose.sdl/RenderBackend.beginFrame|beginFrame(kotlin.Float){}[0]
abstract fun destroy() // com.compose.sdl/RenderBackend.destroy|destroy(){}[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 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 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] 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] 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 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] 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] constructor <init>(kotlin/Float = ...) // com.compose.sdl.node/ComposeRootHost.<init>|<init>(kotlin.Float){}[0]
@@ -2850,37 +2822,12 @@ 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 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 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] final fun notifyOutsidePress(kotlin/Int, kotlin/Int) // com.compose.sdl.window/PopupHostState.notifyOutsidePress|notifyOutsidePress(kotlin.Int;kotlin.Int){}[0]
} }
final class com.compose.sdl/ComposeNativeWindow { // com.compose.sdl/ComposeNativeWindow|null[0] final class com.compose.sdl/ComposeNativeWindow { // com.compose.sdl/ComposeNativeWindow|null[0]
constructor <init>(com.compose.sdl/SDL3Backend, com.compose.sdl/GpuMode, kotlin/String) // com.compose.sdl/ComposeNativeWindow.<init>|<init>(com.compose.sdl.SDL3Backend;com.compose.sdl.GpuMode;kotlin.String){}[0] constructor <init>(com.compose.sdl/Sdl3Backend, com.compose.sdl/GpuMode, kotlin/String) // com.compose.sdl/ComposeNativeWindow.<init>|<init>(com.compose.sdl.Sdl3Backend;com.compose.sdl.GpuMode;kotlin.String){}[0]
final val fps // com.compose.sdl/ComposeNativeWindow.fps|{}fps[0] final val fps // com.compose.sdl/ComposeNativeWindow.fps|{}fps[0]
final fun <get-fps>(): kotlin/Int // com.compose.sdl/ComposeNativeWindow.fps.<get-fps>|<get-fps>(){}[0] final fun <get-fps>(): kotlin/Int // com.compose.sdl/ComposeNativeWindow.fps.<get-fps>|<get-fps>(){}[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 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 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 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 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 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 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 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 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 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 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 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 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 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] final val pixelHeight // com.compose.sdl/ComposeNativeWindow.pixelHeight|{}pixelHeight[0]
@@ -2915,50 +2872,79 @@ final class com.compose.sdl/ComposeNativeWindow { // com.compose.sdl/ComposeNati
final fun minimize() // com.compose.sdl/ComposeNativeWindow.minimize|minimize(){}[0] final fun minimize() // com.compose.sdl/ComposeNativeWindow.minimize|minimize(){}[0]
final fun onResized() // com.compose.sdl/ComposeNativeWindow.onResized|onResized(){}[0] final fun onResized() // com.compose.sdl/ComposeNativeWindow.onResized|onResized(){}[0]
final fun raise() // com.compose.sdl/ComposeNativeWindow.raise|raise(){}[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 requestCloseFromUser(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.requestCloseFromUser|requestCloseFromUser(){}[0]
final fun restore() // com.compose.sdl/ComposeNativeWindow.restore|restore(){}[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 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 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 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 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 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 toggleFullscreen() // com.compose.sdl/ComposeNativeWindow.toggleFullscreen|toggleFullscreen(){}[0]
final fun updateFps(kotlin/Int) // com.compose.sdl/ComposeNativeWindow.updateFps|updateFps(kotlin.Int){}[0] final fun updateFps(kotlin/Int) // com.compose.sdl/ComposeNativeWindow.updateFps|updateFps(kotlin.Int){}[0]
} }
final class com.compose.sdl/SDL3Backend { // com.compose.sdl/SDL3Backend|null[0] final class com.compose.sdl/RawSdlHandles { // com.compose.sdl/RawSdlHandles|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>(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 gpuMode // com.compose.sdl/SDL3Backend.gpuMode|{}gpuMode[0] final val glContext // com.compose.sdl/RawSdlHandles.glContext|{}glContext[0]
final fun <get-gpuMode>(): com.compose.sdl/GpuMode // com.compose.sdl/SDL3Backend.gpuMode.<get-gpuMode>|<get-gpuMode>(){}[0] final fun <get-glContext>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.glContext.<get-glContext>|<get-glContext>(){}[0]
final val pixelDensity // com.compose.sdl/SDL3Backend.pixelDensity|{}pixelDensity[0] final val metalView // com.compose.sdl/RawSdlHandles.metalView|{}metalView[0]
final fun <get-pixelDensity>(): kotlin/Float // com.compose.sdl/SDL3Backend.pixelDensity.<get-pixelDensity>|<get-pixelDensity>(){}[0] final fun <get-metalView>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.metalView.<get-metalView>|<get-metalView>(){}[0]
final val windowId // com.compose.sdl/SDL3Backend.windowId|{}windowId[0] final val renderer // com.compose.sdl/RawSdlHandles.renderer|{}renderer[0]
final fun <get-windowId>(): kotlin/UInt // com.compose.sdl/SDL3Backend.windowId.<get-windowId>|<get-windowId>(){}[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 var glContext // com.compose.sdl/SDL3Backend.glContext|{}glContext[0] final fun component1(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component1|component1(){}[0]
final fun <get-glContext>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/SDL3Backend.glContext.<get-glContext>|<get-glContext>(){}[0] final fun component2(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component2|component2(){}[0]
final var metalView // com.compose.sdl/SDL3Backend.metalView|{}metalView[0] final fun component3(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component3|component3(){}[0]
final fun <get-metalView>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/SDL3Backend.metalView.<get-metalView>|<get-metalView>(){}[0] final fun component4(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component4|component4(){}[0]
final var pixelHeight // com.compose.sdl/SDL3Backend.pixelHeight|{}pixelHeight[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 <get-pixelHeight>(): kotlin/Int // com.compose.sdl/SDL3Backend.pixelHeight.<get-pixelHeight>|<get-pixelHeight>(){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl/RawSdlHandles.equals|equals(kotlin.Any?){}[0]
final var pixelWidth // com.compose.sdl/SDL3Backend.pixelWidth|{}pixelWidth[0] final fun hashCode(): kotlin/Int // com.compose.sdl/RawSdlHandles.hashCode|hashCode(){}[0]
final fun <get-pixelWidth>(): kotlin/Int // com.compose.sdl/SDL3Backend.pixelWidth.<get-pixelWidth>|<get-pixelWidth>(){}[0] final fun toString(): kotlin/String // com.compose.sdl/RawSdlHandles.toString|toString(){}[0]
final var renderer // com.compose.sdl/SDL3Backend.renderer|{}renderer[0] }
final fun <get-renderer>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/SDL3Backend.renderer.<get-renderer>|<get-renderer>(){}[0]
final var vsyncEnabled // com.compose.sdl/SDL3Backend.vsyncEnabled|{}vsyncEnabled[0]
final fun <get-vsyncEnabled>(): kotlin/Boolean // com.compose.sdl/SDL3Backend.vsyncEnabled.<get-vsyncEnabled>|<get-vsyncEnabled>(){}[0]
final var window // com.compose.sdl/SDL3Backend.window|{}window[0]
final fun <get-window>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/SDL3Backend.window.<get-window>|<get-window>(){}[0]
final var windowHeight // com.compose.sdl/SDL3Backend.windowHeight|{}windowHeight[0]
final fun <get-windowHeight>(): kotlin/Int // com.compose.sdl/SDL3Backend.windowHeight.<get-windowHeight>|<get-windowHeight>(){}[0]
final var windowWidth // com.compose.sdl/SDL3Backend.windowWidth|{}windowWidth[0]
final fun <get-windowWidth>(): kotlin/Int // com.compose.sdl/SDL3Backend.windowWidth.<get-windowWidth>|<get-windowWidth>(){}[0]
final fun applyThemeIcon() // com.compose.sdl/SDL3Backend.applyThemeIcon|applyThemeIcon(){}[0] final class com.compose.sdl/Sdl3Backend { // com.compose.sdl/Sdl3Backend|null[0]
final fun destroy(kotlin/Boolean = ...) // com.compose.sdl/SDL3Backend.destroy|destroy(kotlin.Boolean){}[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 fun init(): kotlin/Boolean // com.compose.sdl/SDL3Backend.init|init(){}[0]
final fun updateWindowSize() // com.compose.sdl/SDL3Backend.updateWindowSize|updateWindowSize(){}[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]
final val pixelDensity // com.compose.sdl/Sdl3Backend.pixelDensity|{}pixelDensity[0]
final fun <get-pixelDensity>(): kotlin/Float // com.compose.sdl/Sdl3Backend.pixelDensity.<get-pixelDensity>|<get-pixelDensity>(){}[0]
final val windowId // com.compose.sdl/Sdl3Backend.windowId|{}windowId[0]
final fun <get-windowId>(): kotlin/UInt // com.compose.sdl/Sdl3Backend.windowId.<get-windowId>|<get-windowId>(){}[0]
final var glContext // com.compose.sdl/Sdl3Backend.glContext|{}glContext[0]
final fun <get-glContext>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/Sdl3Backend.glContext.<get-glContext>|<get-glContext>(){}[0]
final var metalView // com.compose.sdl/Sdl3Backend.metalView|{}metalView[0]
final fun <get-metalView>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/Sdl3Backend.metalView.<get-metalView>|<get-metalView>(){}[0]
final var pixelHeight // com.compose.sdl/Sdl3Backend.pixelHeight|{}pixelHeight[0]
final fun <get-pixelHeight>(): kotlin/Int // com.compose.sdl/Sdl3Backend.pixelHeight.<get-pixelHeight>|<get-pixelHeight>(){}[0]
final var pixelWidth // com.compose.sdl/Sdl3Backend.pixelWidth|{}pixelWidth[0]
final fun <get-pixelWidth>(): kotlin/Int // com.compose.sdl/Sdl3Backend.pixelWidth.<get-pixelWidth>|<get-pixelWidth>(){}[0]
final var renderer // com.compose.sdl/Sdl3Backend.renderer|{}renderer[0]
final fun <get-renderer>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/Sdl3Backend.renderer.<get-renderer>|<get-renderer>(){}[0]
final var vsyncEnabled // com.compose.sdl/Sdl3Backend.vsyncEnabled|{}vsyncEnabled[0]
final fun <get-vsyncEnabled>(): kotlin/Boolean // com.compose.sdl/Sdl3Backend.vsyncEnabled.<get-vsyncEnabled>|<get-vsyncEnabled>(){}[0]
final var window // com.compose.sdl/Sdl3Backend.window|{}window[0]
final fun <get-window>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/Sdl3Backend.window.<get-window>|<get-window>(){}[0]
final var windowHeight // com.compose.sdl/Sdl3Backend.windowHeight|{}windowHeight[0]
final fun <get-windowHeight>(): kotlin/Int // com.compose.sdl/Sdl3Backend.windowHeight.<get-windowHeight>|<get-windowHeight>(){}[0]
final var windowWidth // com.compose.sdl/Sdl3Backend.windowWidth|{}windowWidth[0]
final fun <get-windowWidth>(): kotlin/Int // com.compose.sdl/Sdl3Backend.windowWidth.<get-windowWidth>|<get-windowWidth>(){}[0]
final fun applyThemeIcon() // com.compose.sdl/Sdl3Backend.applyThemeIcon|applyThemeIcon(){}[0]
final fun destroy(kotlin/Boolean = ...) // com.compose.sdl/Sdl3Backend.destroy|destroy(kotlin.Boolean){}[0]
final fun init(): kotlin/Boolean // com.compose.sdl/Sdl3Backend.init|init(){}[0]
final fun updateWindowSize() // com.compose.sdl/Sdl3Backend.updateWindowSize|updateWindowSize(){}[0]
} }
final value class <#A: kotlin/Any?> androidx.compose.ui/SessionMutex { // androidx.compose.ui/SessionMutex|null[0] final value class <#A: kotlin/Any?> androidx.compose.ui/SessionMutex { // androidx.compose.ui/SessionMutex|null[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] 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 val button // com.compose.sdl/AppEvent.Pointer.button|{}button[0]
final fun <get-event>(): com.compose.sdl.input/LegacyPointerEvent // com.compose.sdl/AppEvent.Pointer.event.<get-event>|<get-event>(){}[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 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 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 component1(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.component1|component1(){}[0]
final fun component2(): kotlin/UInt // com.compose.sdl/AppEvent.Pointer.component2|component2(){}[0] final fun component2(): kotlin/Float // 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 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 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 hashCode(): kotlin/Int // com.compose.sdl/AppEvent.Pointer.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // com.compose.sdl/AppEvent.Pointer.toString|toString(){}[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 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 val isGpuAccelerated // com.compose.sdl/GpuMode.isGpuAccelerated|{}isGpuAccelerated[0]
final object Metal : com.compose.sdl/GpuMode.Skia { // com.compose.sdl/GpuMode.Skia.Metal|null[0] final fun <get-isGpuAccelerated>(): kotlin/Boolean // com.compose.sdl/GpuMode.isGpuAccelerated.<get-isGpuAccelerated>|<get-isGpuAccelerated>(){}[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 object Auto : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Auto|null[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 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 object CpuRaster : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.CpuRaster|null[0]
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Software.toString|toString(){}[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 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 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 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] 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_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_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 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 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 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] 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 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 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.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.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 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 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.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 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] final fun <get-LocalComposeNativeWindow>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl/ComposeNativeWindow> // com.compose.sdl/LocalComposeNativeWindow.<get-LocalComposeNativeWindow>|<get-LocalComposeNativeWindow>(){}[0]
@@ -4890,11 +4859,11 @@ 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_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$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_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_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_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_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_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_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_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_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_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] 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]
final var androidx.compose.ui.platform/isDebugInspectorInfoEnabled // androidx.compose.ui.platform/isDebugInspectorInfoEnabled|{}isDebugInspectorInfoEnabled[0] final var androidx.compose.ui.platform/isDebugInspectorInfoEnabled // androidx.compose.ui.platform/isDebugInspectorInfoEnabled|{}isDebugInspectorInfoEnabled[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_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/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 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.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.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.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/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/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.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] final fun com.compose.sdl/appDataDir(kotlin/String, kotlin/String): kotlin/String? // com.compose.sdl/appDataDir|appDataDir(kotlin.String;kotlin.String){}[0]
@@ -5420,13 +5383,13 @@ 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_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$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_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_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_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_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_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_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_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_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_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/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] 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]
final fun com.compose.sdl/fileManagerName(): kotlin/String // com.compose.sdl/fileManagerName|fileManagerName(){}[0] final fun com.compose.sdl/fileManagerName(): kotlin/String // com.compose.sdl/fileManagerName|fileManagerName(){}[0]
final fun com.compose.sdl/hasComposeResource(kotlin/String): kotlin/Boolean // com.compose.sdl/hasComposeResource|hasComposeResource(kotlin.String){}[0] final fun com.compose.sdl/hasComposeResource(kotlin/String): kotlin/Boolean // com.compose.sdl/hasComposeResource|hasComposeResource(kotlin.String){}[0]
final fun com.compose.sdl/injectKey(kotlin/Int, kotlin/Boolean) // com.compose.sdl/injectKey|injectKey(kotlin.Int;kotlin.Boolean){}[0] final fun com.compose.sdl/injectKey(kotlin/Int, kotlin/Boolean) // com.compose.sdl/injectKey|injectKey(kotlin.Int;kotlin.Boolean){}[0]
@@ -14,7 +14,7 @@ import androidx.compose.ui.unit.DpSize
* StubOwner). Constant values match the upstream `PlatformContext.DefaultViewConfiguration` * StubOwner). Constant values match the upstream `PlatformContext.DefaultViewConfiguration`
* (longPressTimeout=500ms, doubleTapTimeout=300ms, doubleTapMinTime=40ms, touchSlop=8f). * (longPressTimeout=500ms, doubleTapTimeout=300ms, doubleTapMinTime=40ms, touchSlop=8f).
*/ */
object DefaultViewConfiguration : ViewConfiguration { internal object DefaultViewConfiguration : ViewConfiguration {
override val longPressTimeoutMillis: Long = 500 override val longPressTimeoutMillis: Long = 500
override val doubleTapTimeoutMillis: Long = 300 override val doubleTapTimeoutMillis: Long = 300
override val doubleTapMinTimeMillis: Long = 40 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 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) { 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 vD.x = inX; vD.y = inY; vD.w = inW; vD.h = inH; vD.onDismiss = inOnDismiss
} }
internal fun removeDismisser(inId: Any) { 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 /** Dismiss every registered popup whose content rect does NOT contain the
press. Called from the window's press dispatch; never consumes the press. */ press. Called from the window's press dispatch; never consumes the press. */
fun notifyOutsidePress(inX: Int, inY: Int) { fun notifyOutsidePress(inX: Int, inY: Int) {
if (fDismissers.isEmpty()) return if (mDismissers.isEmpty()) return
// Copy: an onDismiss typically removes the dismisser (state write). // 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() 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 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 composition, not the owner's) observes that, plays its animation, and MUST
end with [finish], which actually removes the entry. */ end with [finish], which actually removes the entry. */
class PopupExitHandle internal constructor( internal class PopupExitHandle internal constructor(
private val fHost: PopupHostState, // owning host - target of finish() private val mHost: PopupHostState, // owning host - target of finish()
private val fEntry: PopupHostState.Entry, // the entry this handle controls private val mEntry: PopupHostState.Entry, // the entry this handle controls
) { ) {
/** True once the owning Popup left the composition and the host is waiting /** True once the owning Popup left the composition and the host is waiting
for this entry's exit animation. Observable - recomposes the content. */ 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. */ /** 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. */ /** 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 /** Per-entry handle, provided by PopupLayer around each hosted content. Null
outside a popup layer. NB: safe to expose through the caller-locals rewrap outside a popup layer. NB: safe to expose through the caller-locals rewrap
in Popup (CompositionLocalProvider(callerContext)) - the caller never in Popup (CompositionLocalProvider(callerContext)) - the caller never
provides this local, so the layer's per-entry value stays visible. */ 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 // MARK: PopupLayer
@@ -187,7 +187,7 @@ fun PopupLayer(inHost: PopupHostState) {
popup host so the dismissing press is NOT consumed (it still reaches whatever popup host so the dismissing press is NOT consumed (it still reaches whatever
is under it - no dead first click). */ is under it - no dead first click). */
@Composable @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 vHost = LocalPopupHost.current
val vId = remember { Any() } val vId = remember { Any() }
SideEffect { vHost.setDismisser(vId, inX, inY, inW, inH, onDismissRequest) } 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 `x`/`y` are LAYOUT PIXELS (matches `LayoutCoordinates.positionInRoot`), not
`Dp` - layout runs in physical pixels under the Option-B density flow. */ `Dp` - layout runs in physical pixels under the Option-B density flow. */
@Composable @Composable
fun PositionedPopup( internal fun PositionedPopup(
x: Int, x: Int,
y: Int, y: Int,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
@@ -8,7 +8,7 @@ import androidx.compose.ui.geometry.Offset
/* Actuals for the vendored `DragAndDrop.kt` expect classes. /* Actuals for the vendored `DragAndDrop.kt` expect classes.
* Backed by SDL3's SDL_EVENT_DROP_* stream (file / text / begin / position / * Backed by SDL3's SDL_EVENT_DROP_* stream (file / text / begin / position /
* complete) - see Sdl3DragAndDropOwner + SDL3EventMapper for the pump side. * complete) - see Sdl3DragAndDropOwner + AppEvent.kt for the pump side.
* *
* DragAndDropTransferData carries the payload a drop into the window * DragAndDropTransferData carries the payload a drop into the window
* delivered: zero or more file paths (SDL_EVENT_DROP_FILE) plus an optional * delivered: zero or more file paths (SDL_EVENT_DROP_FILE) plus an optional
@@ -10,7 +10,7 @@ import androidx.compose.ui.node.ModifierNodeElement
/** The per-window DragAndDropManager backing ComposeOwner. Holds a root /** The per-window DragAndDropManager backing ComposeOwner. Holds a root
* DragAndDropNode installed on the root LayoutNode via [modifier]; the SDL * DragAndDropNode installed on the root LayoutNode via [modifier]; the SDL
* event pump (see SDL3EventMapper + ComposeWindow) drives [handleDrop] which * event pump (see AppEvent.kt + ComposeWindow) drives [handleDrop] which
* accumulates BEGIN → FILE / TEXT / POSITION → COMPLETE into a single * accumulates BEGIN → FILE / TEXT / POSITION → COMPLETE into a single
* DragAndDropEvent and dispatches through the node tree. * DragAndDropEvent and dispatches through the node tree.
* *
@@ -8,8 +8,8 @@ package androidx.compose.ui.input.key
/** /**
* Project [Key] actual - assigns a unique sequential Long to each named * Project [Key] actual - assigns a unique sequential Long to each named
* key. SDL3EventMapper picks the right `Key.X` by switch on SDL scancode * key. The SDL event mapper picks the right `Key.X` by switch on SDL scancode
* (`kKeyForScancode` in `SDL3EventMapper.kt`). Comparison works because * (`kKeyForScancode` in `AppEvent.kt`). Comparison works because
* Key is a value class - `Key(42L) == Key(42L)`. * Key is a value class - `Key(42L) == Key(42L)`.
*/ */
@kotlin.jvm.JvmInline @kotlin.jvm.JvmInline
@@ -103,14 +103,14 @@ private const val kImageMime = "image/png"
// call with an image). Held on nativeHeap because SDL keeps the callback alive // call with an image). Held on nativeHeap because SDL keeps the callback alive
// until the clipboard is cleared or another SDL_SetClipboardData replaces us - // until the clipboard is cleared or another SDL_SetClipboardData replaces us -
// a Kotlin heap reference would break as soon as the caller's coroutine // a Kotlin heap reference would break as soon as the caller's coroutine
// returned. Read by [fClipboardDataCallback], freed by [fClipboardCleanupCallback]. // returned. Read by [mClipboardDataCallback], freed by [mClipboardCleanupCallback].
private var fOutgoingImagePtr: CPointer<ByteVar>? = null private var mOutgoingImagePtr: CPointer<ByteVar>? = null
private var fOutgoingImageSize: Int = 0 private var mOutgoingImageSize: Int = 0
// SDL fires this on the main event thread when another app asks the clipboard // 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 // 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. // SDL_SetClipboardData time. Return NULL if the MIME doesn't match.
private val fClipboardDataCallback = staticCFunction { private val mClipboardDataCallback = staticCFunction {
userdata: COpaquePointer?, userdata: COpaquePointer?,
mimeType: CPointer<ByteVar>?, mimeType: CPointer<ByteVar>?,
sizePtr: CPointer<platform.posix.size_tVar>?, sizePtr: CPointer<platform.posix.size_tVar>?,
@@ -118,20 +118,20 @@ private val fClipboardDataCallback = staticCFunction {
userdata.hashCode() // unused userdata.hashCode() // unused
val vRequested = mimeType?.toKString() ?: return@staticCFunction null val vRequested = mimeType?.toKString() ?: return@staticCFunction null
if (vRequested != kImageMime) return@staticCFunction null if (vRequested != kImageMime) return@staticCFunction null
val vPtr = fOutgoingImagePtr ?: return@staticCFunction null val vPtr = mOutgoingImagePtr ?: return@staticCFunction null
sizePtr?.pointed?.value = fOutgoingImageSize.toULong() sizePtr?.pointed?.value = mOutgoingImageSize.toULong()
vPtr as COpaquePointer vPtr as COpaquePointer
} }
// SDL calls this when the clipboard we set is cleared or overwritten (by us // SDL calls this when the clipboard we set is cleared or overwritten (by us
// or another app). Frees the nativeHeap allocation. // or another app). Frees the nativeHeap allocation.
private val fClipboardCleanupCallback = staticCFunction { private val mClipboardCleanupCallback = staticCFunction {
userdata: COpaquePointer?, userdata: COpaquePointer?,
-> ->
userdata.hashCode() // unused userdata.hashCode() // unused
fOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) } mOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
fOutgoingImagePtr = null mOutgoingImagePtr = null
fOutgoingImageSize = 0 mOutgoingImageSize = 0
Unit Unit
} }
@@ -172,19 +172,19 @@ private fun sdlReadImage(): ByteArray? = memScoped {
private fun sdlWriteImage(bytes: ByteArray) { private fun sdlWriteImage(bytes: ByteArray) {
// Free any previously-held buffer proactively (the cleanup callback also // Free any previously-held buffer proactively (the cleanup callback also
// fires from SDL, but doing it eagerly here handles same-thread re-set). // 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) val vPtr = nativeHeap.allocArray<ByteVar>(bytes.size)
for (i in bytes.indices) vPtr[i] = bytes[i] for (i in bytes.indices) vPtr[i] = bytes[i]
fOutgoingImagePtr = vPtr mOutgoingImagePtr = vPtr
fOutgoingImageSize = bytes.size mOutgoingImageSize = bytes.size
memScoped { memScoped {
val vMimeArray = allocArray<CPointerVar<ByteVar>>(1) val vMimeArray = allocArray<CPointerVar<ByteVar>>(1)
vMimeArray[0] = kImageMime.cstr.ptr vMimeArray[0] = kImageMime.cstr.ptr
SDL_SetClipboardData( SDL_SetClipboardData(
fClipboardDataCallback, mClipboardDataCallback,
fClipboardCleanupCallback, mClipboardCleanupCallback,
null, null,
vMimeArray, vMimeArray,
1.toULong(), 1.toULong(),
@@ -213,10 +213,10 @@ private fun sdlWriteEntry(entry: ClipEntry?) {
// The SDL3-backed Clipboard implementation. Singleton because SDL's clipboard // The SDL3-backed Clipboard implementation. Singleton because SDL's clipboard
// is process-global - allocating a new one per Window() would only waste // is process-global - allocating a new one per Window() would only waste
// memory. Consumed by :desktop-native-window when seeding LocalClipboard. // memory. Consumed by :desktop-native-window when seeding LocalClipboard.
private object SDL3Clipboard : Clipboard { private object Sdl3Clipboard : Clipboard {
override suspend fun getClipEntry(): ClipEntry? = sdlReadEntry() override suspend fun getClipEntry(): ClipEntry? = sdlReadEntry()
override suspend fun setClipEntry(clipEntry: ClipEntry?) { sdlWriteEntry(clipEntry) } override suspend fun setClipEntry(clipEntry: ClipEntry?) { sdlWriteEntry(clipEntry) }
override val nativeClipboard: NativeClipboard get() = nativeClipboardSentinel override val nativeClipboard: NativeClipboard get() = nativeClipboardSentinel
} }
fun platformClipboard(): Clipboard = SDL3Clipboard fun platformClipboard(): Clipboard = Sdl3Clipboard
@@ -18,12 +18,12 @@ import androidx.compose.ui.unit.IntRect
* no-ops. * no-ops.
*/ */
private class StubSemanticsRegion : SemanticsRegion { private class StubSemanticsRegion : SemanticsRegion {
private var fBounds: IntRect = IntRect.Zero private var mBounds: IntRect = IntRect.Zero
override fun set(rect: IntRect) { fBounds = rect } override fun set(rect: IntRect) { mBounds = rect }
override fun intersect(region: SemanticsRegion): Boolean = false override fun intersect(region: SemanticsRegion): Boolean = false
override fun difference(rect: IntRect): Boolean = false override fun difference(rect: IntRect): Boolean = false
override val bounds: IntRect get() = fBounds override val bounds: IntRect get() = mBounds
override val isEmpty: Boolean get() = fBounds == IntRect.Zero override val isEmpty: Boolean get() = mBounds == IntRect.Zero
} }
internal actual fun SemanticsRegion(): SemanticsRegion = StubSemanticsRegion() internal actual fun SemanticsRegion(): SemanticsRegion = StubSemanticsRegion()
@@ -5,19 +5,27 @@ import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import com.compose.sdl.input.LegacyPointerEvent
import kotlinx.cinterop.* import kotlinx.cinterop.*
import sdl3.* import sdl3.*
// ================== // ==================
// MARK: SDL3EventMapper // MARK: AppEvent + the SDL3 event mapper
// ================== // ==================
/** Events carry the SDL window id they belong to (0 = unknown - injected test /** Events carry the SDL window id they belong to (0 = unknown - injected test
events; the loop routes those to its first window). Quit is app-level. */ events; the loop routes those to its first window). Quit is app-level. */
sealed class AppEvent { sealed class AppEvent {
data object Quit : 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 Key(val event: KeyEvent, val windowId: UInt = 0u) : AppEvent()
data class TextInput(val text: String, 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 /** IME preedit / composition (SDL_EVENT_TEXT_EDITING). Empty text = composition
@@ -75,34 +83,36 @@ fun pollEvents(): List<AppEvent> {
} }
private fun mapEvent(e: SDL_Event): AppEvent? { private fun mapEvent(e: SDL_Event): AppEvent? {
val type = e.type return when (e.type) {
return when (type) {
SDL_EVENT_QUIT -> AppEvent.Quit SDL_EVENT_QUIT -> AppEvent.Quit
SDL_EVENT_MOUSE_BUTTON_DOWN -> { SDL_EVENT_MOUSE_BUTTON_DOWN -> {
val mb = e.button val mb = e.button
AppEvent.Pointer(LegacyPointerEvent( AppEvent.Pointer(
x = mb.x, y = mb.y, x = mb.x, y = mb.y,
type = PointerEventType.Press, type = PointerEventType.Press,
button = mapButton(mb.button) button = mapButton(mb.button),
), mb.windowID) windowId = mb.windowID,
)
} }
SDL_EVENT_MOUSE_BUTTON_UP -> { SDL_EVENT_MOUSE_BUTTON_UP -> {
val mb = e.button val mb = e.button
AppEvent.Pointer(LegacyPointerEvent( AppEvent.Pointer(
x = mb.x, y = mb.y, x = mb.x, y = mb.y,
type = PointerEventType.Release, type = PointerEventType.Release,
button = mapButton(mb.button) button = mapButton(mb.button),
), mb.windowID) windowId = mb.windowID,
)
} }
SDL_EVENT_MOUSE_MOTION -> { SDL_EVENT_MOUSE_MOTION -> {
val mm = e.motion val mm = e.motion
AppEvent.Pointer(LegacyPointerEvent( AppEvent.Pointer(
x = mm.x, y = mm.y, x = mm.x, y = mm.y,
type = PointerEventType.Move type = PointerEventType.Move,
), mm.windowID) windowId = mm.windowID,
)
} }
SDL_EVENT_KEY_DOWN -> mapKey(e.key, KeyEventType.KeyDown) SDL_EVENT_KEY_DOWN -> mapKey(e.key, KeyEventType.KeyDown)
@@ -5,14 +5,18 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.reinterpret import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.toKString
import sdl3.SDL_GetRendererName
import sdl3.SDL_MaximizeWindow import sdl3.SDL_MaximizeWindow
import sdl3.SDL_MinimizeWindow import sdl3.SDL_MinimizeWindow
import sdl3.SDL_RaiseWindow import sdl3.SDL_RaiseWindow
import sdl3.SDL_RestoreWindow import sdl3.SDL_RestoreWindow
import sdl3.SDL_SetWindowAlwaysOnTop
import sdl3.SDL_SetWindowBordered
import sdl3.SDL_SetWindowFocusable
import sdl3.SDL_SetWindowFullscreen import sdl3.SDL_SetWindowFullscreen
import sdl3.SDL_SetWindowOpacity
import sdl3.SDL_SetWindowResizable
import sdl3.SDL_SetWindowSize import sdl3.SDL_SetWindowSize
import sdl3.SDL_SetWindowTitle import sdl3.SDL_SetWindowTitle
@@ -30,46 +34,46 @@ import sdl3.SDL_SetWindowTitle
them subscribes the caller to recomposition. The setter methods them subscribes the caller to recomposition. The setter methods
call into SDL and also push the new value into the snapshot. */ call into SDL and also push the new value into the snapshot. */
class ComposeNativeWindow constructor( class ComposeNativeWindow constructor(
private val backend: SDL3Backend, private val backend: Sdl3Backend,
val gpuMode: GpuMode, val gpuMode: GpuMode,
initialTitle: String, initialTitle: String,
) { ) {
private var fWidth by mutableStateOf(backend.windowWidth) private var mWidth by mutableStateOf(backend.windowWidth)
private var fHeight by mutableStateOf(backend.windowHeight) private var mHeight by mutableStateOf(backend.windowHeight)
private var fPixelWidth by mutableStateOf(backend.pixelWidth) private var mPixelWidth by mutableStateOf(backend.pixelWidth)
private var fPixelHeight by mutableStateOf(backend.pixelHeight) private var mPixelHeight by mutableStateOf(backend.pixelHeight)
private var fTitle by mutableStateOf(initialTitle) private var mTitle by mutableStateOf(initialTitle)
private var fMinimized by mutableStateOf(false) private var mMinimized by mutableStateOf(false)
private var fMaximized by mutableStateOf(false) private var mMaximized by mutableStateOf(false)
private var fFullscreen by mutableStateOf(false) private var mFullscreen by mutableStateOf(false)
private var fCloseRequested by mutableStateOf(false) private var mCloseRequested by mutableStateOf(false)
private var fFps by mutableStateOf(0) private var mFps by mutableStateOf(0)
// ============ // ============
// State // State
/** Logical (point) width - the same units layout uses. */ /** Logical (point) width - the same units layout uses. */
val width: Int get() = fWidth val width: Int get() = mWidth
val height: Int get() = fHeight val height: Int get() = mHeight
/** Physical (pixel) back-buffer size; on Retina this is `width * pixelDensity`. */ /** Physical (pixel) back-buffer size; on Retina this is `width * pixelDensity`. */
val pixelWidth: Int get() = fPixelWidth val pixelWidth: Int get() = mPixelWidth
val pixelHeight: Int get() = fPixelHeight val pixelHeight: Int get() = mPixelHeight
val title: String get() = fTitle val title: String get() = mTitle
val isMinimized: Boolean get() = fMinimized val isMinimized: Boolean get() = mMinimized
val isMaximized: Boolean get() = fMaximized val isMaximized: Boolean get() = mMaximized
val isFullscreen: Boolean get() = fFullscreen val isFullscreen: Boolean get() = mFullscreen
val pixelDensity: Float get() = backend.pixelDensity val pixelDensity: Float get() = backend.pixelDensity
/** Frames per second, refreshed ~once a second by the render loop. Snapshot- /** Frames per second, refreshed ~once a second by the render loop. Snapshot-
backed, so reading it in a composable recomposes when it changes. */ 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: /** Human-readable name of the rendering pipeline:
"Skia / Metal", "Skia / OpenGL", "Skia / CPU raster". */ "Skia / Metal", "Skia / OpenGL", "Skia / CPU raster". */
val rendererName: String val rendererName: String
get() = when (gpuMode) { get() = when (gpuMode) {
is GpuMode.Skia.Metal -> "Skia / Metal" is GpuMode.Metal -> "Skia / Metal"
is GpuMode.Skia.OpenGL -> "Skia / OpenGL" is GpuMode.OpenGL -> "Skia / OpenGL"
is GpuMode.Software -> "Skia / CPU raster" is GpuMode.CpuRaster -> "Skia / CPU raster"
is GpuMode.Auto -> "Auto (unresolved)" is GpuMode.Auto -> "Auto (unresolved)"
} }
@@ -79,7 +83,7 @@ class ComposeNativeWindow constructor(
fun setTitle(inTitle: String) { fun setTitle(inTitle: String) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_SetWindowTitle(vWindow, inTitle) SDL_SetWindowTitle(vWindow, inTitle)
fTitle = inTitle mTitle = inTitle
} }
/** Logical size in points - SDL fires AppEvent.WindowResized which /** Logical size in points - SDL fires AppEvent.WindowResized which
@@ -91,56 +95,111 @@ class ComposeNativeWindow constructor(
fun minimize() { fun minimize() {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_MinimizeWindow(vWindow); fMinimized = true SDL_MinimizeWindow(vWindow); mMinimized = true
} }
fun maximize() { fun maximize() {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return 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() { fun restore() {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return 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) { fun setFullscreen(inFullscreen: Boolean) {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return 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() { fun raise() {
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
SDL_RaiseWindow(vWindow) 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. /** 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 Same effect as the user closing the window via the OS. Bypasses any
onCloseRequest handler - this is the "really quit now" path. */ onCloseRequest handler - this is the "really quit now" path. */
fun close() { fCloseRequested = true } fun close() { mCloseRequested = true }
// ============ // ============
// Close interception // 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 /** 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 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() it (e.g. to show an "unsaved changes" dialog first, then call close()
once the user confirms). Pass null to clear. */ once the user confirms). Pass null to clear. */
fun setOnCloseRequest(inHandler: (() -> Boolean)?) { fOnCloseRequest = inHandler } fun setOnCloseRequest(inHandler: (() -> Boolean)?) { mOnCloseRequest = inHandler }
// ============ // ============
// Global key shortcuts // 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 /** 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 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. */ 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 // Framework hooks - driven by composeWindow's main loop (which lives in
@@ -148,26 +207,71 @@ class ComposeNativeWindow constructor(
// intended for app code. // intended for app code.
fun onResized() { fun onResized() {
fWidth = backend.windowWidth mWidth = backend.windowWidth
fHeight = backend.windowHeight mHeight = backend.windowHeight
fPixelWidth = backend.pixelWidth mPixelWidth = backend.pixelWidth
fPixelHeight = backend.pixelHeight mPixelHeight = backend.pixelHeight
} }
/** Called by the main loop ~once a second with the measured frame rate. */ /** 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 /** 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). */ 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 /** Driven by composeWindow's main loop for keys the focused node didn't
consume. Returns true if the shortcut handler handled it. */ 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 // 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 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. */ 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) inFilter.hashCode() // unused (selected filter index)
val vRef = inUserData?.asStableRef<(String?) -> Unit>() ?: return@staticCFunction val vRef = inUserData?.asStableRef<(String?) -> Unit>() ?: return@staticCFunction
val vHandler = vRef.get() val vHandler = vRef.get()
@@ -31,7 +31,7 @@ private val fDialogCallback = staticCFunction { inUserData: COpaquePointer?, inF
fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> Unit) { fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> Unit) {
val vRef = StableRef.create(inOnResult) val vRef = StableRef.create(inOnResult)
SDL_ShowSaveFileDialog( SDL_ShowSaveFileDialog(
fDialogCallback, mDialogCallback,
vRef.asCPointer(), vRef.asCPointer(),
null, // parent window (null = unparented) null, // parent window (null = unparented)
null, // no file-type filters null, // no file-type filters
@@ -45,7 +45,7 @@ fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> U
fun showOpenFileDialog(inOnResult: (String?) -> Unit) { fun showOpenFileDialog(inOnResult: (String?) -> Unit) {
val vRef = StableRef.create(inOnResult) val vRef = StableRef.create(inOnResult)
SDL_ShowOpenFileDialog( SDL_ShowOpenFileDialog(
fDialogCallback, mDialogCallback,
vRef.asCPointer(), vRef.asCPointer(),
null, // parent window null, // parent window
null, // filters null, // filters
@@ -4,28 +4,38 @@ package com.compose.sdl
// MARK: GpuMode // MARK: GpuMode
// ================== // ==================
/** Which GPU path the Skia renderer uses. /**
* Which drawing path the 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 Every mode is Skia - the port renders through Skia on all targets. The axis
* │ created, ComposeWindow falls back to Software. here is only how Skia's output reaches the screen:
* ├─ Software - Skia CPU raster: paints a host pixel buffer that SDL_Renderer
* │ uploads as a texture each frame. No GPU context needed. Auto resolve per platform at window creation (Metal on macOS, OpenGL
* └─ Skia.* - Skia GPU bridges on Linux and Windows). If the GPU context can't be created, the
* ├─ OpenGL - Skia GL backend on an SDL OpenGL (WGL/GLX) context window falls back to CpuRaster rather than failing to open.
* └─ Metal - Skia Metal on a CAMetalLayer via SDL_Metal_CreateView (macOS) Metal Skia's Metal backend on a CAMetalLayer (SDL_Metal_CreateView), macOS.
* OpenGL Skia's GL backend on an SDL OpenGL (WGL/GLX) context.
* AUTO resolves via rendererPreferredGpuMode() per target, at composeWindow entry. */ 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 { 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" } object Auto : GpuMode() { override fun toString() = "Auto" }
/** Skia CPU raster - a host pixel buffer uploaded via SDL_Renderer each frame. */ /** Skia Metal on a CAMetalLayer via SDL_Metal_CreateView (macOS only). */
object Software : GpuMode() { override fun toString() = "Software" } object Metal : GpuMode() { override fun toString() = "Metal" }
/** Skia GPU bridges. */ /** Skia GL on an SDL OpenGL context (WGL on Windows, GLX on Linux). */
sealed class Skia : GpuMode() { object OpenGL : GpuMode() { override fun toString() = "OpenGL" }
object OpenGL : Skia() { override fun toString() = "Skia.OpenGL" }
object Metal : Skia() { override fun toString() = "Skia.Metal" } /** 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 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 requires the main thread, so a small background scope launches them and
(optionally) posts the result back to Main. */ (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 /** 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 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. */ thread with SDL_OpenURL's success flag. */
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
fun openUrl(inUrl: String, inOnResult: ((Boolean) -> Unit)? = null) { fun openUrl(inUrl: String, inOnResult: ((Boolean) -> Unit)? = null) {
fOpenExternalScope.launch { mOpenExternalScope.launch {
val vOk = SDL_OpenURL(inUrl) val vOk = SDL_OpenURL(inUrl)
if (inOnResult != null) withContext(Dispatchers.Main) { inOnResult(vOk) } 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 + (official Skiko on macOS/Linux, the fork on mingwX64); text measurement +
drawing go through the skiko paragraph engine (androidx.compose.ui.text drawing go through the skiko paragraph engine (androidx.compose.ui.text
Paragraph actuals), so there is no renderer-owned measurer here. */ 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 { interface RenderBackend {
/** Loader the shared ImageMeasurePolicy / Res.readBytes plug into. Backed /** Loader the shared ImageMeasurePolicy / Res.readBytes plug into. Backed
by the same decode cache the renderer uses to paint images, so a 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 DrawModifierNode pipeline. Taking a (Canvas)->Unit instead of the host keeps
RenderBackend + its implementations independent of the node/host layer - the RenderBackend + its implementations independent of the node/host layer - the
decoupling that lets the renderers live in :ui-graphics (no ui-graphics→ui decoupling that lets the renderers live in :ui-graphics (no ui-graphics→ui
cycle). Default no-op. */ cycle).
fun drawRoot(inDraw: (canvas: androidx.compose.ui.graphics.Canvas) -> Unit) {}
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. */ /** Flush + present whatever was just drawn. */
fun endFrame() fun endFrame()
@@ -16,7 +16,7 @@ package com.compose.sdl
/** Create the render backend for the selected [GpuMode]; null when the /** Create the render backend for the selected [GpuMode]; null when the
backend can't initialise (caller falls back / reports). */ backend can't initialise (caller falls back / reports). */
expect fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? expect fun createRenderBackend(inSdl: Sdl3Backend, inGpu: GpuMode): RenderBackend?
/** The renderer module's per-OS default GpuMode (used for GpuMode.Auto). */ /** The renderer module's per-OS default GpuMode (used for GpuMode.Auto). */
expect fun rendererPreferredGpuMode(): GpuMode expect fun rendererPreferredGpuMode(): GpuMode
@@ -43,7 +43,7 @@ import sdl3.SDL_GetBasePath
// no zlib/gzip wrapper). ZIP64 is not supported - the resource set is // 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. // 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 // One entry's location + sizing + compression method from the central
// directory. method 0 = stored, 8 = deflated. // directory. method 0 = stored, 8 = deflated.
@@ -53,18 +53,18 @@ private class ComposeResourceArchive(private val fHandle: FileHandle) {
val uncompressedSize: Long, val uncompressedSize: Long,
val method: Int, val method: Int,
) )
private val fEntries: Map<String, Entry> private val mEntries: Map<String, Entry>
init { 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 /** Reads an entry's bytes (inflated if needed) or null if the entry is
missing / uses an unsupported compression method. */ missing / uses an unsupported compression method. */
fun readBytes(inPath: String): ByteArray? { fun readBytes(inPath: String): ByteArray? {
val vEntry = fEntries[inPath] ?: return null val vEntry = mEntries[inPath] ?: return null
if (vEntry.uncompressedSize <= 0L) return ByteArray(0) if (vEntry.uncompressedSize <= 0L) return ByteArray(0)
// Local file header layout: 30 fixed bytes, then filename_len + extra_len. // 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 // 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 // bytes of comment trailing. Read the last 64 KiB + 22 and scan backward
// for the EOCD signature. // for the EOCD signature.
val vFileLen: Long = fHandle.size() val vFileLen: Long = mHandle.size()
if (vFileLen < 22L) return emptyMap() if (vFileLen < 22L) return emptyMap()
val vTailLen = minOf(vFileLen, 65557L).toInt() val vTailLen = minOf(vFileLen, 65557L).toInt()
val vTail = ByteArray(vTailLen) val vTail = ByteArray(vTailLen)
@@ -182,14 +182,14 @@ private class ComposeResourceArchive(private val fHandle: FileHandle) {
if (outBuf.isEmpty()) return true if (outBuf.isEmpty()) return true
var vRead = 0 var vRead = 0
while (vRead < outBuf.size) { 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 if (vN <= 0) return false
vRead += vN vRead += vN
} }
return true return true
} }
fun close() = fHandle.close() fun close() = mHandle.close()
private fun le16(inBuf: ByteArray, inOff: Int): Int = private fun le16(inBuf: ByteArray, inOff: Int): Int =
(inBuf[inOff].toInt() and 0xFF) or (inBuf[inOff].toInt() and 0xFF) or
@@ -4,23 +4,32 @@ import kotlinx.cinterop.*
import sdl3.* import sdl3.*
// ================== // ==================
// MARK: SDL3Backend // MARK: Sdl3Backend
// ================== // ==================
class SDL3Backend( class Sdl3Backend(
private val title: String = "ComposeNativeSDL3", private val title: String = "ComposeNativeSDL3",
private val width: Int = 800, private val width: Int = 800,
private val height: Int = 600, 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. // 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 first-listed size is irrelevant - the largest becomes the base and
// the rest become alternate resolutions. Dark set falls back to light. // the rest become alternate resolutions. Dark set falls back to light.
private val iconLightResourcePaths: List<String> = emptyList(), private val iconLightResourcePaths: List<String> = emptyList(),
private val iconDarkResourcePaths: 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 { init {
require(gpuMode !is GpuMode.Auto) { require(gpuMode !is GpuMode.Auto) {
"SDL3Backend received GpuMode.Auto - resolve via preferredGpuMode() first" "Sdl3Backend received GpuMode.Auto - resolve via preferredGpuMode() first"
} }
} }
@@ -66,7 +75,7 @@ class SDL3Backend(
com.compose.sdl.res.preferredLocaleProvider = ::sdlPreferredLocale com.compose.sdl.res.preferredLocaleProvider = ::sdlPreferredLocale
com.compose.sdl.res.preferredLocaleTagsProvider = { com.compose.sdl.text.systemPreferredLocaleTags() } 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_MAJOR_VERSION, 3)
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_MINOR_VERSION, 2) SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_MINOR_VERSION, 2)
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_PROFILE_MASK, 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) SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_STENCIL_SIZE, 8)
} }
val flags = SDL_WINDOW_RESIZABLE or SDL_WINDOW_HIGH_PIXEL_DENSITY or when (gpuMode) { var attrFlags = SDL_WINDOW_HIGH_PIXEL_DENSITY
is GpuMode.Skia.OpenGL -> SDL_WINDOW_OPENGL if (resizable) attrFlags = attrFlags or SDL_WINDOW_RESIZABLE
is GpuMode.Skia.Metal -> SDL_WINDOW_METAL if (undecorated) attrFlags = attrFlags or SDL_WINDOW_BORDERLESS
is GpuMode.Software -> 0UL 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") is GpuMode.Auto -> error("unreachable")
} }
window = SDL_CreateWindow(title, width, height, flags) window = SDL_CreateWindow(title, width, height, flags)
@@ -88,7 +103,7 @@ class SDL3Backend(
} }
when (gpuMode) { when (gpuMode) {
is GpuMode.Skia.OpenGL -> { is GpuMode.OpenGL -> {
glContext = SDL_GL_CreateContext(window?.reinterpret()) glContext = SDL_GL_CreateContext(window?.reinterpret())
if (glContext == null) { if (glContext == null) {
println("SDL_GL_CreateContext failed: ${SDL_GetError()?.toKString()}") println("SDL_GL_CreateContext failed: ${SDL_GetError()?.toKString()}")
@@ -101,7 +116,7 @@ class SDL3Backend(
// keeps its fallback frame cap. // keeps its fallback frame cap.
vsyncEnabled = SDL_GL_SetSwapInterval(1) vsyncEnabled = SDL_GL_SetSwapInterval(1)
} }
is GpuMode.Skia.Metal -> { is GpuMode.Metal -> {
metalView = SDL_Metal_CreateView(window?.reinterpret()) metalView = SDL_Metal_CreateView(window?.reinterpret())
if (metalView == null) { if (metalView == null) {
println("SDL_Metal_CreateView failed: ${SDL_GetError()?.toKString()}") 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. // blocks - so Metal is vsync-paced and the loop skips its delay.
vsyncEnabled = true vsyncEnabled = true
} }
is GpuMode.Software -> { is GpuMode.CpuRaster -> {
// Skia CPU raster: SkiaSurfaceBridge paints a host buffer that // Skia CPU raster: SkiaSurfaceBridge paints a host buffer that
// this SDL_Renderer uploads as a texture each frame. // this SDL_Renderer uploads as a texture each frame.
renderer = SDL_CreateRenderer(window?.reinterpret(), null) renderer = SDL_CreateRenderer(window?.reinterpret(), null)
@@ -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"). // the root keeps LayoutNode's ErrorMeasurePolicy ("Undefined measure").
measurePolicy = androidx.compose.ui.layout.RootMeasurePolicy measurePolicy = androidx.compose.ui.layout.RootMeasurePolicy
} }
private val fOwner = ComposeOwner(rootNode, Density(inDensity), LayoutDirection.Ltr) private val mOwner = ComposeOwner(rootNode, Density(inDensity), LayoutDirection.Ltr)
private val fApplier = NodeApplier(rootNode) private val mApplier = NodeApplier(rootNode)
// Upcast to the public supertype so the internal NodeApplier / LayoutNode // Upcast to the public supertype so the internal NodeApplier / LayoutNode
// don't leak across the module boundary; Composition accepts Applier<*>. // don't leak across the module boundary; Composition accepts Applier<*>.
val applier: Applier<*> get() = fApplier val applier: Applier<*> get() = mApplier
fun attach() { fun attach() {
// Install the focus root + drag-and-drop root on the root LayoutNode // 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 // focus root the focus tree has no origin and requestFocus never
// sticks; without the DnD root the root DragAndDropNode never receives // sticks; without the DnD root the root DragAndDropNode never receives
// events from Sdl3DragAndDropOwner's dispatch calls. // events from Sdl3DragAndDropOwner's dispatch calls.
rootNode.modifier = fOwner.focusOwner.modifier.then(fOwner.dragAndDropManager.modifier) rootNode.modifier = mOwner.focusOwner.modifier.then(mOwner.dragAndDropManager.modifier)
fOwner.attach() mOwner.attach()
// Turn on snapshot observation so writes to `mutableStateOf` (ScrollState.value, // Turn on snapshot observation so writes to `mutableStateOf` (ScrollState.value,
// LazyList firstVisibleItemIndex, etc.) fire the appropriate invalidation // LazyList firstVisibleItemIndex, etc.) fire the appropriate invalidation
// callbacks - requestRelayout / requestRemeasure - routing through the vendored // 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 // state but the placement lambda inside ScrollNode.measure never re-runs, so
// the child layer's move(IntOffset(0, -scroll)) never happens → scroll offset // the child layer's move(IntOffset(0, -scroll)) never happens → scroll offset
// is invisible even though the state is updating. // is invisible even though the state is updating.
fOwner.snapshotObserver.startObserving() mOwner.snapshotObserver.startObserving()
} }
fun setConstraints(inWidth: Int, inHeight: Int) { 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 // Pump the owner's node-animation frame clock (scroll fling / animateScrollToItem / node
// Animatables). Called once per frame by ComposeWindow with a monotonic nanos timestamp. // Animatables). Called once per frame by ComposeWindow with a monotonic nanos timestamp.
fun sendAnimationFrame(inNanos: Long) { 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 // 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 // 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). // 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() { fun measureAndLayout() {
// Flush deferred end-of-apply work (focus invalidation etc.) before measuring, so // Flush deferred end-of-apply work (focus invalidation etc.) before measuring, so
// requestFocus() from composition OR from a pointer event this frame takes effect - // 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. // e.g. focus-on-click, which schedules a focus invalidation during event dispatch.
fOwner.onEndApplyChanges() mOwner.onEndApplyChanges()
fOwner.measureAndLayout() mOwner.measureAndLayout()
// Drop snapshot observations whose scope is no longer valid (detached nodes, // Drop snapshot observations whose scope is no longer valid (detached nodes,
// destroyed layers, disposed draw scopes). Upstream RootNodeOwner does this after // destroyed layers, disposed draw scopes). Upstream RootNodeOwner does this after
// the measure pass; without it, every disposed subtree's measure/layout/DRAW // the measure pass; without it, every disposed subtree's measure/layout/DRAW
// observation scopes linger in the OwnerSnapshotObserver forever - each pins its // observation scopes linger in the OwnerSnapshotObserver forever - each pins its
// observed object graph (a leak the P2.2 soak caught on ripple/indication draws). // 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 // Draw the composed tree into [canvas] - re-records dirty layers, then walks
// the root so clean layers replay their cached content. Backends call this // the root so clean layers replay their cached content. Backends call this
// instead of rootNode.draw so retained-layer bookkeeping runs each frame. // instead of rootNode.draw so retained-layer bookkeeping runs each frame.
fun drawRoot(canvas: androidx.compose.ui.graphics.Canvas) { 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 // The window wires this to set needsFrame - a layer whose content changed
// (OwnedLayer.invalidate) schedules a frame even with nothing else pending. // (OwnedLayer.invalidate) schedules a frame even with nothing else pending.
fun setInvalidationCallback(callback: () -> Unit) { fun setInvalidationCallback(callback: () -> Unit) {
fOwner.onInvalidate = callback mOwner.onInvalidate = callback
} }
// The owner's FocusOwner IS a FocusManager - the window provides it as LocalFocusManager. // 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 // 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 // Provider; ComposeWindow wraps setContent in a CompositionLocalProvider whose
// values come from the ComposeOwner attached below. These accessors expose the // values come from the ComposeOwner attached below. These accessors expose the
// right fields without leaking the `internal Owner` interface across modules. // right fields without leaking the `internal Owner` interface across modules.
val density: androidx.compose.ui.unit.Density get() = fOwner.density val density: androidx.compose.ui.unit.Density get() = mOwner.density
val layoutDirection: androidx.compose.ui.unit.LayoutDirection get() = fOwner.layoutDirection val layoutDirection: androidx.compose.ui.unit.LayoutDirection get() = mOwner.layoutDirection
val viewConfiguration: androidx.compose.ui.platform.ViewConfiguration get() = fOwner.viewConfiguration val viewConfiguration: androidx.compose.ui.platform.ViewConfiguration get() = mOwner.viewConfiguration
val graphicsContext: androidx.compose.ui.graphics.GraphicsContext get() = fOwner.graphicsContext val graphicsContext: androidx.compose.ui.graphics.GraphicsContext get() = mOwner.graphicsContext
val inputModeManager: androidx.compose.ui.input.InputModeManager get() = fOwner.inputModeManager val inputModeManager: androidx.compose.ui.input.InputModeManager get() = mOwner.inputModeManager
val hapticFeedback: androidx.compose.ui.hapticfeedback.HapticFeedback get() = fOwner.hapticFeedBack val hapticFeedback: androidx.compose.ui.hapticfeedback.HapticFeedback get() = mOwner.hapticFeedBack
val textToolbar: androidx.compose.ui.platform.TextToolbar get() = fOwner.textToolbar val textToolbar: androidx.compose.ui.platform.TextToolbar get() = mOwner.textToolbar
val windowInfo: androidx.compose.ui.platform.WindowInfo get() = fOwner.windowInfo val windowInfo: androidx.compose.ui.platform.WindowInfo get() = mOwner.windowInfo
// Push SDL window focus into windowInfo.isWindowFocused (focus-reactive UI). // 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 val softwareKeyboardController: androidx.compose.ui.platform.SoftwareKeyboardController
get() = fOwner.softwareKeyboardController get() = mOwner.softwareKeyboardController
// PointerIconService and its composition local are :ui-internal, so ComposeWindow // PointerIconService and its composition local are :ui-internal, so ComposeWindow
// can't provide the local directly. This wrapper seeds it from the owner so // 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 @androidx.compose.runtime.Composable
fun ProvidePointerIconService(content: @androidx.compose.runtime.Composable () -> Unit) { fun ProvidePointerIconService(content: @androidx.compose.runtime.Composable () -> Unit) {
androidx.compose.runtime.CompositionLocalProvider( androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.ui.platform.LocalPointerIconService provides fOwner.pointerIconService, androidx.compose.ui.platform.LocalPointerIconService provides mOwner.pointerIconService,
content = content, content = content,
) )
} }
@@ -140,7 +140,7 @@ class ComposeRootHost(inDensity: Float = 1f) {
onKeyEvent) - drives text-field editing keys (backspace / arrows / enter) and onKeyEvent) - drives text-field editing keys (backspace / arrows / enter) and
clickable Enter/Space activation. Returns true if some node consumed it. */ clickable Enter/Space activation. Returns true if some node consumed it. */
fun dispatchKeyEvent(inEvent: androidx.compose.ui.input.key.KeyEvent): Boolean = 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 // 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 // `expect PointerInputEvent` has no commonMain constructor, so the actual build+dispatch
// lives in nativeMain (feedPointerToProcessor). // lives in nativeMain (feedPointerToProcessor).
fun onPointerRaw(inX: Float, inY: Float, inType: Int, inButton: Int, inUptime: Long) { 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 // Mouse wheel - feed a scroll PointerInputEvent to the processor so the vendored
// Modifier.scrollable (MouseWheelScrollingLogic) handles it, exactly like upstream. // Modifier.scrollable (MouseWheelScrollingLogic) handles it, exactly like upstream.
fun onWheel(inX: Float, inY: Float, inDeltaX: Float, inDeltaY: Float, inUptime: Long) { 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 // COMPLETE to these methods; Sdl3DragAndDropOwner accumulates the drop
// session and dispatches through the root DragAndDropNode on COMPLETE. // session and dispatches through the root DragAndDropNode on COMPLETE.
fun onDropBegin() = fOwner.dragAndDropManager.dropBegin() fun onDropBegin() = mOwner.dragAndDropManager.dropBegin()
fun onDropPosition(inX: Float, inY: Float) = fOwner.dragAndDropManager.dropPosition(inX, inY) fun onDropPosition(inX: Float, inY: Float) = mOwner.dragAndDropManager.dropPosition(inX, inY)
fun onDropFile(inPath: String) = fOwner.dragAndDropManager.dropFile(inPath) fun onDropFile(inPath: String) = mOwner.dragAndDropManager.dropFile(inPath)
fun onDropText(inText: String) = fOwner.dragAndDropManager.dropText(inText) fun onDropText(inText: String) = mOwner.dragAndDropManager.dropText(inText)
fun onDropComplete() = fOwner.dragAndDropManager.dropComplete() fun onDropComplete() = mOwner.dragAndDropManager.dropComplete()
} }
// NOTE: feedPointerToProcessor / feedScrollToProcessor are plain nativeMain // 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 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 - 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. */ not just the changed button. A single mouse => module-level flags are enough. */
private var fPrimaryDown = false private var mPrimaryDown = false
private var fSecondaryDown = false private var mSecondaryDown = false
private var fTertiaryDown = false private var mTertiaryDown = false
/** Builds the internal PointerInputEvent (its constructor is native-only) from a single mouse /** 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; 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, inY: Float,
) { ) {
when (inType) { when (inType) {
1 -> when (inButton) { 0 -> fPrimaryDown = true; 1 -> fSecondaryDown = true; 2 -> fTertiaryDown = true } 1 -> when (inButton) { 0 -> mPrimaryDown = true; 1 -> mSecondaryDown = true; 2 -> mTertiaryDown = true }
2 -> when (inButton) { 0 -> fPrimaryDown = false; 1 -> fSecondaryDown = false; 2 -> fTertiaryDown = false } 2 -> when (inButton) { 0 -> mPrimaryDown = false; 1 -> mSecondaryDown = false; 2 -> mTertiaryDown = false }
} }
val vButtons = PointerButtons( val vButtons = PointerButtons(
isPrimaryPressed = fPrimaryDown, isPrimaryPressed = mPrimaryDown,
isSecondaryPressed = fSecondaryDown, isSecondaryPressed = mSecondaryDown,
isTertiaryPressed = fTertiaryDown, isTertiaryPressed = mTertiaryDown,
) )
val vButton = if (inType == 1 || inType == 2) { val vButton = if (inType == 1 || inType == 2) {
when (inButton) { when (inButton) {
@@ -57,7 +57,7 @@ internal fun feedPointerToProcessor(
uptime = inUptime, uptime = inUptime,
positionOnScreen = vPos, positionOnScreen = vPos,
position = vPos, position = vPos,
down = fPrimaryDown || fSecondaryDown || fTertiaryDown, down = mPrimaryDown || mSecondaryDown || mTertiaryDown,
pressure = 1f, pressure = 1f,
type = PointerType.Mouse, type = PointerType.Mouse,
activeHover = inType == 0, // Exit (3) also reports no active hover activeHover = inType == 0, // Exit (3) also reports no active hover
@@ -80,28 +80,28 @@ internal class ComposeOwner(
) : Owner, OwnedLayerManager { ) : Owner, OwnedLayerManager {
// The vendored measure/layout state machine, rooted at [root]. // 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 // The vendored pointer-input dispatcher. Routes a PointerInputEvent through
// HitPathTracker to the tree's PointerInputModifierNodes (hover / gesture), // HitPathTracker to the tree's PointerInputModifierNodes (hover / gesture),
// synthesizing Enter/Exit. Fed from ComposeWindow via ComposeRootHost. // 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 // Window focus + container size backing [windowInfo], snapshot-backed so
// focus-reactive UI and LocalWindowInfo.containerSize readers recompose. Fed by // focus-reactive UI and LocalWindowInfo.containerSize readers recompose. Fed by
// the window: focus via setWindowFocused (SDL activation), size from setRootConstraints. // the window: focus via setWindowFocused (SDL activation), size from setRootConstraints.
private val fWindowFocused = androidx.compose.runtime.mutableStateOf(true) private val mWindowFocused = androidx.compose.runtime.mutableStateOf(true)
private val fContainerSize = androidx.compose.runtime.mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) private val mContainerSize = androidx.compose.runtime.mutableStateOf(androidx.compose.ui.unit.IntSize.Zero)
// Called by the window on SDL focus gain/loss. // 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. // Dispatch one pointer event to the upstream PointerInputModifierNode tree.
// [this] is the PositionCalculator (Owner : PositionCalculator). // [this] is the PositionCalculator (Owner : PositionCalculator).
internal fun processPointerInput(inEvent: androidx.compose.ui.input.pointer.PointerInputEvent): Boolean { 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). // 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 return vResult.dispatchedToAPointerInputModifier
} }
@@ -115,11 +115,11 @@ internal class ComposeOwner(
// Sets the constraints the root is measured against (window pixel size in // Sets the constraints the root is measured against (window pixel size in
// logical points). Call each frame before [measureAndLayout]. // logical points). Call each frame before [measureAndLayout].
fun setRootConstraints(inConstraints: Constraints) { fun setRootConstraints(inConstraints: Constraints) {
fDelegate.updateRootConstraints(inConstraints) mDelegate.updateRootConstraints(inConstraints)
// Report the container size (physical px, the port's layout coord space) to // 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. // windowInfo; updates on every resize since the window re-measures per frame.
if (inConstraints.hasBoundedWidth && inConstraints.hasBoundedHeight) { if (inConstraints.hasBoundedWidth && inConstraints.hasBoundedHeight) {
fContainerSize.value = mContainerSize.value =
androidx.compose.ui.unit.IntSize(inConstraints.maxWidth, inConstraints.maxHeight) androidx.compose.ui.unit.IntSize(inConstraints.maxWidth, inConstraints.maxHeight)
} }
} }
@@ -138,8 +138,8 @@ internal class ComposeOwner(
forceRequest: Boolean, forceRequest: Boolean,
scheduleMeasureAndLayout: Boolean, scheduleMeasureAndLayout: Boolean,
) { ) {
if (affectsLookahead) fDelegate.requestLookaheadRemeasure(layoutNode, forceRequest) if (affectsLookahead) mDelegate.requestLookaheadRemeasure(layoutNode, forceRequest)
else fDelegate.requestRemeasure(layoutNode, forceRequest) else mDelegate.requestRemeasure(layoutNode, forceRequest)
} }
override fun onRequestRelayout( override fun onRequestRelayout(
@@ -147,25 +147,25 @@ internal class ComposeOwner(
affectsLookahead: Boolean, affectsLookahead: Boolean,
forceRequest: Boolean, forceRequest: Boolean,
) { ) {
if (affectsLookahead) fDelegate.requestLookaheadRelayout(layoutNode, forceRequest) if (affectsLookahead) mDelegate.requestLookaheadRelayout(layoutNode, forceRequest)
else fDelegate.requestRelayout(layoutNode, forceRequest) else mDelegate.requestRelayout(layoutNode, forceRequest)
} }
override fun measureAndLayout(sendPointerUpdate: Boolean) { override fun measureAndLayout(sendPointerUpdate: Boolean) {
fDelegate.measureAndLayout() mDelegate.measureAndLayout()
fDelegate.dispatchOnPositionedCallbacks() mDelegate.dispatchOnPositionedCallbacks()
} }
override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) { override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) {
fDelegate.measureAndLayout(layoutNode, constraints) mDelegate.measureAndLayout(layoutNode, constraints)
} }
override fun forceMeasureTheSubtree(layoutNode: LayoutNode, affectsLookahead: Boolean) { override fun forceMeasureTheSubtree(layoutNode: LayoutNode, affectsLookahead: Boolean) {
fDelegate.forceMeasureTheSubtree(layoutNode, affectsLookahead) mDelegate.forceMeasureTheSubtree(layoutNode, affectsLookahead)
} }
override fun onDetach(node: LayoutNode) { override fun onDetach(node: LayoutNode) {
fDelegate.onNodeDetached(node) mDelegate.onNodeDetached(node)
// Drop the detached node's read-observation scopes, exactly as upstream // Drop the detached node's read-observation scopes, exactly as upstream
// RootNodeOwner.onDetach does. Without this, every disposed node's measure/ // RootNodeOwner.onDetach does. Without this, every disposed node's measure/
// layout/draw observation scopes accumulate in the snapshot observer forever // layout/draw observation scopes accumulate in the snapshot observer forever
@@ -174,7 +174,7 @@ internal class ComposeOwner(
} }
override fun registerOnLayoutCompletedListener(listener: Owner.OnLayoutCompletedListener) { override fun registerOnLayoutCompletedListener(listener: Owner.OnLayoutCompletedListener) {
fDelegate.registerOnLayoutCompletedListener(listener) mDelegate.registerOnLayoutCompletedListener(listener)
} }
override fun requestOnPositionedCallback(layoutNode: LayoutNode) = Unit override fun requestOnPositionedCallback(layoutNode: LayoutNode) = Unit
@@ -345,12 +345,12 @@ internal class ComposeOwner(
} }
// Desired hover cursor, set by the vendored HoverIconModifierNode // Desired hover cursor, set by the vendored HoverIconModifierNode
// (Modifier.pointerHoverIcon) and applied via SDL in processPointerInput. null = default. // (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 androidx.compose.ui.input.pointer.PointerIcon.Default
override val pointerIconService: PointerIconService = object : PointerIconService { 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?) { 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 getStylusHoverIcon(): androidx.compose.ui.input.pointer.PointerIcon? = null
override fun setStylusHoverIcon(value: androidx.compose.ui.input.pointer.PointerIcon?) {} override fun setStylusHoverIcon(value: androidx.compose.ui.input.pointer.PointerIcon?) {}
@@ -368,13 +368,13 @@ internal class ComposeOwner(
owner = this, owner = this,
) )
override val windowInfo: WindowInfo = object : WindowInfo { override val windowInfo: WindowInfo = object : WindowInfo {
override val isWindowFocused: Boolean get() = fWindowFocused.value override val isWindowFocused: Boolean get() = mWindowFocused.value
override val containerSize: androidx.compose.ui.unit.IntSize get() = fContainerSize.value override val containerSize: androidx.compose.ui.unit.IntSize get() = mContainerSize.value
override val containerDpSize: androidx.compose.ui.unit.DpSize override val containerDpSize: androidx.compose.ui.unit.DpSize
get() = with(density) { get() = with(density) {
androidx.compose.ui.unit.DpSize( androidx.compose.ui.unit.DpSize(
fContainerSize.value.width.toDp(), mContainerSize.value.width.toDp(),
fContainerSize.value.height.toDp(), mContainerSize.value.height.toDp(),
) )
} }
} }
@@ -412,16 +412,16 @@ internal class ComposeOwner(
// End-of-apply-changes listeners - the focus system (FocusInvalidationManager) and other // 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 // 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. // 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) { override fun registerOnEndApplyChangesListener(listener: () -> Unit) {
if (listener !in fEndApplyChangesListeners) fEndApplyChangesListeners.add(listener) if (listener !in mEndApplyChangesListeners) mEndApplyChangesListeners.add(listener)
} }
override fun onEndApplyChanges() { override fun onEndApplyChanges() {
// Drain in order; listeners may re-register, so loop until empty. // Drain in order; listeners may re-register, so loop until empty.
while (fEndApplyChangesListeners.isNotEmpty()) { while (mEndApplyChangesListeners.isNotEmpty()) {
val vListener = fEndApplyChangesListeners.removeAt(0) val vListener = mEndApplyChangesListeners.removeAt(0)
vListener() vListener()
} }
} }
@@ -17,10 +17,10 @@ import com.compose.sdl.loadComposeResourceBytes
* *
* Idempotent; called from ComposeWindow.installGlobals (data.kres is loadable by then). * Idempotent; called from ComposeWindow.installGlobals (data.kres is loadable by then).
*/ */
private var fRegistered = false private var mRegistered = false
fun registerGenericFonts() { fun registerGenericFonts() {
if (fRegistered) return if (mRegistered) return
fRegistered = true mRegistered = true
loadComposeResourceBytes("font/NotoSansMono.ttf")?.let { IconFont.register("generic:monospace", it) } loadComposeResourceBytes("font/NotoSansMono.ttf")?.let { IconFont.register("generic:monospace", it) }
} }
@@ -6,4 +6,4 @@ import com.compose.sdl.renderer.skia.SkiaBridge
// MARK: Linux GPU defaults // MARK: Linux GPU defaults
// ================== // ==================
internal actual fun makeMetalBridge(backend: SDL3Backend): SkiaBridge? = null internal actual fun makeMetalBridge(backend: Sdl3Backend): SkiaBridge? = null
@@ -3,7 +3,7 @@ package com.compose.sdl
import com.compose.sdl.renderer.skia.SkiaBridge import com.compose.sdl.renderer.skia.SkiaBridge
import com.compose.sdl.renderer.skia.SkiaMetalBridge import com.compose.sdl.renderer.skia.SkiaMetalBridge
internal actual fun makeMetalBridge(backend: SDL3Backend): SkiaBridge? { internal actual fun makeMetalBridge(backend: Sdl3Backend): SkiaBridge? {
val bridge = SkiaMetalBridge(backend) val bridge = SkiaMetalBridge(backend)
return if (bridge.init()) bridge else null return if (bridge.init()) bridge else null
} }
@@ -37,16 +37,16 @@ import sdl3.SDL_Metal_GetLayer
per frame, so we tear the BackendRenderTarget + Surface down every frame per frame, so we tear the BackendRenderTarget + Surface down every frame
(cheap - the DirectContext + device + queue persist). */ (cheap - the DirectContext + device + queue persist). */
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge { internal class SkiaMetalBridge(private val backend: Sdl3Backend) : SkiaBridge {
private var fDevice: MTLDeviceProtocol? = null private var mDevice: MTLDeviceProtocol? = null
private var fQueue: MTLCommandQueueProtocol? = null private var mQueue: MTLCommandQueueProtocol? = null
private var fLayer: CAMetalLayer? = null private var mLayer: CAMetalLayer? = null
private var fContext: DirectContext? = null private var mContext: DirectContext? = null
private var fSurface: Surface? = null private var mSurface: Surface? = null
private var fRT: BackendRenderTarget? = null private var mRT: BackendRenderTarget? = null
private var fDrawable: CAMetalDrawableProtocol? = null private var mDrawable: CAMetalDrawableProtocol? = null
private var fWidth = 0 private var mWidth = 0
private var fHeight = 0 private var mHeight = 0
fun init(): Boolean { fun init(): Boolean {
val vView = backend.metalView ?: run { val vView = backend.metalView ?: run {
@@ -84,22 +84,22 @@ internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
return false return false
} }
fDevice = vDevice mDevice = vDevice
fQueue = vQueue mQueue = vQueue
fLayer = vLayer mLayer = vLayer
fContext = vContext mContext = vContext
return true return true
} }
override val canvas: Canvas 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 { override fun ensureSize(inWidth: Int, inHeight: Int): Boolean {
if (inWidth <= 0 || inHeight <= 0) return false if (inWidth <= 0 || inHeight <= 0) return false
val vLayer = fLayer ?: return false val vLayer = mLayer ?: return false
val vContext = fContext ?: return false val vContext = mContext ?: return false
if (inWidth != fWidth || inHeight != fHeight) { if (inWidth != mWidth || inHeight != mHeight) {
vLayer.drawableSize = CGSizeMake(inWidth.toDouble(), inHeight.toDouble()) vLayer.drawableSize = CGSizeMake(inWidth.toDouble(), inHeight.toDouble())
// Keep contentsScale in lock-step with the live backing density. // Keep contentsScale in lock-step with the live backing density.
// Setting it only in init() can leave it stale at 1.0 when the // 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 // drawable then gets downscaled to the layer's logical backing and
// re-upscaled by the display, softening text and aliasing edges. // re-upscaled by the display, softening text and aliasing edges.
vLayer.contentsScale = backend.pixelDensity.toDouble() vLayer.contentsScale = backend.pixelDensity.toDouble()
fWidth = inWidth mWidth = inWidth
fHeight = inHeight mHeight = inHeight
println("SkiaMetalBridge: drawable ${inWidth}x${inHeight} @ contentsScale ${backend.pixelDensity}") println("SkiaMetalBridge: drawable ${inWidth}x${inHeight} @ contentsScale ${backend.pixelDensity}")
} }
// Per-frame: drop the previous frame's drawable wrappers and grab a // Per-frame: drop the previous frame's drawable wrappers and grab a
// fresh one. nextDrawable() may block (~16ms vsync) when 3 drawables // fresh one. nextDrawable() may block (~16ms vsync) when 3 drawables
// are already in flight - that's the natural pacing mechanism. // are already in flight - that's the natural pacing mechanism.
fSurface?.close() mSurface?.close()
fRT?.close() mRT?.close()
fSurface = null mSurface = null
fRT = null mRT = null
val vDrawable = vLayer.nextDrawable() ?: run { val vDrawable = vLayer.nextDrawable() ?: run {
println("SkiaMetalBridge: nextDrawable returned null") println("SkiaMetalBridge: nextDrawable returned null")
@@ -142,38 +142,38 @@ internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
println("SkiaMetalBridge: Surface.makeFromBackendRenderTarget returned null") println("SkiaMetalBridge: Surface.makeFromBackendRenderTarget returned null")
return false return false
} }
fRT = vRT mRT = vRT
fSurface = vSurface mSurface = vSurface
fDrawable = vDrawable mDrawable = vDrawable
return true return true
} }
override fun present() { override fun present() {
val vSurface = fSurface ?: return val vSurface = mSurface ?: return
val vQueue = fQueue ?: return val vQueue = mQueue ?: return
val vDrawable = fDrawable ?: return val vDrawable = mDrawable ?: return
vSurface.flushAndSubmit() vSurface.flushAndSubmit()
val vCommandBuffer = vQueue.commandBuffer() ?: return val vCommandBuffer = vQueue.commandBuffer() ?: return
vCommandBuffer.presentDrawable(vDrawable) vCommandBuffer.presentDrawable(vDrawable)
vCommandBuffer.commit() 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>? = override fun snapshotBgra(): Triple<Int, Int, ByteArray>? =
readBackBgra(fSurface, fWidth, fHeight) readBackBgra(mSurface, mWidth, mHeight)
override fun destroy() { override fun destroy() {
fSurface?.close() mSurface?.close()
fRT?.close() mRT?.close()
fContext?.close() mContext?.close()
fSurface = null mSurface = null
fRT = null mRT = null
fContext = null mContext = null
fLayer = null mLayer = null
fQueue = null mQueue = null
fDevice = null mDevice = null
fDrawable = null mDrawable = null
} }
} }
@@ -7,17 +7,17 @@ import kotlin.experimental.ExperimentalNativeApi
// MARK: Metal bridge factory (skia-only) // MARK: Metal bridge factory (skia-only)
// ================== // ==================
internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge? internal expect fun makeMetalBridge(backend: Sdl3Backend): SkiaBridge?
actual fun rendererPreferredGpuMode(): GpuMode { actual fun rendererPreferredGpuMode(): GpuMode {
@OptIn(ExperimentalNativeApi::class) @OptIn(ExperimentalNativeApi::class)
return when (Platform.osFamily) { return when (Platform.osFamily) {
OsFamily.MACOSX -> GpuMode.Skia.Metal OsFamily.MACOSX -> GpuMode.Metal
OsFamily.LINUX -> GpuMode.Skia.OpenGL OsFamily.LINUX -> GpuMode.OpenGL
// Windows (mingwX64, Route 1a): GPU via Skia's GL backend on an SDL WGL // Windows (mingwX64, Route 1a): GPU via Skia's GL backend on an SDL WGL
// context (SkiaGLBridge, shared with Linux). CPU raster stays available // context (SkiaGLBridge, shared with Linux). CPU raster stays available
// as --gpu=software. (A native D3D12 bridge is the milestone-2 upgrade.) // as --gpu=software. (A native D3D12 bridge is the milestone-2 upgrade.)
OsFamily.WINDOWS -> GpuMode.Skia.OpenGL OsFamily.WINDOWS -> GpuMode.OpenGL
else -> GpuMode.Software else -> GpuMode.CpuRaster
} }
} }
@@ -10,7 +10,7 @@ import com.compose.sdl.renderer.skia.SkiaRenderBackend
actual delegates here when Skia is the selected renderer). Mirrors the actual delegates here when Skia is the selected renderer). Mirrors the
SDL3 module's createRenderBackend / rendererPreferredGpuMode in the same SDL3 module's createRenderBackend / rendererPreferredGpuMode in the same
package; exactly one renderer module is included per target. */ package; exactly one renderer module is included per target. */
actual fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? { actual fun createRenderBackend(inSdl: Sdl3Backend, inGpu: GpuMode): RenderBackend? {
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
return try { return try {
SkiaRenderBackend(inSdl, vResolved) SkiaRenderBackend(inSdl, vResolved)
@@ -13,7 +13,7 @@ import org.jetbrains.skia.Image
per frame; the concrete implementation owns whatever GPU / CPU per frame; the concrete implementation owns whatever GPU / CPU
resources it needs (raster buffer + SDL_Texture, or a GL/Metal backend resources it needs (raster buffer + SDL_Texture, or a GL/Metal backend
render target). */ render target). */
interface SkiaBridge { internal interface SkiaBridge {
val canvas: Canvas val canvas: Canvas
fun ensureSize(inWidth: Int, inHeight: Int): Boolean fun ensureSize(inWidth: Int, inHeight: Int): Boolean
fun present() fun present()
@@ -4,16 +4,13 @@ import com.compose.sdl.*
import kotlinx.cinterop.* import kotlinx.cinterop.*
import org.jetbrains.skia.BackendRenderTarget import org.jetbrains.skia.BackendRenderTarget
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Canvas import org.jetbrains.skia.Canvas
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.DirectContext import org.jetbrains.skia.DirectContext
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageInfo
import org.jetbrains.skia.Surface import org.jetbrains.skia.Surface
import org.jetbrains.skia.SurfaceColorFormat import org.jetbrains.skia.SurfaceColorFormat
import org.jetbrains.skia.SurfaceOrigin import org.jetbrains.skia.SurfaceOrigin
import sdl3.SDL_GL_MakeCurrent
import sdl3.SDL_GL_SwapWindow import sdl3.SDL_GL_SwapWindow
// ================== // ==================
@@ -27,17 +24,38 @@ import sdl3.SDL_GL_SwapWindow
Resize: tear down and rebuild the BackendRenderTarget + Surface; the Resize: tear down and rebuild the BackendRenderTarget + Surface; the
DirectContext is reused. The default GL framebuffer (id 0) is wrapped DirectContext is reused. The default GL framebuffer (id 0) is wrapped
directly; we don't manage an offscreen FBO. */ directly; we don't manage an offscreen FBO. */
internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge { internal class SkiaGLBridge(private val backend: Sdl3Backend) : SkiaBridge {
private var fContext: DirectContext? = null private var mContext: DirectContext? = null
private var fRT: BackendRenderTarget? = null private var mRT: BackendRenderTarget? = null
private var fSurface: Surface? = null private var mSurface: Surface? = null
private var fWidth = 0 private var mWidth = 0
private var fHeight = 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 { fun init(): Boolean {
return try { return try {
fContext = DirectContext.makeGL() makeCurrent()
fContext != null mContext = DirectContext.makeGL()
mContext != null
} catch (t: Throwable) { } catch (t: Throwable) {
println("SkiaGLBridge: DirectContext.makeGL failed: ${t.message}") println("SkiaGLBridge: DirectContext.makeGL failed: ${t.message}")
false false
@@ -45,17 +63,20 @@ internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge {
} }
override val canvas: Canvas 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 { 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 if (inWidth <= 0 || inHeight <= 0) return false
val vContext = fContext ?: return false val vContext = mContext ?: return false
fSurface?.close() mSurface?.close()
fRT?.close() mRT?.close()
fSurface = null mSurface = null
fRT = null mRT = null
// GL_RGBA8 = 0x8058. fbId = 0 binds the default framebuffer. // GL_RGBA8 = 0x8058. fbId = 0 binds the default framebuffer.
// sampleCnt = 0 means no MSAA; stencilBits = 8 is what Skia recommends. // sampleCnt = 0 means no MSAA; stencilBits = 8 is what Skia recommends.
@@ -77,31 +98,35 @@ internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge {
println("SkiaGLBridge: Surface.makeFromBackendRenderTarget returned null") println("SkiaGLBridge: Surface.makeFromBackendRenderTarget returned null")
return false return false
} }
fRT = vRT mRT = vRT
fSurface = vSurface mSurface = vSurface
fWidth = inWidth mWidth = inWidth
fHeight = inHeight mHeight = inHeight
return true return true
} }
override fun present() { override fun present() {
val vSurface = fSurface ?: return val vSurface = mSurface ?: return
val vWindow = backend.window ?: return val vWindow = backend.window ?: return
makeCurrent()
vSurface.flushAndSubmit() vSurface.flushAndSubmit()
SDL_GL_SwapWindow(vWindow.reinterpret()) SDL_GL_SwapWindow(vWindow.reinterpret())
} }
override fun snapshot(): Image? = fSurface?.makeImageSnapshot() override fun snapshot(): Image? = mSurface?.makeImageSnapshot()
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = override fun snapshotBgra(): Triple<Int, Int, ByteArray>? =
readBackBgra(fSurface, fWidth, fHeight) readBackBgra(mSurface, mWidth, mHeight)
override fun destroy() { override fun destroy() {
fSurface?.close() // Free this window's GPU objects against its OWN context, not whichever
fRT?.close() // window happens to be current.
fContext?.close() makeCurrent()
fSurface = null mSurface?.close()
fRT = null mRT?.close()
fContext = null mContext?.close()
mSurface = null
mRT = null
mContext = null
} }
} }
@@ -25,13 +25,13 @@ import org.jetbrains.skia.Color as SkColor
elevation shadows ride behind it via skiaLeafDrawer + the SkiaBackedCanvas elevation shadows ride behind it via skiaLeafDrawer + the SkiaBackedCanvas
manual-vendor. */ manual-vendor. */
internal class SkiaRenderBackend( internal class SkiaRenderBackend(
private val sdl: SDL3Backend, private val sdl: Sdl3Backend,
private val gpuMode: GpuMode, private val gpuMode: GpuMode,
) : RenderBackend { ) : RenderBackend {
private val fBridge: SkiaBridge = buildBridge() private val mBridge: SkiaBridge = buildBridge()
private val fSkiaImageCache = SkiaImageCache() private val mSkiaImageCache = SkiaImageCache()
private var fCurrentCanvas: Canvas? = null private var mCurrentCanvas: Canvas? = null
init { init {
// skiko's paragraph engine always supports variable-font axes (Material // 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) // 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 // 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. // 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 val imageLoader: ImageLoader = object : ImageLoader {
override fun intrinsicSize(inPath: String, inKind: ResourceKind): Size = override fun intrinsicSize(inPath: String, inKind: ResourceKind): Size =
fSkiaImageCache.intrinsicSize(inPath, inKind) mSkiaImageCache.intrinsicSize(inPath, inKind)
override fun readBytes(inPath: String): ByteArray? = override fun readBytes(inPath: String): ByteArray? =
loadComposeResourceBytes(inPath) loadComposeResourceBytes(inPath)
} }
private fun buildBridge(): SkiaBridge = when (gpuMode) { private fun buildBridge(): SkiaBridge = when (gpuMode) {
is GpuMode.Skia.Metal -> makeMetalBridge(sdl) ?: error("Skia.Metal isn't supported on this target") is GpuMode.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.OpenGL -> SkiaGLBridge(sdl).also { require(it.init()) { "Skia.OpenGL init failed" } }
is GpuMode.Software -> SkiaSurfaceBridge(sdl) is GpuMode.CpuRaster -> SkiaSurfaceBridge(sdl)
is GpuMode.Auto -> error("SkiaRenderBackend received non-Skia gpuMode $gpuMode") is GpuMode.Auto -> error("SkiaRenderBackend received non-Skia gpuMode $gpuMode")
} }
override fun ensureSize(inPixelWidth: Int, inPixelHeight: Int): Boolean = override fun ensureSize(inPixelWidth: Int, inPixelHeight: Int): Boolean =
fBridge.ensureSize(inPixelWidth, inPixelHeight) mBridge.ensureSize(inPixelWidth, inPixelHeight)
override fun beginFrame(inDpr: Float) { override fun beginFrame(inDpr: Float) {
val canvas = fBridge.canvas val canvas = mBridge.canvas
// Clear the surface to Material dark. Without this the Metal back // Clear the surface to Material dark. Without this the Metal back
// buffer shows uninitialized GPU memory (pink). Clearing before scale // buffer shows uninitialized GPU memory (pink). Clearing before scale
// so it covers the whole physical target. // so it covers the whole physical target.
canvas.clear(SkColor.makeARGB(0xFF, 0x12, 0x12, 0x12)) canvas.clear(SkColor.makeARGB(0xFF, 0x12, 0x12, 0x12))
canvas.save() canvas.save()
if (inDpr != 1f) canvas.scale(inDpr, inDpr) if (inDpr != 1f) canvas.scale(inDpr, inDpr)
fCurrentCanvas = canvas mCurrentCanvas = canvas
} }
override fun drawRoot(inDraw: (androidx.compose.ui.graphics.Canvas) -> Unit) { 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. // 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/ // Wrap the live skia canvas as upstream's SkiaBackedCanvas (real gradients/
// paint/shader). Its port draw contracts forward to skiaLeafDrawer. The // paint/shader). Its port draw contracts forward to skiaLeafDrawer. The
// ImageBitmap-backed offscreen (VectorPainter / DrawCache) now goes through // ImageBitmap-backed offscreen (VectorPainter / DrawCache) now goes through
@@ -104,18 +104,18 @@ internal class SkiaRenderBackend(
} }
override fun endFrame() { override fun endFrame() {
fCurrentCanvas?.restore() mCurrentCanvas?.restore()
fCurrentCanvas = null mCurrentCanvas = null
fBridge.present() mBridge.present()
} }
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = fBridge.snapshotBgra() override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = mBridge.snapshotBgra()
override fun destroy() { override fun destroy() {
// Drop the global if it still points at this (about-to-be-destroyed) backend, // 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. // so a stray draw before the next window's drawRoot can't hit freed renderers.
if (skiaLeafDrawer === fLeafDrawer) skiaLeafDrawer = null if (skiaLeafDrawer === mLeafDrawer) skiaLeafDrawer = null
fSkiaImageCache.destroy() mSkiaImageCache.destroy()
fBridge.destroy() mBridge.destroy()
} }
} }
@@ -18,21 +18,21 @@ import sdl3.*
/** Owns the CPU pixel buffer Skia draws into, plus the streaming SDL_Texture /** 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, used to push that buffer onto the window. All three (buffer, Skia surface,
SDL texture) get reallocated when the window resizes. */ SDL texture) get reallocated when the window resizes. */
class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge { internal class SkiaSurfaceBridge(private val backend: Sdl3Backend) : SkiaBridge {
private var fWidth = 0 private var mWidth = 0
private var fHeight = 0 private var mHeight = 0
private var fPixels: CPointer<UByteVar>? = null private var mPixels: CPointer<UByteVar>? = null
private var fSurface: Surface? = null private var mSurface: Surface? = null
private var fTexture: COpaquePointer? = null private var mTexture: COpaquePointer? = null
override val canvas: Canvas override val canvas: Canvas
get() = requireNotNull(fSurface) { "SkiaSurfaceBridge not initialised" }.canvas get() = requireNotNull(mSurface) { "SkiaSurfaceBridge not initialised" }.canvas
val width: Int get() = fWidth val width: Int get() = mWidth
val height: Int get() = fHeight val height: Int get() = mHeight
override fun ensureSize(inWidth: Int, inHeight: Int): Boolean { 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 if (inWidth <= 0 || inHeight <= 0) return false
destroy() destroy()
@@ -60,34 +60,34 @@ class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
} }
SDL_SetTextureBlendMode(vTexture.reinterpret(), SDL_BLENDMODE_NONE) SDL_SetTextureBlendMode(vTexture.reinterpret(), SDL_BLENDMODE_NONE)
fPixels = vPixels mPixels = vPixels
fSurface = vSurface mSurface = vSurface
fTexture = vTexture mTexture = vTexture
fWidth = inWidth mWidth = inWidth
fHeight = inHeight mHeight = inHeight
return true return true
} }
/** Skia → SDL_Texture → screen, once per frame. */ /** Skia → SDL_Texture → screen, once per frame. */
override fun present() { override fun present() {
val vRenderer = backend.renderer ?: return val vRenderer = backend.renderer ?: return
val vTexture = fTexture ?: return val vTexture = mTexture ?: return
val vSurface = fSurface ?: return val vSurface = mSurface ?: return
val vPixels = fPixels ?: return val vPixels = mPixels ?: return
vSurface.flushAndSubmit() 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_RenderTexture(vRenderer.reinterpret(), vTexture.reinterpret(), null, null)
SDL_RenderPresent(vRenderer.reinterpret()) 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. */ Repack to BGRA so we share one BMP writer with the GPU bridges. */
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? { override fun snapshotBgra(): Triple<Int, Int, ByteArray>? {
val vPixels = fPixels ?: return null val vPixels = mPixels ?: return null
val vCount = fWidth * fHeight val vCount = mWidth * mHeight
val vBytes = ByteArray(vCount * 4) val vBytes = ByteArray(vCount * 4)
for (i in 0 until vCount) { for (i in 0 until vCount) {
val vR = vPixels[i * 4 + 0] 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 + 2] = vR.toByte()
vBytes[i * 4 + 3] = vA.toByte() vBytes[i * 4 + 3] = vA.toByte()
} }
return Triple(fWidth, fHeight, vBytes) return Triple(mWidth, mHeight, vBytes)
} }
override fun destroy() { override fun destroy() {
fTexture?.let { SDL_DestroyTexture(it.reinterpret()) } mTexture?.let { SDL_DestroyTexture(it.reinterpret()) }
fSurface?.close() mSurface?.close()
fPixels?.let { nativeHeap.free(it) } mPixels?.let { nativeHeap.free(it) }
fTexture = null mTexture = null
fSurface = null mSurface = null
fPixels = null mPixels = null
fWidth = 0 mWidth = 0
fHeight = 0 mHeight = 0
} }
} }
@@ -6,7 +6,7 @@ import com.compose.sdl.renderer.skia.SkiaBridge
// MARK: Windows (mingwX64) GPU defaults // 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 // SkiaSurfaceBridge), so no GPU-context bridge is provided here yet. A D3D/Vulkan
// bridge can be added later (Route 1a milestone 2). // bridge can be added later (Route 1a milestone 2).
internal actual fun makeMetalBridge(backend: SDL3Backend): SkiaBridge? = null internal actual fun makeMetalBridge(backend: Sdl3Backend): SkiaBridge? = null
@@ -36,7 +36,7 @@ internal fun AnimationScreen() {
"animation.core", "animation.core",
"animate*AsState, AnimationSpec (tween / spring / repeatable / infiniteRepeatable), " + "animate*AsState, AnimationSpec (tween / spring / repeatable / infiniteRepeatable), " +
"Easing (Linear, FastOutSlowIn, CubicBezier), rememberInfiniteTransition. " + "Easing (Linear, FastOutSlowIn, CubicBezier), rememberInfiniteTransition. " +
"All driven by the SDL3FrameClock via withFrameNanos.", "All driven by the Sdl3FrameClock via withFrameNanos.",
) )
// ============ // ============
@@ -31,7 +31,7 @@ internal fun GestureScreen() {
ScreenTitle( ScreenTitle(
"pointerInput + gesture DSL", "pointerInput + gesture DSL",
"Suspending Modifier.pointerInput { } with detectTapGestures / detectDragGestures " + "Suspending Modifier.pointerInput { } with detectTapGestures / detectDragGestures " +
"on top. Awaits resume on the SDL3FrameClock; no per-event polling.", "on top. Awaits resume on the Sdl3FrameClock; no per-event polling.",
) )
// ============ // ============
+6 -957
View File
@@ -2,7 +2,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -10,29 +9,17 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.compose.sdl.Window import com.compose.sdl.Window
import com.compose.sdl.nativeComposeApp import com.compose.sdl.nativeComposeApp
import com.compose.sdl.nativeComposeWindow
import demo.registry.allCategories import demo.registry.allCategories
import demo.shell.App import demo.shell.App
import screens.ExtraWindows import screens.ExtraWindows
import utils.encodeBmpBgra32 import utils.encodeBmpBgra32
import utils.parseArgs import utils.parseArgs
import utils.writeFile import utils.writeFile
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.toKString
// ================== // ==================
// MARK: Entry point // MARK: Entry point
@@ -79,6 +66,12 @@ fun main(args: Array<String>) {
runSearchEscTest() runSearchEscTest()
return 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, // Verifies nativeComposeApp multi-window: two Windows render concurrently,
// one closes via state, the app keeps running on the survivor. // one closes via state, the app keeps running on the survivor.
if (args.any { it == "--multiwintest" }) { 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 own id, so closing one removes exactly THAT id (a count would always drop the
last-declared window, closing the wrong one). */ last-declared window, closing the wrong one). */
val ExtraWindows = mutableStateListOf<Int>() val ExtraWindows = mutableStateListOf<Int>()
private var fNextExtraWindowId = 1 private var mNextExtraWindowId = 1
fun openExtraWindow() { fun openExtraWindow() {
ExtraWindows.add(fNextExtraWindowId++) ExtraWindows.add(mNextExtraWindowId++)
} }
@Composable @Composable
+7 -8
View File
@@ -8,7 +8,7 @@ import com.compose.sdl.GpuMode
/** Parsed view of the demo's command line. /** 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) --screen=Buttons | TextField | ... (default: full app w/ sidebar)
--screenshot=path.bmp capture at quiescence and quit --screenshot=path.bmp capture at quiescence and quit
--width=W --height=H (default 1000 / 700) --width=W --height=H (default 1000 / 700)
@@ -25,15 +25,14 @@ internal data class CliArgs(
val maxFrames: Int = 300, val maxFrames: Int = 300,
) )
/** Translates the --gpu= string to the right GpuMode sealed instance. /** Translates the --gpu= string to a GpuMode. Accepts the bare driver names
Accepts both dotted (`skia.metal`) and dashed (`skia-metal`) forms, (`metal`, `opengl`, `cpu`) plus the retired `skia.*` / `skia-*` spellings, so
plus the bare driver names (`metal`, `opengl`, …) as Skia aliases existing scripts and docs keep working after GpuMode was flattened. */
for backwards compatibility. */
private fun parseGpu(inValue: String): GpuMode = when (inValue.lowercase().replace('-', '.')) { private fun parseGpu(inValue: String): GpuMode = when (inValue.lowercase().replace('-', '.')) {
"auto" -> GpuMode.Auto "auto" -> GpuMode.Auto
"none", "cpu", "software" -> GpuMode.Software "none", "cpu", "software", "raster" -> GpuMode.CpuRaster
"metal", "skia.metal" -> GpuMode.Skia.Metal "metal", "skia.metal" -> GpuMode.Metal
"opengl", "gl", "skia.opengl" -> GpuMode.Skia.OpenGL "opengl", "gl", "skia.opengl" -> GpuMode.OpenGL
else -> { else -> {
println("Unknown --gpu=$inValue, using auto") println("Unknown --gpu=$inValue, using auto")
GpuMode.Auto GpuMode.Auto
@@ -278,7 +278,7 @@ internal object IconCodec {
} }
/** An 8-byte little-endian [width][height] header + straight-alpha RGBA - the /** An 8-byte little-endian [width][height] header + straight-alpha RGBA - the
format the runtime (WindowIcon.kt / SDL3Backend) reads. */ format the runtime (WindowIcon.kt / Sdl3Backend) reads. */
fun rgbaBlob(inW: Int, inH: Int, inRgba: ByteArray): ByteArray { fun rgbaBlob(inW: Int, inH: Int, inRgba: ByteArray): ByteArray {
val vOut = ByteArray(8 + inRgba.size) val vOut = ByteArray(8 + inRgba.size)
le32(vOut, 0, inW) le32(vOut, 0, inW)
@@ -20,7 +20,7 @@ import org.gradle.api.tasks.Copy
// //
// Text rendering goes through Skia's skparagraph (HarfBuzz + skunicode). On // 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 // 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 // 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 // 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 // compatible icudtl.dat (same Skia base as the fork), so we source it from there