mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-05 10:47:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2656f905 | ||
|
|
48cfa5da4d | ||
|
|
856cbe5ef3 | ||
|
|
5c7308c614 | ||
|
|
2fb322776a | ||
|
|
7c7bb63cae | ||
|
|
02fb2ddf4e | ||
|
|
126e83c056 | ||
|
|
0c09109e6e | ||
|
|
5804128e1d | ||
|
|
04d1df58de | ||
|
|
a87ff2d7f6 |
@@ -566,8 +566,18 @@ drift / vendor-clean guardrails are in
|
||||
## Key files by area - start here when you need to find something
|
||||
|
||||
### Renderer + main loop
|
||||
- `compose/desktop/native/desktop-native-window/src/nativeMain/…/ComposeWindow.kt` - main loop,
|
||||
recomposer lifecycle, SDL event dispatch, composition-local seeding.
|
||||
- `compose/desktop/native/desktop-native-window/src/nativeMain/…/` - the window
|
||||
module, one concern per file:
|
||||
- `ComposeWindow.kt` - the PUBLIC surface (`ApplicationScope`, `Window()`,
|
||||
`nativeComposeApp`, `nativeComposeWindow`) + the main loop.
|
||||
- `WindowInstance.kt` - one SDL window: backend + render backend + root host +
|
||||
its own recomposer/composition, event handling, frame pump, teardown. This is
|
||||
where composition-local seeding lives.
|
||||
- `AppRuntime.kt` - the live-window registry + create/destroy lifecycle.
|
||||
- `WindowAttributes.kt` - the Compose Desktop `Window()` attribute bundle.
|
||||
- `FrameTiming.kt` - deterministic/virtual frame clocks and the
|
||||
probe-quiescence hooks (`disableInfiniteAnimations`, `useVirtualFrameTime`,
|
||||
`windowHasInvalidations`).
|
||||
- `compose/ui/ui/src/nativeMain/…/RenderBackend.kt` - the interface.
|
||||
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` - sealed driver picker
|
||||
(`Auto` / `Software` / `Skia.OpenGL` / `Skia.Metal`).
|
||||
@@ -632,9 +642,23 @@ Open renderer / fidelity / release work is tracked in [PLAN.md](PLAN.md).
|
||||
|
||||
## Conventions
|
||||
|
||||
Kotlin standard style - plain `camelCase` for parameters, local variables,
|
||||
and fields. **No `f`/`in`/`v` prefixes**, no SPIRTECH scheme (see the global
|
||||
CLAUDE.md - that scheme is C-only).
|
||||
Kotlin standard style for formatting (braces, wrapping, indentation).
|
||||
|
||||
Naming, as the code actually is - this paragraph used to claim "no prefixes",
|
||||
which had not been true for a long time:
|
||||
|
||||
- **`m` for private/protected members** - `mWidth`, `mSurface`, `mCache`. NOT
|
||||
`f`: the `f` prefix was retired across the project, so do not reintroduce it.
|
||||
- **`v` for local variables** - `vWindow`, `vResult`.
|
||||
- **`in` for function parameters** - `inTitle`, `inWidth`. Note Compose
|
||||
`@Composable` parameters are the exception: they stay plain, because the name
|
||||
is part of the call-site API (`title = `, `onCloseRequest = `).
|
||||
- **`k` for private file-level constants** - `kMaxFrames`, `kIsWindows`.
|
||||
- Public API surface is plain `camelCase` - prefixes are for implementation
|
||||
detail only, and nothing prefixed should ever reach the klib dump.
|
||||
|
||||
VENDORED code under `src/vendor/` follows UPSTREAM's naming, untouched - it is
|
||||
copied byte-for-byte, so never re-prefix anything in it.
|
||||
|
||||
Section headers inside a file, when useful:
|
||||
|
||||
@@ -752,7 +776,7 @@ Caveats that make these work here (all already wired - listed so nobody
|
||||
- The google `LocalViewModelStoreOwner` / `LocalSavedStateRegistryOwner` /
|
||||
`LocalLifecycleOwner` are PLAIN composition locals - the JB HostDefault
|
||||
mechanism (`compositionLocalWithHostDefaultOf`) does not exist in google
|
||||
artifacts. `WindowArchitectureOwner` (ComposeWindow.kt) provides all three
|
||||
artifacts. `WindowArchitectureOwner` (WindowInstance.kt) provides all three
|
||||
per window, mirrors upstream desktop's DefaultArchitectureComponentsOwner,
|
||||
calls `enableSavedStateHandles()` at construction, and follows SDL focus /
|
||||
visibility (focused → RESUMED, unfocused → STARTED, minimised → CREATED).
|
||||
|
||||
@@ -727,6 +727,30 @@ A read-only feasibility map (no code changed) found two layers:
|
||||
proves infeasible, fall back to keeping the seam but at least converting the hand files to tracked
|
||||
Rule-3 vendors (like `FontRasterizationSettings.native.kt` now has a `VENDOR-BASE` line).
|
||||
|
||||
### 7c. `demo --multiwintest` segfault on mingwX64 - ✅ FIXED
|
||||
|
||||
The multi-window probe exited 139 with no output, so it had silently been
|
||||
asserting nothing. Root cause: an OpenGL context is per-WINDOW but "current" is
|
||||
per-THREAD, and `SDL_GL_MakeCurrent` ran only once at creation
|
||||
(`SDL3Backend.init`) with nothing re-binding it per frame. With two windows open,
|
||||
whichever initialised last owned the thread - so window A issued its draw and
|
||||
swap against window B's context (silent corruption while both lived), and
|
||||
destroying B left the current context dangling, faulting in `present()`.
|
||||
|
||||
Fixed in `SkiaGLBridge` by binding the window's own context at every entry point
|
||||
that touches GL: `init`, `ensureSize` (before its unchanged-size early return -
|
||||
this is the per-frame bind that covers the draw phase), `present`, and `destroy`
|
||||
(so a window frees its GPU objects against its own context). Purely additive, 29
|
||||
lines.
|
||||
|
||||
Single-window rendering is bit-identical before and after (same mean RGB
|
||||
[46.6 44.4 51.5] and 167 distinct colours on the Buttons screenshot), which is
|
||||
expected: SDL short-circuits a redundant bind. `--multiwintest` now PASSes.
|
||||
|
||||
NOTE the multi-window path was never correct, not just at teardown - two live
|
||||
windows were sharing one context. Anything that looked like flicker or
|
||||
cross-window corruption before this is explained by it.
|
||||
|
||||
## Accepted 1.0.0 gaps (documented, not fixed)
|
||||
|
||||
Drag-OUT of window (SDL platform limit; drop-IN works), full accessibility pipeline (out of
|
||||
@@ -813,6 +837,18 @@ Two residuals, neither a text-metrics bug:
|
||||
(0.29% at +0px vs 2.13% at +1px), where before +2px halved the error. The residual 0.29%
|
||||
is rasterizer AA.
|
||||
|
||||
- **Icon-font glyphs rendered LOW - ✅ FIXED.** Material Symbols sat 2-4px lower on native
|
||||
than on jvm, the offset scaling with icon size (~13% of it). Cause: the JVM actual centres
|
||||
the glyph explicitly (`y = height/2 - (ascent+descent)/2`), while `IconFontIcon` placed
|
||||
`IconText` in a Box at the DEFAULT TopStart alignment - and the glyph's natural text box is
|
||||
taller than the nominal icon size. Fixed by centring, with
|
||||
`wrapContentSize(unbounded = true)` so the text measures at its natural size first:
|
||||
`Modifier.size()` imposes FIXED constraints, so without it nothing overflows and
|
||||
contentAlignment is a no-op (a first attempt without it changed literally nothing).
|
||||
gear/download/lock are now pixel-exact; the eye is 1px off from half-pixel rounding (odd
|
||||
glyph height centred in an even box; jvm draws at a float y, native placement is integer).
|
||||
Whole-window >32/255 drift 0.29% -> 0.17%.
|
||||
|
||||
- **Missing glyph in the session header.** The leading icon next to "Untitled session"
|
||||
renders as a `.notdef` TOFU on jvm and as NOTHING on native - so the glyph is absent from
|
||||
the subsetted Material Symbols font on both, and the two stacks just disagree on how to
|
||||
|
||||
@@ -71,6 +71,23 @@ Each window gets its own `Lifecycle`, `ViewModelStore`, and `SavedStateRegistry`
|
||||
owners, driven by real SDL focus and visibility events, so ViewModels and
|
||||
saved state behave as they do on Android.
|
||||
|
||||
`Window()` takes the same attributes as Compose Desktop's - `undecorated`,
|
||||
`transparent`, `resizable`, `enabled`, `focusable`, `alwaysOnTop`,
|
||||
`onPreviewKeyEvent` and `onKeyEvent` - and re-applies them when they change.
|
||||
(`transparent` is the one exception: SDL needs it at window-creation time, so it
|
||||
is fixed for the window's life.)
|
||||
|
||||
When the Compose-level API doesn't reach far enough, drop to SDL directly:
|
||||
|
||||
```kotlin
|
||||
val handles = window.rawSdlHandles() // SDL_Window* / SDL_Renderer* / GL / Metal
|
||||
handles.window?.let { sdl3.SDL_FlashWindow(it.reinterpret(), SDL_FLASH_BRIEFLY) }
|
||||
```
|
||||
|
||||
The pointers are a snapshot valid only while the window lives, and which ones
|
||||
are non-null depends on the resolved renderer (`glContext` for Skia OpenGL,
|
||||
`metalView` for Metal, `renderer` for CPU raster).
|
||||
|
||||
### Building in this repo
|
||||
|
||||
```kotlin
|
||||
|
||||
@@ -56,11 +56,11 @@ internal class PackState(
|
||||
// A linked copy mirrors its source's requests read-only; non-linked packs own
|
||||
// their requests. `requests` resolves to whichever applies.
|
||||
var linkedSource by mutableStateOf<PackState?>(null)
|
||||
private val fOwnRequests = mutableStateListOf<ReqState>().apply {
|
||||
private val mOwnRequests = mutableStateListOf<ReqState>().apply {
|
||||
inPack.requests.forEach { add(ReqState(it)) } // packs (and the loose root) may be empty
|
||||
}
|
||||
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
|
||||
get() = linkedSource?.requests ?: fOwnRequests
|
||||
get() = linkedSource?.requests ?: mOwnRequests
|
||||
val isLinked: Boolean get() = linkedSource != null
|
||||
var parent: PackState? =
|
||||
null // enclosing pack (null = top-level / root)
|
||||
|
||||
@@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonElement
|
||||
// respects the editor's "tab size" (TextLayoutConfig.tabWidth), exactly like
|
||||
// typed tabs. Lenient so slightly-off JSON (single quotes, trailing commas)
|
||||
// still reformats instead of failing.
|
||||
private val fPrettyJson = Json {
|
||||
private val mPrettyJson = Json {
|
||||
prettyPrint = true
|
||||
prettyPrintIndent = "\t"
|
||||
isLenient = true
|
||||
@@ -23,7 +23,7 @@ private val fPrettyJson = Json {
|
||||
fun formatBody(inText: String, inFormat: BodyFormat): String =
|
||||
when (inFormat) {
|
||||
BodyFormat.JSON -> runCatching {
|
||||
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
|
||||
mPrettyJson.encodeToString(JsonElement.serializer(), mPrettyJson.parseToJsonElement(inText))
|
||||
}.getOrDefault(inText)
|
||||
|
||||
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
|
||||
|
||||
@@ -22,7 +22,7 @@ macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
|
||||
run() is a suspend fun - call it off the UI dispatcher. */
|
||||
class HttpRunner {
|
||||
|
||||
private val fClient = createApiHttpClient()
|
||||
private val mClient = createApiHttpClient()
|
||||
|
||||
init {
|
||||
// Clear any temporary client certs a prior crash left in the Windows
|
||||
@@ -36,7 +36,7 @@ class HttpRunner {
|
||||
if (inReq.hasClientCert) return curlSendWithClientCert(inReq)
|
||||
val vMark = TimeSource.Monotonic.markNow()
|
||||
return try {
|
||||
val vResp: HttpResponse = fClient.request(inReq.url.trim()) {
|
||||
val vResp: HttpResponse = mClient.request(inReq.url.trim()) {
|
||||
method = HttpMethod.parse(inReq.method.name)
|
||||
inReq.params
|
||||
.filter { it.enabled && it.key.isNotBlank() }
|
||||
@@ -113,7 +113,7 @@ class HttpRunner {
|
||||
}
|
||||
}
|
||||
|
||||
fun close() = fClient.close()
|
||||
fun close() = mClient.close()
|
||||
}
|
||||
|
||||
/** Heuristic for a non-text body: textual content types (text, json, xml, html,
|
||||
|
||||
@@ -9,7 +9,7 @@ import okio.Path.Companion.toPath
|
||||
// MARK: Pack persistence (okio + kotlinx.serialization)
|
||||
// ==================
|
||||
|
||||
private val fJson = Json {
|
||||
private val mJson = Json {
|
||||
prettyPrint = true
|
||||
prettyPrintIndent = " "
|
||||
ignoreUnknownKeys = true
|
||||
@@ -20,7 +20,7 @@ private val fJson = Json {
|
||||
error message. */
|
||||
fun exportPack(inPack: Pack, inPath: String): String? = try {
|
||||
systemFileSystem.write(inPath.trim().toPath()) {
|
||||
writeUtf8(fJson.encodeToString(inPack))
|
||||
writeUtf8(mJson.encodeToString(inPack))
|
||||
}
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
@@ -30,7 +30,7 @@ fun exportPack(inPack: Pack, inPath: String): String? = try {
|
||||
/** Read a pack back from inPath. */
|
||||
fun importPack(inPath: String): Result<Pack> = try {
|
||||
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||
Result.success(fJson.decodeFromString<Pack>(vText))
|
||||
Result.success(mJson.decodeFromString<Pack>(vText))
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ fun importPack(inPath: String): Result<Pack> = try {
|
||||
/** Write a whole self-contained session to inPath. Returns null on success. */
|
||||
fun exportSession(inSession: Session, inPath: String): String? = try {
|
||||
systemFileSystem.write(inPath.trim().toPath()) {
|
||||
writeUtf8(fJson.encodeToString(inSession))
|
||||
writeUtf8(mJson.encodeToString(inSession))
|
||||
}
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
@@ -48,7 +48,7 @@ fun exportSession(inSession: Session, inPath: String): String? = try {
|
||||
/** Read a session back from inPath. */
|
||||
fun importSession(inPath: String): Result<Session> = try {
|
||||
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||
Result.success(fJson.decodeFromString<Session>(vText))
|
||||
Result.success(mJson.decodeFromString<Session>(vText))
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
|
||||
/** Re-indent a JSON response for display; returns the input unchanged if it
|
||||
isn't valid JSON (so plain-text / HTML responses still show). */
|
||||
fun prettyJsonOrRaw(inText: String): String = try {
|
||||
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
|
||||
mJson.encodeToString(JsonElement.serializer(), mJson.parseToJsonElement(inText))
|
||||
} catch (_: Throwable) {
|
||||
inText
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ data class Session(
|
||||
// MARK: Load / save (app-data dir via okio)
|
||||
// ==================
|
||||
|
||||
private val fStateJson = Json {
|
||||
private val mStateJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
prettyPrint = true
|
||||
@@ -88,7 +88,7 @@ fun loadAppState(): AppState {
|
||||
val vPath = stateFilePath() ?: return AppState()
|
||||
return try {
|
||||
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
|
||||
fStateJson.decodeFromString<AppState>(vText)
|
||||
mStateJson.decodeFromString<AppState>(vText)
|
||||
} catch (_: Throwable) {
|
||||
AppState()
|
||||
}
|
||||
@@ -99,7 +99,7 @@ the session, not the user's exported .json packs). */
|
||||
fun saveAppState(inState: AppState) {
|
||||
val vPath = stateFilePath() ?: return
|
||||
try {
|
||||
systemFileSystem.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
|
||||
systemFileSystem.write(vPath.toPath()) { writeUtf8(mStateJson.encodeToString(inState)) }
|
||||
} catch (_: Throwable) {
|
||||
// ignore - nothing the user can act on, and packs can still be exported
|
||||
}
|
||||
|
||||
@@ -157,9 +157,9 @@ actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inF
|
||||
|
||||
/** The jvm parity app only stores the preference - upstream's text pipeline has
|
||||
no global tab-width knob. Backed by state so menu checkmarks recompose. */
|
||||
private var fTabWidth by mutableStateOf(4)
|
||||
private var mTabWidth by mutableStateOf(4)
|
||||
actual var editorTabWidth: Int
|
||||
get() = fTabWidth
|
||||
get() = mTabWidth
|
||||
set(value) {
|
||||
fTabWidth = value
|
||||
mTabWidth = value
|
||||
}
|
||||
|
||||
+11
-11
@@ -51,20 +51,20 @@ private class SvgPainter(private val bytes: ByteArray) : Painter() {
|
||||
|
||||
override val intrinsicSize: Size = svgIntrinsicSize(bytes) ?: Size.Unspecified
|
||||
|
||||
private var fAlpha: Float = 1f
|
||||
private var fColorFilter: ColorFilter? = null
|
||||
private var fCacheKey: Long = -1L
|
||||
private var fCacheBitmap: ImageBitmap? = null
|
||||
private var mAlpha: Float = 1f
|
||||
private var mColorFilter: ColorFilter? = null
|
||||
private var mCacheKey: Long = -1L
|
||||
private var mCacheBitmap: ImageBitmap? = null
|
||||
|
||||
override fun applyAlpha(alpha: Float): Boolean { fAlpha = alpha; return true }
|
||||
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { fColorFilter = colorFilter; return true }
|
||||
override fun applyAlpha(alpha: Float): Boolean { mAlpha = alpha; return true }
|
||||
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { mColorFilter = colorFilter; return true }
|
||||
|
||||
private fun rasterFor(inWidthPx: Int, inHeightPx: Int): ImageBitmap? {
|
||||
val vKey = (inWidthPx.toLong() shl 32) or inHeightPx.toLong()
|
||||
if (vKey == fCacheKey && fCacheBitmap != null) return fCacheBitmap
|
||||
if (vKey == mCacheKey && mCacheBitmap != null) return mCacheBitmap
|
||||
val vBitmap = decodeSvgAt(bytes, inWidthPx, inHeightPx) ?: return null
|
||||
fCacheKey = vKey
|
||||
fCacheBitmap = vBitmap
|
||||
mCacheKey = vKey
|
||||
mCacheBitmap = vBitmap
|
||||
return vBitmap
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ private class SvgPainter(private val bytes: ByteArray) : Painter() {
|
||||
srcSize = IntSize(vBitmap.width, vBitmap.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(vWpx, vHpx),
|
||||
alpha = fAlpha,
|
||||
colorFilter = fColorFilter,
|
||||
alpha = mAlpha,
|
||||
colorFilter = mColorFilter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+46
-46
@@ -32,13 +32,13 @@ private class Attr(val prefix: String, val localName: String, val nsUri: String,
|
||||
val qualifiedName: String get() = if (prefix.isEmpty()) localName else "$prefix:$localName"
|
||||
}
|
||||
|
||||
private class NodeListImpl(private val fNodes: List<Node>) : NodeList {
|
||||
override fun item(i: Int): Node = fNodes[i]
|
||||
override val length: Int get() = fNodes.size
|
||||
private class NodeListImpl(private val mNodes: List<Node>) : NodeList {
|
||||
override fun item(i: Int): Node = mNodes[i]
|
||||
override val length: Int get() = mNodes.size
|
||||
}
|
||||
|
||||
private class TextNodeImpl(private val fText: String) : Node {
|
||||
override val textContent: String? get() = fText
|
||||
private class TextNodeImpl(private val mText: String) : Node {
|
||||
override val textContent: String? get() = mText
|
||||
override val nodeName: String get() = "#text"
|
||||
override val localName: String get() = ""
|
||||
override val childNodes: NodeList = NodeListImpl(emptyList())
|
||||
@@ -47,16 +47,16 @@ private class TextNodeImpl(private val fText: String) : Node {
|
||||
}
|
||||
|
||||
private class ElementImpl(
|
||||
private val fPrefix: String,
|
||||
private val mPrefix: String,
|
||||
override val localName: String,
|
||||
override val namespaceURI: String,
|
||||
private val fAttrs: List<Attr>,
|
||||
private val mAttrs: List<Attr>,
|
||||
// Innermost-first chain of xmlns scopes: each is prefix -> uri ("" = default ns).
|
||||
private val fNsScope: List<Map<String, String>>,
|
||||
private val mNsScope: List<Map<String, String>>,
|
||||
) : Element {
|
||||
var children: List<Node> = emptyList()
|
||||
|
||||
override val nodeName: String get() = if (fPrefix.isEmpty()) localName else "$fPrefix:$localName"
|
||||
override val nodeName: String get() = if (mPrefix.isEmpty()) localName else "$mPrefix:$localName"
|
||||
override val childNodes: NodeList get() = NodeListImpl(children)
|
||||
|
||||
// Concatenated descendant text - what DOM's textContent does.
|
||||
@@ -72,7 +72,7 @@ private class ElementImpl(
|
||||
|
||||
override fun lookupPrefix(namespaceURI: String): String {
|
||||
if (namespaceURI.isEmpty()) return ""
|
||||
for (vScope in fNsScope) {
|
||||
for (vScope in mNsScope) {
|
||||
for ((vPrefix, vUri) in vScope) {
|
||||
if (vUri == namespaceURI && vPrefix.isNotEmpty()) return vPrefix
|
||||
}
|
||||
@@ -81,22 +81,22 @@ private class ElementImpl(
|
||||
}
|
||||
|
||||
override fun getAttributeNS(nameSpaceURI: String, localName: String): String =
|
||||
fAttrs.firstOrNull { it.nsUri == nameSpaceURI && it.localName == localName }?.value ?: ""
|
||||
mAttrs.firstOrNull { it.nsUri == nameSpaceURI && it.localName == localName }?.value ?: ""
|
||||
|
||||
override fun getAttribute(name: String): String =
|
||||
fAttrs.firstOrNull { it.qualifiedName == name }?.value ?: ""
|
||||
mAttrs.firstOrNull { it.qualifiedName == name }?.value ?: ""
|
||||
}
|
||||
|
||||
// ============
|
||||
// The parser - one pass over the string with a cursor.
|
||||
private class XmlDomParser(private val fXml: String) {
|
||||
private var fPos = 0
|
||||
private class XmlDomParser(private val mXml: String) {
|
||||
private var mPos = 0
|
||||
|
||||
fun skipProlog() {
|
||||
while (true) {
|
||||
skipWhitespace()
|
||||
when {
|
||||
lookingAt("") -> fPos++
|
||||
lookingAt("") -> mPos++
|
||||
lookingAt("<?") -> skipUntil("?>")
|
||||
lookingAt("<!--") -> skipUntil("-->")
|
||||
lookingAt("<!DOCTYPE") -> skipDoctype()
|
||||
@@ -112,7 +112,7 @@ private class XmlDomParser(private val fXml: String) {
|
||||
if (!lookingAt("<") || lookingAt("</")) return null
|
||||
expect("<")
|
||||
val vQName = readName()
|
||||
if (vQName.isEmpty()) throw MalformedXMLException("empty element name at $fPos")
|
||||
if (vQName.isEmpty()) throw MalformedXMLException("empty element name at $mPos")
|
||||
|
||||
// Attributes (collect raw first - xmlns declarations shape the scope
|
||||
// that THIS element's own prefix resolves against).
|
||||
@@ -121,7 +121,7 @@ private class XmlDomParser(private val fXml: String) {
|
||||
skipWhitespace()
|
||||
if (lookingAt("/>") || lookingAt(">")) break
|
||||
val vAttrName = readName()
|
||||
if (vAttrName.isEmpty()) throw MalformedXMLException("bad attribute at $fPos")
|
||||
if (vAttrName.isEmpty()) throw MalformedXMLException("bad attribute at $mPos")
|
||||
skipWhitespace(); expect("="); skipWhitespace()
|
||||
vRawAttrs.add(vAttrName to readQuotedValue())
|
||||
}
|
||||
@@ -160,7 +160,7 @@ private class XmlDomParser(private val fXml: String) {
|
||||
val vElement = ElementImpl(vPrefix, vLocal, resolveNs(vPrefix), vAttrs, vScope)
|
||||
|
||||
if (lookingAt("/>")) {
|
||||
fPos += 2
|
||||
mPos += 2
|
||||
return vElement
|
||||
}
|
||||
expect(">")
|
||||
@@ -169,9 +169,9 @@ private class XmlDomParser(private val fXml: String) {
|
||||
val vChildren = ArrayList<Node>()
|
||||
while (true) {
|
||||
when {
|
||||
fPos >= fXml.length -> throw MalformedXMLException("unclosed <$vQName>")
|
||||
mPos >= mXml.length -> throw MalformedXMLException("unclosed <$vQName>")
|
||||
lookingAt("</") -> {
|
||||
fPos += 2
|
||||
mPos += 2
|
||||
val vClose = readName()
|
||||
skipWhitespace(); expect(">")
|
||||
if (vClose != vQName) throw MalformedXMLException("mismatched </$vClose> for <$vQName>")
|
||||
@@ -180,18 +180,18 @@ private class XmlDomParser(private val fXml: String) {
|
||||
}
|
||||
lookingAt("<!--") -> skipUntil("-->")
|
||||
lookingAt("<![CDATA[") -> {
|
||||
val vEnd = fXml.indexOf("]]>", fPos + 9)
|
||||
val vEnd = mXml.indexOf("]]>", mPos + 9)
|
||||
if (vEnd < 0) throw MalformedXMLException("unclosed CDATA")
|
||||
vChildren.add(TextNodeImpl(fXml.substring(fPos + 9, vEnd)))
|
||||
fPos = vEnd + 3
|
||||
vChildren.add(TextNodeImpl(mXml.substring(mPos + 9, vEnd)))
|
||||
mPos = vEnd + 3
|
||||
}
|
||||
lookingAt("<?") -> skipUntil("?>")
|
||||
lookingAt("<") -> parseElement(vScope)?.let { vChildren.add(it) }
|
||||
else -> {
|
||||
val vNext = fXml.indexOf('<', fPos).let { if (it < 0) fXml.length else it }
|
||||
val vText = decodeEntities(fXml.substring(fPos, vNext))
|
||||
val vNext = mXml.indexOf('<', mPos).let { if (it < 0) mXml.length else it }
|
||||
val vText = decodeEntities(mXml.substring(mPos, vNext))
|
||||
if (vText.isNotBlank()) vChildren.add(TextNodeImpl(vText))
|
||||
fPos = vNext
|
||||
mPos = vNext
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,28 +200,28 @@ private class XmlDomParser(private val fXml: String) {
|
||||
// ============
|
||||
// Lexing helpers
|
||||
|
||||
private fun lookingAt(inToken: String): Boolean = fXml.startsWith(inToken, fPos)
|
||||
private fun lookingAt(inToken: String): Boolean = mXml.startsWith(inToken, mPos)
|
||||
|
||||
private fun expect(inToken: String) {
|
||||
if (!lookingAt(inToken)) throw MalformedXMLException("expected '$inToken' at $fPos")
|
||||
fPos += inToken.length
|
||||
if (!lookingAt(inToken)) throw MalformedXMLException("expected '$inToken' at $mPos")
|
||||
mPos += inToken.length
|
||||
}
|
||||
|
||||
private fun skipWhitespace() {
|
||||
while (fPos < fXml.length && fXml[fPos].isWhitespace()) fPos++
|
||||
while (mPos < mXml.length && mXml[mPos].isWhitespace()) mPos++
|
||||
}
|
||||
|
||||
private fun skipUntil(inToken: String) {
|
||||
val vEnd = fXml.indexOf(inToken, fPos)
|
||||
val vEnd = mXml.indexOf(inToken, mPos)
|
||||
if (vEnd < 0) throw MalformedXMLException("unterminated '$inToken' section")
|
||||
fPos = vEnd + inToken.length
|
||||
mPos = vEnd + inToken.length
|
||||
}
|
||||
|
||||
/* DOCTYPE may nest an internal subset in [ ] - skip to the matching '>'. */
|
||||
private fun skipDoctype() {
|
||||
var vDepth = 0
|
||||
while (fPos < fXml.length) {
|
||||
val c = fXml[fPos++]
|
||||
while (mPos < mXml.length) {
|
||||
val c = mXml[mPos++]
|
||||
if (c == '[') vDepth++
|
||||
else if (c == ']') vDepth--
|
||||
else if (c == '>' && vDepth <= 0) return
|
||||
@@ -230,24 +230,24 @@ private class XmlDomParser(private val fXml: String) {
|
||||
}
|
||||
|
||||
private fun readName(): String {
|
||||
val vStart = fPos
|
||||
while (fPos < fXml.length) {
|
||||
val c = fXml[fPos]
|
||||
val vStart = mPos
|
||||
while (mPos < mXml.length) {
|
||||
val c = mXml[mPos]
|
||||
if (c.isWhitespace() || c == '=' || c == '>' || c == '/' || c == '<') break
|
||||
fPos++
|
||||
mPos++
|
||||
}
|
||||
return fXml.substring(vStart, fPos)
|
||||
return mXml.substring(vStart, mPos)
|
||||
}
|
||||
|
||||
private fun readQuotedValue(): String {
|
||||
if (fPos >= fXml.length) throw MalformedXMLException("unterminated attribute value")
|
||||
val vQuote = fXml[fPos]
|
||||
if (vQuote != '"' && vQuote != '\'') throw MalformedXMLException("attribute value must be quoted at $fPos")
|
||||
fPos++
|
||||
val vEnd = fXml.indexOf(vQuote, fPos)
|
||||
if (mPos >= mXml.length) throw MalformedXMLException("unterminated attribute value")
|
||||
val vQuote = mXml[mPos]
|
||||
if (vQuote != '"' && vQuote != '\'') throw MalformedXMLException("attribute value must be quoted at $mPos")
|
||||
mPos++
|
||||
val vEnd = mXml.indexOf(vQuote, mPos)
|
||||
if (vEnd < 0) throw MalformedXMLException("unterminated attribute value")
|
||||
val vRaw = fXml.substring(fPos, vEnd)
|
||||
fPos = vEnd + 1
|
||||
val vRaw = mXml.substring(mPos, vEnd)
|
||||
mPos = vEnd + 1
|
||||
return decodeEntities(vRaw)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ final var com.compose.sdl/useVirtualFrameTime // com.compose.sdl/useVirtualFrame
|
||||
final fun <get-useVirtualFrameTime>(): kotlin/Boolean // com.compose.sdl/useVirtualFrameTime.<get-useVirtualFrameTime>|<get-useVirtualFrameTime>(){}[0]
|
||||
final fun <set-useVirtualFrameTime>(kotlin/Boolean) // com.compose.sdl/useVirtualFrameTime.<set-useVirtualFrameTime>|<set-useVirtualFrameTime>(kotlin.Boolean){}[0]
|
||||
|
||||
final fun (com.compose.sdl/ApplicationScope).com.compose.sdl/Window(kotlin/Function0<kotlin/Unit>, kotlin/String?, kotlin/Int, kotlin/Int, com.compose.sdl/GpuMode?, com.compose.sdl/AppWindowIcon?, kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>?, kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl/Window|Window@com.compose.sdl.ApplicationScope(kotlin.Function0<kotlin.Unit>;kotlin.String?;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode?;com.compose.sdl.AppWindowIcon?;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun (com.compose.sdl/ApplicationScope).com.compose.sdl/Window(kotlin/Function0<kotlin/Unit>, kotlin/String?, kotlin/Int, kotlin/Int, com.compose.sdl/GpuMode?, com.compose.sdl/AppWindowIcon?, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean>?, kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean>?, kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>?, kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // com.compose.sdl/Window|Window@com.compose.sdl.ApplicationScope(kotlin.Function0<kotlin.Unit>;kotlin.String?;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode?;com.compose.sdl.AppWindowIcon?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>?;kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>?;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_AppWindowIcon$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_AppWindowIcon$stableprop_getter|com_compose_sdl_AppWindowIcon$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/nativeComposeApp(kotlin/Function3<com.compose.sdl/ApplicationScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.sdl/nativeComposeApp|nativeComposeApp(kotlin.Function3<com.compose.sdl.ApplicationScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
|
||||
final fun com.compose.sdl/nativeComposeWindow(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., com.compose.sdl/AppWindowIcon? = ..., kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>? = ..., kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.sdl/nativeComposeWindow|nativeComposeWindow(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;com.compose.sdl.AppWindowIcon?;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
|
||||
final fun com.compose.sdl/nativeComposeWindow(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., com.compose.sdl/AppWindowIcon? = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean> = ..., kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean> = ..., kotlin/Function2<com.compose.sdl/RenderBackend, kotlin/Int, kotlin/Boolean>? = ..., kotlin/Function3<com.compose.sdl/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.sdl/nativeComposeWindow|nativeComposeWindow(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;com.compose.sdl.AppWindowIcon?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>;kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>;kotlin.Function2<com.compose.sdl.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.sdl.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
|
||||
final fun com.compose.sdl/windowHasInvalidations(): kotlin/Boolean // com.compose.sdl/windowHasInvalidations|windowHasInvalidations(){}[0]
|
||||
|
||||
+88
@@ -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() {}
|
||||
}
|
||||
+46
-677
@@ -5,46 +5,22 @@
|
||||
|
||||
package com.compose.sdl
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.AbstractApplier
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.runtime.Composition
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Recomposer
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.PointerButton
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import com.compose.sdl.node.ComposeRootHost
|
||||
import com.compose.sdl.res.currentImageLoader
|
||||
import com.compose.sdl.window.LocalPopupHost
|
||||
import com.compose.sdl.window.PopupLayer
|
||||
import com.compose.sdl.window.createPopupHostState
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.cinterop.toKString
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.COpaquePointer
|
||||
import kotlinx.cinterop.pointed
|
||||
import sdl3.SDL_Delay
|
||||
import sdl3.SDL_GetCurrentDisplayMode
|
||||
import sdl3.SDL_GetDisplayForWindow
|
||||
import sdl3.SDL_GetPerformanceCounter
|
||||
import sdl3.SDL_GetPerformanceFrequency
|
||||
import sdl3.SDL_GetTicks
|
||||
import sdl3.SDL_GetTicksNS
|
||||
import sdl3.SDL_Quit
|
||||
import sdl3.SDL_SetWindowTitle
|
||||
import sdl3.SDL_WaitEventTimeout
|
||||
|
||||
// ==================
|
||||
@@ -92,6 +68,14 @@ fun ApplicationScope.Window(
|
||||
height: Int = 600,
|
||||
gpu: GpuMode = GpuMode.Auto,
|
||||
icon: AppWindowIcon? = null,
|
||||
undecorated: Boolean = false,
|
||||
transparent: Boolean = false,
|
||||
resizable: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
focusable: Boolean = true,
|
||||
alwaysOnTop: Boolean = false,
|
||||
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
|
||||
onKeyEvent: (KeyEvent) -> Boolean = { false },
|
||||
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
|
||||
content: @Composable ComposeWindowScope.() -> Unit,
|
||||
) {
|
||||
@@ -99,6 +83,16 @@ fun ApplicationScope.Window(
|
||||
?: error("Window() must be called inside nativeComposeApp { ... }")
|
||||
val vContent = rememberUpdatedState(content)
|
||||
val vClose = rememberUpdatedState(onCloseRequest)
|
||||
val vPreviewKey = rememberUpdatedState(onPreviewKeyEvent)
|
||||
val vKey = rememberUpdatedState(onKeyEvent)
|
||||
val vAttrs = WindowAttributes(
|
||||
undecorated = undecorated,
|
||||
transparent = transparent,
|
||||
resizable = resizable,
|
||||
enabled = enabled,
|
||||
focusable = focusable,
|
||||
alwaysOnTop = alwaysOnTop,
|
||||
)
|
||||
val vWindow = remember {
|
||||
vScope.runtime.createWindow(
|
||||
inTitle = title,
|
||||
@@ -106,7 +100,12 @@ fun ApplicationScope.Window(
|
||||
inHeight = height,
|
||||
inGpu = gpu,
|
||||
inIcon = icon,
|
||||
inAttrs = vAttrs,
|
||||
inOnFrame = onFrame,
|
||||
// Read through holders so updated lambdas propagate without
|
||||
// recreating the window (same reason as the content holder below).
|
||||
inPreviewKeyHandler = { vPreviewKey.value },
|
||||
inKeyHandler = { vKey.value },
|
||||
// The window composition reads the holder each recomposition, so
|
||||
// updated content lambdas propagate without recreating the window.
|
||||
inContent = { vContent.value },
|
||||
@@ -115,6 +114,12 @@ fun ApplicationScope.Window(
|
||||
SideEffect {
|
||||
if (vWindow.facade.title != title) vWindow.facade.setTitle(title)
|
||||
}
|
||||
// Re-apply only on a real change; `transparent` is creation-only (see
|
||||
// WindowAttributes) so it is deliberately not re-applied here.
|
||||
DisposableEffect(vAttrs) {
|
||||
vWindow.applyAttributes(vAttrs)
|
||||
onDispose { }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { vScope.runtime.scheduleDestroy(vWindow) }
|
||||
}
|
||||
@@ -311,74 +316,6 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
SDL_Quit()
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: Probe / screenshot support (render-to-quiescence)
|
||||
// ==================
|
||||
|
||||
/** Set BEFORE nativeComposeApp/nativeComposeWindow: every window's composition then runs
|
||||
under an InfiniteAnimationPolicy that CANCELS infinite animations, so
|
||||
rememberInfiniteTransition & co. freeze at their initial value - the same mechanism
|
||||
upstream's test rules use. Screenshot/parity runs enable it so looping screens can
|
||||
reach quiescence and capture deterministically. */
|
||||
var disableInfiniteAnimations: Boolean = false
|
||||
|
||||
/** The cancelling policy: a coroutine that ends in CancellationException counts as
|
||||
cancelled (not failed), so only the animation coroutine stops - nothing propagates. */
|
||||
private object CancelInfiniteAnimationsPolicy : androidx.compose.ui.platform.InfiniteAnimationPolicy {
|
||||
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
|
||||
throw CancellationException("infinite animations are disabled (disableInfiniteAnimations)")
|
||||
}
|
||||
|
||||
/** Set BEFORE nativeComposeApp/nativeComposeWindow: the composition + animation frame
|
||||
clocks advance a VIRTUAL 16.6ms per main-loop iteration instead of reading SDL's
|
||||
real-time ticks - the native mirror of the JVM parity leg's render(nanos) stepping.
|
||||
Animations then progress by exact per-frame deltas, so anything time-raced (e.g. a
|
||||
bring-into-view scroll interrupted mid-flight) resolves identically on every run and
|
||||
screenshots become deterministic. Input timestamps and FPS stay on real time. */
|
||||
var useVirtualFrameTime: Boolean = false
|
||||
|
||||
private var virtualFrameNanos = 0L
|
||||
|
||||
private fun advanceVirtualFrame() {
|
||||
if (useVirtualFrameTime) virtualFrameNanos += 16_666_667L
|
||||
}
|
||||
|
||||
// Timestamp for the composition frame clocks (recomposer + withFrameNanos animations).
|
||||
private fun frameClockNanos(): Long =
|
||||
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicksNS().toLong()
|
||||
|
||||
// Timestamp for the owner's node-animation clock - real path keeps the pre-existing
|
||||
// ms-resolution SDL_GetTicks base so non-screenshot behaviour is bit-for-bit unchanged.
|
||||
private fun animationClockNanos(): Long =
|
||||
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicks().toLong() * 1_000_000L
|
||||
|
||||
// The window currently inside renderFrame - the loop is single-threaded, so a plain
|
||||
// var is enough for onFrame probes to address "the window I'm being called for".
|
||||
private var renderingWindow: WindowInstance? = null
|
||||
|
||||
/** From inside an onFrame callback: true while the just-rendered window still has pending
|
||||
work (recomposition, layout/draw invalidation, or an animation awaiting the next frame).
|
||||
Screenshot probes capture once this stays false for a few consecutive frames instead of
|
||||
at a fixed frame count. Outside onFrame it answers false. */
|
||||
fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: false
|
||||
|
||||
/** Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
|
||||
their native memory (see the main loop's native-memory nudge). */
|
||||
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
|
||||
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
|
||||
|
||||
/** Frame interval (ms) of the window's current display, for the non-vsync
|
||||
fallback pacing (SDL_Delay). Falls back to 16ms (~60Hz) when the mode can't
|
||||
be read, so a 144Hz panel on a non-vsync path isn't capped to 60. */
|
||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
private fun displayFrameDelayMs(window: COpaquePointer?): UInt {
|
||||
val vWindow = window ?: return 16u
|
||||
val vDisplay = SDL_GetDisplayForWindow(vWindow.reinterpret())
|
||||
val vMode = SDL_GetCurrentDisplayMode(vDisplay) ?: return 16u
|
||||
val vHz = vMode.pointed.refresh_rate
|
||||
return if (vHz > 0f) (1000f / vHz).toUInt().coerceAtLeast(1u) else 16u
|
||||
}
|
||||
|
||||
/** Single-window compatibility wrapper - the pre-multi-window entry point.
|
||||
Closing the window exits the app, exactly as before. */
|
||||
fun nativeComposeWindow(
|
||||
@@ -387,6 +324,14 @@ fun nativeComposeWindow(
|
||||
height: Int = 600,
|
||||
gpu: GpuMode = GpuMode.Auto,
|
||||
icon: AppWindowIcon? = null,
|
||||
undecorated: Boolean = false,
|
||||
transparent: Boolean = false,
|
||||
resizable: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
focusable: Boolean = true,
|
||||
alwaysOnTop: Boolean = false,
|
||||
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
|
||||
onKeyEvent: (KeyEvent) -> Boolean = { false },
|
||||
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
|
||||
content: @Composable ComposeWindowScope.() -> Unit,
|
||||
) {
|
||||
@@ -398,592 +343,16 @@ fun nativeComposeWindow(
|
||||
height = height,
|
||||
gpu = gpu,
|
||||
icon = icon,
|
||||
undecorated = undecorated,
|
||||
transparent = transparent,
|
||||
resizable = resizable,
|
||||
enabled = enabled,
|
||||
focusable = focusable,
|
||||
alwaysOnTop = alwaysOnTop,
|
||||
onPreviewKeyEvent = onPreviewKeyEvent,
|
||||
onKeyEvent = onKeyEvent,
|
||||
onFrame = onFrame,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: App runtime internals
|
||||
// ==================
|
||||
|
||||
internal class ApplicationScopeImpl(val runtime: AppRuntime) : ApplicationScope {
|
||||
override fun exitApplication() { runtime.exitRequested = true }
|
||||
}
|
||||
|
||||
/** Registry + lifecycle for the live windows. Windows are created by the app
|
||||
composition (Window()'s remember) and destroyed by the LOOP - DisposableEffect
|
||||
onDispose only schedules, because teardown disposes a composition and that
|
||||
must not run re-entrantly inside another composition's apply pass. */
|
||||
internal class AppRuntime {
|
||||
lateinit var scope: CoroutineScope
|
||||
val windows = mutableListOf<WindowInstance>()
|
||||
private val destroyQueue = mutableListOf<WindowInstance>()
|
||||
var exitRequested = false
|
||||
var hadWindow = false
|
||||
|
||||
fun windowFor(inId: UInt): WindowInstance? =
|
||||
if (inId == 0u) windows.firstOrNull()
|
||||
else windows.firstOrNull { it.backend.windowId == inId } ?: windows.firstOrNull()
|
||||
|
||||
fun markAllNeedFrame() {
|
||||
for (vW in windows) vW.needsFrame = true
|
||||
}
|
||||
|
||||
fun createWindow(
|
||||
inTitle: String,
|
||||
inWidth: Int,
|
||||
inHeight: Int,
|
||||
inGpu: GpuMode,
|
||||
inIcon: AppWindowIcon?,
|
||||
inOnFrame: ((RenderBackend, Int) -> Boolean)?,
|
||||
inContent: () -> (@Composable ComposeWindowScope.() -> Unit),
|
||||
): WindowInstance {
|
||||
val vWindow = WindowInstance(inTitle, inWidth, inHeight, inGpu, inIcon, inOnFrame, inContent)
|
||||
// A Window() was declared either way - the "exit when the last window
|
||||
// is gone" rule must also fire when every declared window failed to
|
||||
// initialise (otherwise the loop would spin forever with none).
|
||||
hadWindow = true
|
||||
if (vWindow.init(scope)) {
|
||||
windows.add(vWindow)
|
||||
} else {
|
||||
println("nativeComposeApp: window '$inTitle' failed to initialise")
|
||||
}
|
||||
return vWindow
|
||||
}
|
||||
|
||||
fun scheduleDestroy(inWindow: WindowInstance) {
|
||||
if (inWindow in windows && inWindow !in destroyQueue) destroyQueue.add(inWindow)
|
||||
}
|
||||
|
||||
fun reapDestroyed() {
|
||||
while (destroyQueue.isNotEmpty()) {
|
||||
val vW = destroyQueue.removeLast()
|
||||
windows.remove(vW)
|
||||
vW.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No-op applier for the app-level composition - it emits no UI nodes, only
|
||||
// side effects (each Window() manages a native window).
|
||||
private class UnitApplier : AbstractApplier<Unit>(Unit) {
|
||||
override fun insertTopDown(index: Int, instance: Unit) {}
|
||||
override fun insertBottomUp(index: Int, instance: Unit) {}
|
||||
override fun remove(index: Int, count: Int) {}
|
||||
override fun move(from: Int, to: Int, count: Int) {}
|
||||
override fun onClear() {}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: WindowInstance - one SDL window + renderer + composition
|
||||
// ==================
|
||||
|
||||
internal class WindowInstance(
|
||||
private val initialTitle: String,
|
||||
inWidth: Int,
|
||||
inHeight: Int,
|
||||
inGpu: GpuMode,
|
||||
inIcon: AppWindowIcon?,
|
||||
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
|
||||
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
|
||||
) {
|
||||
// Resolved renderer mode. `var` so init() can fall back GPU → CPU raster when
|
||||
// a Skia GPU context can't be created (RDP / headless / missing GL driver).
|
||||
private var gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||
private val initialWidth = inWidth
|
||||
private val initialHeight = inHeight
|
||||
private val icon = inIcon
|
||||
// Assigned by init() from the first backend that comes up (see makeBackend()).
|
||||
lateinit var backend: SDL3Backend
|
||||
private set
|
||||
private var renderBackend: RenderBackend? = null
|
||||
lateinit var host: ComposeRootHost
|
||||
private set
|
||||
lateinit var facade: ComposeNativeWindow
|
||||
private set
|
||||
private var popupHost: com.compose.sdl.window.PopupHostState? = null
|
||||
|
||||
val frameClock = SDL3FrameClock()
|
||||
private var recomposer: Recomposer? = null
|
||||
private var recomposeJob: Job? = null
|
||||
private var composition: Composition? = null
|
||||
|
||||
// Set by Window(); the compat wrapper points it at exitApplication().
|
||||
var onCloseRequest: () -> Unit = {}
|
||||
var closeDispatched = false
|
||||
|
||||
// Render-on-demand flag (see the pre-multi-window loop): set by events,
|
||||
// state writes (global observer), resize, expose.
|
||||
var needsFrame = true
|
||||
|
||||
// Hover refresh: last pointer position in PIXEL space, re-dispatched as a
|
||||
// synthetic Move after layout so items scrolled under the cursor hover.
|
||||
private var lastMouseX = -1f
|
||||
private var lastMouseY = -1f
|
||||
private var hasMousePos = false
|
||||
|
||||
private var frameIndex = 0
|
||||
private var fpsEma = 0.0
|
||||
private var fpsLastFrameMs = SDL_GetTicks()
|
||||
private var fpsLastTitleMs = 0uL
|
||||
|
||||
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
|
||||
// Escape completes a back navigation on THIS window's dispatcher.
|
||||
private val navigationEventOwner = object : androidx.navigationevent.NavigationEventDispatcherOwner {
|
||||
override val navigationEventDispatcher = androidx.navigationevent.NavigationEventDispatcher()
|
||||
}
|
||||
private val backNavigationInput = BackNavigationInput()
|
||||
|
||||
// WINDOW-scoped architecture-components owner (Lifecycle + ViewModelStore +
|
||||
// SavedStateRegistry) - the same trio upstream desktop's
|
||||
// DefaultArchitectureComponentsOwner supplies through the window
|
||||
// PlatformContext (compose/ui skikoMain PlatformOwnerProvider.skiko.kt).
|
||||
// viewModel(), SavedStateHandle plumbing and navigation3's
|
||||
// rememberViewModelStoreNavEntryDecorator all resolve their parent owners
|
||||
// from this; destroy() moves it to DESTROYED and clears the store.
|
||||
private val architectureOwner = WindowArchitectureOwner()
|
||||
|
||||
// Window focus / visibility → Lifecycle.State, Compose Desktop's mapping:
|
||||
// focused → RESUMED, visible unfocused → STARTED, hidden/minimised →
|
||||
// CREATED. Starts focused+visible (SDL fires FOCUS_GAINED right after
|
||||
// creation anyway; headless probe runs simply stay RESUMED).
|
||||
private var windowFocused = true
|
||||
private var windowVisible = true
|
||||
|
||||
fun onActivationEvent(inEvent: AppEvent.WindowActivation) {
|
||||
inEvent.focused?.let { windowFocused = it }
|
||||
inEvent.visible?.let { windowVisible = it }
|
||||
if (::host.isInitialized) host.setWindowFocused(windowFocused)
|
||||
architectureOwner.setLifecycleState(
|
||||
when {
|
||||
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
|
||||
windowFocused -> androidx.lifecycle.Lifecycle.State.RESUMED
|
||||
else -> androidx.lifecycle.Lifecycle.State.STARTED
|
||||
}
|
||||
)
|
||||
// Shown / restored / focus-gained also invalidate the contents - keep
|
||||
// the pre-lifecycle RedrawNeeded behaviour of these SDL events.
|
||||
needsFrame = true
|
||||
}
|
||||
|
||||
// Creates + initialises an SDL3Backend for [mode] and its RenderBackend. On
|
||||
// success commits `backend` and returns the renderer; else tears everything
|
||||
// down and returns null so the caller can retry with a different mode.
|
||||
private fun makeBackend(mode: GpuMode): RenderBackend? {
|
||||
val vBackend = SDL3Backend(
|
||||
initialTitle, initialWidth, initialHeight, gpuMode = mode,
|
||||
iconLightResourcePaths = icon?.light ?: emptyList(),
|
||||
iconDarkResourcePaths = icon?.dark ?: emptyList(),
|
||||
)
|
||||
if (!vBackend.init()) {
|
||||
vBackend.destroy(inQuitSdl = false)
|
||||
return null
|
||||
}
|
||||
vBackend.updateWindowSize()
|
||||
val vRender = createRenderBackend(vBackend, mode)
|
||||
if (vRender == null || !vRender.ensureSize(vBackend.pixelWidth, vBackend.pixelHeight)) {
|
||||
vRender?.destroy()
|
||||
vBackend.destroy(inQuitSdl = false)
|
||||
return null
|
||||
}
|
||||
backend = vBackend
|
||||
return vRender
|
||||
}
|
||||
|
||||
fun init(inScope: CoroutineScope): Boolean {
|
||||
// Try the resolved gpuMode; if it's a Skia GPU mode whose context/bridge
|
||||
// can't come up (RDP / headless / missing GL driver), fall back once to
|
||||
// CPU raster (Software) so the window still opens instead of failing.
|
||||
var vRender = makeBackend(gpuMode)
|
||||
if (vRender == null && gpuMode is GpuMode.Skia) {
|
||||
println("GPU renderer ($gpuMode) unavailable - falling back to CPU raster (Software)")
|
||||
gpuMode = GpuMode.Software
|
||||
vRender = makeBackend(gpuMode)
|
||||
}
|
||||
if (vRender == null) {
|
||||
println("Failed to init render backend")
|
||||
return false
|
||||
}
|
||||
renderBackend = vRender
|
||||
|
||||
host = ComposeRootHost(inDensity = backend.pixelDensity)
|
||||
host.attach()
|
||||
// A layer whose content changed (OwnedLayer.invalidate) schedules a frame
|
||||
// even when nothing else (recompose / relayout) is pending - retained layers
|
||||
// need this so a draw-only state change still repaints.
|
||||
host.setInvalidationCallback { needsFrame = true }
|
||||
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
|
||||
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
|
||||
|
||||
// The render-bridge globals must point at THIS window's renderer while
|
||||
// its content composes/measures (first composition happens inside
|
||||
// setContent below).
|
||||
installGlobals()
|
||||
|
||||
// Effect context for this window's composition - the screenshot flag injects the
|
||||
// infinite-animation-cancelling policy (see disableInfiniteAnimations above).
|
||||
var vEffectContext = inScope.coroutineContext + frameClock
|
||||
if (disableInfiniteAnimations) vEffectContext += CancelInfiniteAnimationsPolicy
|
||||
val vRecomposer = Recomposer(vEffectContext)
|
||||
recomposer = vRecomposer
|
||||
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
|
||||
val vComposition = Composition(host.applier, vRecomposer)
|
||||
composition = vComposition
|
||||
|
||||
val vUriHandler = object : androidx.compose.ui.platform.UriHandler {
|
||||
override fun openUri(uri: String) { openUrl(uri) }
|
||||
}
|
||||
val vPopupHost = createPopupHostState()
|
||||
popupHost = vPopupHost
|
||||
val vWindowScope = object : ComposeWindowScope {
|
||||
override val window: ComposeNativeWindow = facade
|
||||
}
|
||||
|
||||
vComposition.setContent {
|
||||
CompositionLocalProvider(
|
||||
LocalComposeNativeWindow provides facade,
|
||||
LocalPopupHost provides vPopupHost,
|
||||
// Upstream vendored CompositionLocals.kt declares each of these as
|
||||
// `staticCompositionLocalOf<T> { noLocalProvidedFor("…") }` - reading
|
||||
// one without a Provider throws. Seed them all from the ComposeOwner.
|
||||
androidx.compose.ui.platform.LocalDensity provides host.density,
|
||||
androidx.compose.ui.platform.LocalLayoutDirection provides host.layoutDirection,
|
||||
androidx.compose.ui.platform.LocalFocusManager provides host.focusManager,
|
||||
androidx.compose.ui.platform.LocalGraphicsContext provides host.graphicsContext,
|
||||
androidx.compose.ui.platform.LocalViewConfiguration provides host.viewConfiguration,
|
||||
androidx.compose.ui.platform.LocalInputModeManager provides host.inputModeManager,
|
||||
androidx.compose.ui.platform.LocalHapticFeedback provides host.hapticFeedback,
|
||||
androidx.compose.ui.platform.LocalTextToolbar provides host.textToolbar,
|
||||
androidx.compose.ui.platform.LocalWindowInfo provides host.windowInfo,
|
||||
androidx.compose.ui.platform.LocalSoftwareKeyboardController provides host.softwareKeyboardController,
|
||||
androidx.compose.ui.platform.LocalClipboard provides
|
||||
androidx.compose.ui.platform.platformClipboard(),
|
||||
androidx.compose.ui.platform.LocalFontFamilyResolver provides
|
||||
com.compose.sdl.text.font.projectFontFamilyResolver,
|
||||
androidx.compose.ui.platform.LocalUriHandler provides vUriHandler,
|
||||
// Desktop windows have no system bars / notch / IME insets - the
|
||||
// interface's all-zero defaults are exactly right.
|
||||
androidx.compose.ui.platform.LocalPlatformWindowInsets provides
|
||||
object : androidx.compose.ui.platform.PlatformWindowInsets {},
|
||||
// The window's architecture-components owner backs all three arch
|
||||
// locals, exactly like upstream desktop's window PlatformContext:
|
||||
// - LocalLifecycleOwner: RESUMED for the window's whole life;
|
||||
// lifecycle-aware content (NavDisplay, rememberLifecycleOwner, …)
|
||||
// errors without it.
|
||||
// - LocalViewModelStoreOwner: window-scoped ViewModels (google
|
||||
// lifecycle-viewmodel-compose's local is a plain composition
|
||||
// local; the JB HostDefault route doesn't exist in the google
|
||||
// artifacts this port ships).
|
||||
// - LocalSavedStateRegistryOwner: SavedStateHandle / rememberSaveable
|
||||
// registry parent (nav3's ViewModelStoreNavEntryDecorator requires it).
|
||||
androidx.lifecycle.compose.LocalLifecycleOwner provides architectureOwner,
|
||||
androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner provides architectureOwner,
|
||||
androidx.savedstate.compose.LocalSavedStateRegistryOwner provides architectureOwner,
|
||||
// Runtime-level host defaults - this window's navigation-event
|
||||
// owner (SearchBar / sheets back plumbing). The viewmodel store
|
||||
// is provided through the plain LocalViewModelStoreOwner above
|
||||
// instead: the google lifecycle artifacts this port ships have no
|
||||
// HostDefault key for it (that mechanism is JB-variant-only).
|
||||
androidx.compose.runtime.LocalHostDefaultProvider provides
|
||||
remember {
|
||||
object : androidx.compose.runtime.HostDefaultProvider {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T> getHostDefault(key: androidx.compose.runtime.HostDefaultKey<T>): T = when (key) {
|
||||
androidx.navigationevent.compose.NavigationEventDispatcherOwnerHostDefaultKey -> navigationEventOwner
|
||||
else -> null
|
||||
} as T
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Seed the :ui-internal LocalPointerIconService so Modifier.pointerHoverIcon
|
||||
// drives the SDL cursor (the local + service type can't cross the module boundary).
|
||||
host.ProvidePointerIconService {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Read through the holder so Window() recompositions with a
|
||||
// new content lambda propagate into this composition.
|
||||
val vContent = contentHolder()
|
||||
with(vWindowScope) { vContent() }
|
||||
PopupLayer(vPopupHost)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// First composition done (setContent is synchronous) - promote the
|
||||
// window lifecycle from CREATED to the focus/visibility-derived state.
|
||||
// Composition itself runs at CREATED so enableSavedStateHandles()
|
||||
// callers see a legal state, mirroring upstream desktop's
|
||||
// compose-first-resume-after ordering.
|
||||
architectureOwner.setLifecycleState(
|
||||
when {
|
||||
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
|
||||
windowFocused -> androidx.lifecycle.Lifecycle.State.RESUMED
|
||||
else -> androidx.lifecycle.Lifecycle.State.STARTED
|
||||
}
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Points the render-bridge globals (text measurer / image loader /
|
||||
viewport) at this window's renderer. Must be called before composing,
|
||||
measuring, or drawing this window's tree. */
|
||||
fun installGlobals() {
|
||||
val vRender = renderBackend ?: return
|
||||
currentImageLoader = vRender.imageLoader
|
||||
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
|
||||
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
|
||||
// Register bundled generic fonts (FontFamily.Monospace → NotoSansMono) once
|
||||
// data.kres is loadable; idempotent, no-op if the font isn't bundled.
|
||||
com.compose.sdl.text.registerGenericFonts()
|
||||
// Position the OS IME candidate window the moment a field in THIS window
|
||||
// gains focus, not only on the first TEXT_EDITING event (repointed per
|
||||
// window here so multi-window focus targets the right SDL window).
|
||||
com.compose.sdl.text.input.ImeBridge.onSessionActiveChange = { active ->
|
||||
if (active) updateImeArea()
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Event handling (loop-routed, per window)
|
||||
|
||||
fun onPointerEvent(inEvent: AppEvent.Pointer) {
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
val vType = when (inEvent.event.type) {
|
||||
PointerEventType.Press -> 1
|
||||
PointerEventType.Release -> 2
|
||||
else -> 0
|
||||
}
|
||||
val vBtn = when (inEvent.event.button) {
|
||||
PointerButton.Secondary -> 1
|
||||
PointerButton.Tertiary -> 2
|
||||
else -> 0
|
||||
}
|
||||
// SDL3 delivers mouse coords in logical points on HiDPI - multiply by
|
||||
// DPR so hit-testing lands in the pixel space layout uses.
|
||||
val vDpr = backend.pixelDensity
|
||||
val vPx = inEvent.event.x * vDpr
|
||||
val vPy = inEvent.event.y * vDpr
|
||||
if (inEvent.event.type == PointerEventType.Press) {
|
||||
popupHost?.notifyOutsidePress(vPx.toInt(), vPy.toInt())
|
||||
}
|
||||
host.onPointerRaw(vPx, vPy, vType, vBtn, SDL_GetTicks().toLong())
|
||||
lastMouseX = vPx
|
||||
lastMouseY = vPy
|
||||
hasMousePos = true
|
||||
}
|
||||
|
||||
/** Pointer left the window (SDL_EVENT_WINDOW_MOUSE_LEAVE). Stop the per-frame
|
||||
synthetic hover re-dispatch and fire one Exit at the last position so a
|
||||
hovered widget clears its highlight instead of sticking forever. */
|
||||
fun onPointerExit() {
|
||||
needsFrame = true
|
||||
if (hasMousePos) {
|
||||
host.onPointerRaw(lastMouseX, lastMouseY, 3, 0, SDL_GetTicks().toLong())
|
||||
}
|
||||
hasMousePos = false
|
||||
}
|
||||
|
||||
fun onWheelEvent(inEvent: AppEvent.MouseWheel) {
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
val vDpr = backend.pixelDensity
|
||||
host.onWheel(inEvent.x * vDpr, inEvent.y * vDpr, inEvent.deltaX, inEvent.deltaY, SDL_GetTicks().toLong())
|
||||
}
|
||||
|
||||
fun onKeyEvent(inEvent: AppEvent.Key) {
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
// Focused chain → window-level shortcuts → back navigation (Escape).
|
||||
if (!host.dispatchKeyEvent(inEvent.event) && !facade.dispatchKeyShortcut(inEvent.event)) {
|
||||
backNavigationInput.onKeyEvent(inEvent.event)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTextInputEvent(inEvent: AppEvent.TextInput) {
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
// A focused text field runs an IME session (ComposeOwner.textInputSession):
|
||||
// commit through it so the text REPLACES any active composition. Falls back
|
||||
// to synthesising typed KeyEvents when no field is focused - SDL key events
|
||||
// carry UNSHIFTED keycodes (no uppercase/numpad/dead keys), so committed
|
||||
// text is the only layout-correct character source.
|
||||
if (!com.compose.sdl.text.input.ImeBridge.commit(inEvent.text)) {
|
||||
dispatchTypedText(host, inEvent.text)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTextEditingEvent(inEvent: AppEvent.TextEditing) {
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
// IME preedit: show the composing (underlined) region in the focused field.
|
||||
// No-op if no field is focused. Also nudge the OS candidate window to the
|
||||
// field's on-screen rect.
|
||||
com.compose.sdl.text.input.ImeBridge.compose(inEvent.text)
|
||||
updateImeArea()
|
||||
}
|
||||
|
||||
// Nudge the OS IME candidate window to the focused field's on-screen rect.
|
||||
// Best-effort: no-op without an active session or a laid-out field.
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
private fun updateImeArea() {
|
||||
val vRequest = com.compose.sdl.text.input.ImeBridge.request ?: return
|
||||
val vRect = vRequest.focusedRectInRoot() ?: return
|
||||
val vWindow = backend.window ?: return
|
||||
val vDpr = backend.pixelDensity
|
||||
kotlinx.cinterop.memScoped {
|
||||
// SDL wants the area in logical window points; layout runs in physical px.
|
||||
val vSdlRect = alloc<sdl3.SDL_Rect>()
|
||||
vSdlRect.x = (vRect.left / vDpr).toInt()
|
||||
vSdlRect.y = (vRect.top / vDpr).toInt()
|
||||
vSdlRect.w = (vRect.width / vDpr).toInt().coerceAtLeast(1)
|
||||
vSdlRect.h = (vRect.height / vDpr).toInt().coerceAtLeast(1)
|
||||
sdl3.SDL_SetTextInputArea(vWindow.reinterpret(), vSdlRect.ptr, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDropEvent(inEvent: AppEvent.Drop) {
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
// SDL fires drop coords in logical points at DPR-1; scale to the pixel
|
||||
// space layout runs in (Option-B density flow) so hit-testing lands
|
||||
// where the pointer is.
|
||||
val vDpr = backend.pixelDensity
|
||||
when (inEvent.phase) {
|
||||
AppEvent.DropPhase.BEGIN -> host.onDropBegin()
|
||||
AppEvent.DropPhase.POSITION -> host.onDropPosition(inEvent.x * vDpr, inEvent.y * vDpr)
|
||||
AppEvent.DropPhase.FILE -> inEvent.data?.let { host.onDropFile(it) }
|
||||
AppEvent.DropPhase.TEXT -> inEvent.data?.let { host.onDropText(it) }
|
||||
AppEvent.DropPhase.COMPLETE -> host.onDropComplete()
|
||||
}
|
||||
}
|
||||
|
||||
fun onResizedEvent() {
|
||||
needsFrame = true
|
||||
backend.updateWindowSize()
|
||||
facade.onResized()
|
||||
}
|
||||
|
||||
/** OS light/dark theme changed - re-pick the theme-appropriate window icon
|
||||
(no-op if this window has no icon configured or the choice is unchanged). */
|
||||
fun onSystemThemeChanged() {
|
||||
backend.applyThemeIcon()
|
||||
}
|
||||
|
||||
/** OS close button / app-level Quit: honour the veto handler, then hand the
|
||||
decision to the Window() declaration. */
|
||||
fun requestClose() {
|
||||
if (closeDispatched) return
|
||||
if (facade.requestCloseFromUser()) {
|
||||
closeDispatched = true
|
||||
onCloseRequest()
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Frame pump
|
||||
|
||||
fun shouldRender(): Boolean =
|
||||
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
|
||||
|
||||
/** True while this window still has pending work: a state/layout/draw invalidation
|
||||
(needsFrame), recomposer work, or a composition/node animation awaiting the next
|
||||
frame. Sampled by windowHasInvalidations() from onFrame probes - the quiescence
|
||||
signal for render-to-settle screenshot capture. */
|
||||
fun hasInvalidations(): Boolean =
|
||||
needsFrame || recomposer?.hasPendingWork == true ||
|
||||
frameClock.hasAwaiters || host.hasAnimationAwaiters()
|
||||
|
||||
fun renderFrame() {
|
||||
val vRender = renderBackend ?: return
|
||||
needsFrame = false
|
||||
if (recomposer?.hasPendingWork == true) needsFrame = true
|
||||
|
||||
backend.updateWindowSize()
|
||||
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
|
||||
// Charge the GPU back-buffer acquire to its own phase. On Metal this is
|
||||
// where nextDrawable() blocks on vsync (the natural frame pacing), so
|
||||
// folding it into "layout" made the profiler read the vsync wait as
|
||||
// layout cost - a ~6-7ms phantom that hid the real (tiny) layout time.
|
||||
FrameProfiler.phase(" acquire")
|
||||
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
|
||||
// Deliver any state written by this iteration's frame-clock continuations
|
||||
// (withFrameNanos animations - notably smooth wheel scrolling) BEFORE we lay
|
||||
// out. Those writes happen in the per-window pump's sendFrame()/yield(), which
|
||||
// is AFTER the loop's last sendApplyNotifications() - so without this the
|
||||
// relayout they trigger wasn't registered until the next frame, and the frame
|
||||
// drew last frame's positions. That one-frame trail read as "the clip is a
|
||||
// frame late" when scrolling (content briefly drawn past the viewport edge).
|
||||
Snapshot.sendApplyNotifications()
|
||||
host.measureAndLayout()
|
||||
FrameProfiler.phase(" layout")
|
||||
|
||||
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
|
||||
if (hasMousePos) {
|
||||
host.onPointerRaw(lastMouseX, lastMouseY, 0, 0, SDL_GetTicks().toLong())
|
||||
}
|
||||
|
||||
vRender.beginFrame(1f)
|
||||
vRender.drawRoot { canvas -> host.drawRoot(canvas) }
|
||||
if (onFrame != null) {
|
||||
renderingWindow = this
|
||||
val vContinue = onFrame.invoke(vRender, frameIndex)
|
||||
renderingWindow = null
|
||||
// Probe consumers end the app when their scenario completes.
|
||||
if (!vContinue) facade.close()
|
||||
}
|
||||
FrameProfiler.phase(" draw")
|
||||
vRender.endFrame()
|
||||
FrameProfiler.phase(" present")
|
||||
frameIndex++
|
||||
|
||||
// FPS - instantaneous inter-frame rate, EMA-smoothed, title refreshed
|
||||
// ~4x/sec. This shows within ~2 rendered frames of ANY activity rather
|
||||
// than waiting for a full second of unbroken rendering to accumulate
|
||||
// (the old fixed-window counter never got its first update: bursty
|
||||
// interaction kept idling before 1s elapsed, and the idle-skip reset the
|
||||
// window each time - so FPS only appeared during a long enough page
|
||||
// transition). A dt outside 1..100ms is the first frame or a resume from
|
||||
// idle (the gap isn't a real frame interval), so it's not sampled - the
|
||||
// title just holds the last active rate while idle.
|
||||
val vNowMs = SDL_GetTicks()
|
||||
val vDt = (vNowMs - fpsLastFrameMs).toInt()
|
||||
fpsLastFrameMs = vNowMs
|
||||
if (vDt in 1..100) {
|
||||
val vInst = 1000.0 / vDt
|
||||
fpsEma = if (fpsEma <= 0.0) vInst else fpsEma * 0.9 + vInst * 0.1
|
||||
if ((vNowMs - fpsLastTitleMs).toInt() >= 250) {
|
||||
val vFps = (fpsEma + 0.5).toInt()
|
||||
facade.updateFps(vFps)
|
||||
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
|
||||
fpsLastTitleMs = vNowMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idle-skip calls this: drop the frame timer so the resume-from-idle frame's
|
||||
// dt (the whole idle gap) isn't sampled as a real interval. The EMA is left
|
||||
// intact so the title keeps showing the last active rate while idle.
|
||||
fun resetFpsWindow() {
|
||||
fpsLastFrameMs = SDL_GetTicks()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
composition?.dispose()
|
||||
composition = null
|
||||
// Window gone → DESTROYED lifecycle + ViewModels cleared (onCleared
|
||||
// runs), matching upstream desktop's window-scoped owner lifetime.
|
||||
architectureOwner.destroy()
|
||||
recomposer?.cancel()
|
||||
recomposer = null
|
||||
recomposeJob?.cancel()
|
||||
recomposeJob = null
|
||||
renderBackend?.destroy()
|
||||
renderBackend = null
|
||||
backend.destroy(inQuitSdl = false)
|
||||
}
|
||||
}
|
||||
|
||||
// WindowArchitectureOwner -> WindowArchitectureOwner.kt
|
||||
// BackNavigationInput / dispatchTypedText -> WindowInputHelpers.kt
|
||||
|
||||
+26
-26
@@ -18,56 +18,56 @@ import sdl3.SDL_GetTicks
|
||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
internal object FrameProfiler {
|
||||
// null until first checked; then true/false for the run's lifetime.
|
||||
private var fEnabled: Boolean? = null
|
||||
private var mEnabled: Boolean? = null
|
||||
// Output file - resolved once from CDN_PROFILE. Writing to a file (not
|
||||
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
|
||||
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
|
||||
// the cwd; CDN_PROFILE=<path> → that path.
|
||||
private var fPath: String = "cdn_profile.log"
|
||||
private var mPath: String = "cdn_profile.log"
|
||||
val enabled: Boolean
|
||||
get() = fEnabled ?: run {
|
||||
get() = mEnabled ?: run {
|
||||
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
|
||||
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
|
||||
(vEnv != null).also { fEnabled = it }
|
||||
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) mPath = vEnv
|
||||
(vEnv != null).also { mEnabled = it }
|
||||
}
|
||||
|
||||
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
|
||||
private val mFreq = SDL_GetPerformanceFrequency().toDouble()
|
||||
// Insertion-ordered so the printed line follows the call order.
|
||||
private val fSum = LinkedHashMap<String, Double>()
|
||||
private val fMax = LinkedHashMap<String, Double>()
|
||||
private var fFrames = 0
|
||||
private var fLastPrintMs = SDL_GetTicks()
|
||||
private var fMark = 0uL
|
||||
private val mSum = LinkedHashMap<String, Double>()
|
||||
private val mMax = LinkedHashMap<String, Double>()
|
||||
private var mFrames = 0
|
||||
private var mLastPrintMs = SDL_GetTicks()
|
||||
private var mMark = 0uL
|
||||
|
||||
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
|
||||
fun mark() { if (enabled) mMark = SDL_GetPerformanceCounter() }
|
||||
|
||||
fun phase(inName: String) {
|
||||
if (!enabled) return
|
||||
val vNow = SDL_GetPerformanceCounter()
|
||||
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
|
||||
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
|
||||
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
|
||||
fMark = vNow
|
||||
val vMs = (vNow - mMark).toDouble() * 1000.0 / mFreq
|
||||
mSum[inName] = (mSum[inName] ?: 0.0) + vMs
|
||||
if (vMs > (mMax[inName] ?: 0.0)) mMax[inName] = vMs
|
||||
mMark = vNow
|
||||
}
|
||||
|
||||
fun frameDone(inRendered: Boolean) {
|
||||
if (!enabled) return
|
||||
if (inRendered) fFrames++
|
||||
if (inRendered) mFrames++
|
||||
val vNowMs = SDL_GetTicks()
|
||||
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
|
||||
val vParts = fSum.keys.map { vName ->
|
||||
val vAvg = (fSum[vName] ?: 0.0) / fFrames
|
||||
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
|
||||
if (vNowMs - mLastPrintMs >= 2000u && mFrames > 0) {
|
||||
val vParts = mSum.keys.map { vName ->
|
||||
val vAvg = (mSum[vName] ?: 0.0) / mFrames
|
||||
"$vName=${(vAvg * 100).toInt() / 100.0}/${((mMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
|
||||
}
|
||||
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + "\n"
|
||||
val vFile = platform.posix.fopen(fPath, "a")
|
||||
val vLine = "[profile] frames=$mFrames avg/max " + vParts.joinToString(" ") + "\n"
|
||||
val vFile = platform.posix.fopen(mPath, "a")
|
||||
if (vFile != null) {
|
||||
platform.posix.fputs(vLine, vFile)
|
||||
platform.posix.fclose(vFile)
|
||||
}
|
||||
fSum.clear(); fMax.clear()
|
||||
fFrames = 0
|
||||
fLastPrintMs = vNowMs
|
||||
mSum.clear(); mMax.clear()
|
||||
mFrames = 0
|
||||
mLastPrintMs = vNowMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -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
|
||||
}
|
||||
+7
-7
@@ -36,16 +36,16 @@ import sdl3.SDL_GetCurrentThreadID
|
||||
writes land at the loop's drainPending(), observable on the same frame. */
|
||||
internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
|
||||
|
||||
private val fQueue = Channel<Runnable>(Channel.UNLIMITED)
|
||||
private val mQueue = Channel<Runnable>(Channel.UNLIMITED)
|
||||
|
||||
// The SDL main thread - the dispatcher is constructed by nativeComposeApp
|
||||
// on the thread that then runs the loop.
|
||||
private val fMainThreadId = SDL_GetCurrentThreadID()
|
||||
private val mMainThreadId = SDL_GetCurrentThreadID()
|
||||
|
||||
override val immediate: MainCoroutineDispatcher = ImmediateDispatcher()
|
||||
|
||||
override fun dispatch(context: CoroutineContext, block: Runnable) {
|
||||
fQueue.trySend(block)
|
||||
mQueue.trySend(block)
|
||||
}
|
||||
|
||||
/** Drains all pending tasks. Called from the SDL3 main loop once per
|
||||
@@ -54,7 +54,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
|
||||
composition. */
|
||||
fun drainPending() {
|
||||
while (true) {
|
||||
val vTask = fQueue.tryReceive().getOrNull() ?: break
|
||||
val vTask = mQueue.tryReceive().getOrNull() ?: break
|
||||
runCatching { vTask.run() }.onFailure { vErr ->
|
||||
println("Sdl3MainDispatcher: dispatched task threw: ${vErr.message}")
|
||||
}
|
||||
@@ -64,7 +64,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
|
||||
/** Closes the queue. After this, dispatch() drops new posts silently.
|
||||
Called from nativeComposeWindow as part of shutdown. */
|
||||
fun close() {
|
||||
fQueue.close()
|
||||
mQueue.close()
|
||||
}
|
||||
|
||||
override fun toString(): String = "Sdl3MainDispatcher"
|
||||
@@ -75,10 +75,10 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
|
||||
override val immediate: MainCoroutineDispatcher get() = this
|
||||
|
||||
override fun isDispatchNeeded(context: CoroutineContext): Boolean =
|
||||
SDL_GetCurrentThreadID() != fMainThreadId
|
||||
SDL_GetCurrentThreadID() != mMainThreadId
|
||||
|
||||
override fun dispatch(context: CoroutineContext, block: Runnable) {
|
||||
fQueue.trySend(block)
|
||||
mQueue.trySend(block)
|
||||
}
|
||||
|
||||
override fun toString(): String = "Sdl3MainDispatcher.immediate"
|
||||
|
||||
+31
@@ -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,
|
||||
)
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
@file:OptIn(
|
||||
androidx.compose.ui.InternalComposeUiApi::class,
|
||||
androidx.compose.runtime.InternalComposeApi::class,
|
||||
)
|
||||
|
||||
package com.compose.sdl
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Composition
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Recomposer
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.pointer.PointerButton
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import com.compose.sdl.node.ComposeRootHost
|
||||
import com.compose.sdl.res.currentImageLoader
|
||||
import com.compose.sdl.window.LocalPopupHost
|
||||
import com.compose.sdl.window.PopupLayer
|
||||
import com.compose.sdl.window.createPopupHostState
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import sdl3.SDL_GetTicks
|
||||
import sdl3.SDL_SetWindowTitle
|
||||
|
||||
// ==================
|
||||
// MARK: WindowInstance - one SDL window + renderer + composition
|
||||
// ==================
|
||||
|
||||
internal class WindowInstance(
|
||||
private val initialTitle: String,
|
||||
inWidth: Int,
|
||||
inHeight: Int,
|
||||
inGpu: GpuMode,
|
||||
inIcon: AppWindowIcon?,
|
||||
private val attrs: WindowAttributes,
|
||||
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
|
||||
// Window-level key handlers, read through holders so Window() recompositions
|
||||
// with new lambdas take effect without recreating the window.
|
||||
private val previewKeyHandler: () -> ((KeyEvent) -> Boolean),
|
||||
private val keyHandler: () -> ((KeyEvent) -> Boolean),
|
||||
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
|
||||
) {
|
||||
// Resolved renderer mode. `var` so init() can fall back GPU → CPU raster when
|
||||
// a Skia GPU context can't be created (RDP / headless / missing GL driver).
|
||||
private var gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||
private val initialWidth = inWidth
|
||||
private val initialHeight = inHeight
|
||||
private val icon = inIcon
|
||||
// Assigned by init() from the first backend that comes up (see makeBackend()).
|
||||
lateinit var backend: SDL3Backend
|
||||
private set
|
||||
private var renderBackend: RenderBackend? = null
|
||||
lateinit var host: ComposeRootHost
|
||||
private set
|
||||
lateinit var facade: ComposeNativeWindow
|
||||
private set
|
||||
private var popupHost: com.compose.sdl.window.PopupHostState? = null
|
||||
|
||||
val frameClock = SDL3FrameClock()
|
||||
private var recomposer: Recomposer? = null
|
||||
private var recomposeJob: Job? = null
|
||||
private var composition: Composition? = null
|
||||
|
||||
// Set by Window(); the compat wrapper points it at exitApplication().
|
||||
var onCloseRequest: () -> Unit = {}
|
||||
var closeDispatched = false
|
||||
|
||||
// Render-on-demand flag (see the pre-multi-window loop): set by events,
|
||||
// state writes (global observer), resize, expose.
|
||||
var needsFrame = true
|
||||
|
||||
// Hover refresh: last pointer position in PIXEL space, re-dispatched as a
|
||||
// synthetic Move after layout so items scrolled under the cursor hover.
|
||||
private var lastMouseX = -1f
|
||||
private var lastMouseY = -1f
|
||||
private var hasMousePos = false
|
||||
|
||||
private var frameIndex = 0
|
||||
private var fpsEma = 0.0
|
||||
private var fpsLastFrameMs = SDL_GetTicks()
|
||||
private var fpsLastTitleMs = 0uL
|
||||
|
||||
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
|
||||
// Escape completes a back navigation on THIS window's dispatcher.
|
||||
private val navigationEventOwner = object : androidx.navigationevent.NavigationEventDispatcherOwner {
|
||||
override val navigationEventDispatcher = androidx.navigationevent.NavigationEventDispatcher()
|
||||
}
|
||||
private val backNavigationInput = BackNavigationInput()
|
||||
|
||||
// WINDOW-scoped architecture-components owner (Lifecycle + ViewModelStore +
|
||||
// SavedStateRegistry) - the same trio upstream desktop's
|
||||
// DefaultArchitectureComponentsOwner supplies through the window
|
||||
// PlatformContext (compose/ui skikoMain PlatformOwnerProvider.skiko.kt).
|
||||
// viewModel(), SavedStateHandle plumbing and navigation3's
|
||||
// rememberViewModelStoreNavEntryDecorator all resolve their parent owners
|
||||
// from this; destroy() moves it to DESTROYED and clears the store.
|
||||
private val architectureOwner = WindowArchitectureOwner()
|
||||
|
||||
// Window focus / visibility → Lifecycle.State, Compose Desktop's mapping:
|
||||
// focused → RESUMED, visible unfocused → STARTED, hidden/minimised →
|
||||
// CREATED. Starts focused+visible (SDL fires FOCUS_GAINED right after
|
||||
// creation anyway; headless probe runs simply stay RESUMED).
|
||||
private var windowFocused = true
|
||||
private var windowVisible = true
|
||||
|
||||
fun onActivationEvent(inEvent: AppEvent.WindowActivation) {
|
||||
inEvent.focused?.let { windowFocused = it }
|
||||
inEvent.visible?.let { windowVisible = it }
|
||||
if (::host.isInitialized) host.setWindowFocused(windowFocused)
|
||||
architectureOwner.setLifecycleState(
|
||||
when {
|
||||
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
|
||||
windowFocused -> androidx.lifecycle.Lifecycle.State.RESUMED
|
||||
else -> androidx.lifecycle.Lifecycle.State.STARTED
|
||||
}
|
||||
)
|
||||
// Shown / restored / focus-gained also invalidate the contents - keep
|
||||
// the pre-lifecycle RedrawNeeded behaviour of these SDL events.
|
||||
needsFrame = true
|
||||
}
|
||||
|
||||
// Creates + initialises an SDL3Backend for [mode] and its RenderBackend. On
|
||||
// success commits `backend` and returns the renderer; else tears everything
|
||||
// down and returns null so the caller can retry with a different mode.
|
||||
private fun makeBackend(mode: GpuMode): RenderBackend? {
|
||||
val vBackend = SDL3Backend(
|
||||
initialTitle, initialWidth, initialHeight, gpuMode = mode,
|
||||
iconLightResourcePaths = icon?.light ?: emptyList(),
|
||||
iconDarkResourcePaths = icon?.dark ?: emptyList(),
|
||||
undecorated = attrs.undecorated,
|
||||
transparent = attrs.transparent,
|
||||
resizable = attrs.resizable,
|
||||
alwaysOnTop = attrs.alwaysOnTop,
|
||||
focusable = attrs.focusable,
|
||||
)
|
||||
if (!vBackend.init()) {
|
||||
vBackend.destroy(inQuitSdl = false)
|
||||
return null
|
||||
}
|
||||
vBackend.updateWindowSize()
|
||||
val vRender = createRenderBackend(vBackend, mode)
|
||||
if (vRender == null || !vRender.ensureSize(vBackend.pixelWidth, vBackend.pixelHeight)) {
|
||||
vRender?.destroy()
|
||||
vBackend.destroy(inQuitSdl = false)
|
||||
return null
|
||||
}
|
||||
backend = vBackend
|
||||
return vRender
|
||||
}
|
||||
|
||||
fun init(inScope: CoroutineScope): Boolean {
|
||||
// Try the resolved gpuMode; if it's a Skia GPU mode whose context/bridge
|
||||
// can't come up (RDP / headless / missing GL driver), fall back once to
|
||||
// CPU raster (Software) so the window still opens instead of failing.
|
||||
var vRender = makeBackend(gpuMode)
|
||||
if (vRender == null && gpuMode.isGpuAccelerated) {
|
||||
println("GPU renderer ($gpuMode) unavailable - falling back to CPU raster (Software)")
|
||||
gpuMode = GpuMode.CpuRaster
|
||||
vRender = makeBackend(gpuMode)
|
||||
}
|
||||
if (vRender == null) {
|
||||
println("Failed to init render backend")
|
||||
return false
|
||||
}
|
||||
renderBackend = vRender
|
||||
|
||||
host = ComposeRootHost(inDensity = backend.pixelDensity)
|
||||
host.attach()
|
||||
// A layer whose content changed (OwnedLayer.invalidate) schedules a frame
|
||||
// even when nothing else (recompose / relayout) is pending - retained layers
|
||||
// need this so a draw-only state change still repaints.
|
||||
host.setInvalidationCallback { needsFrame = true }
|
||||
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
|
||||
// Mirror the creation flags into the facade so isResizable & co. read true
|
||||
// from the start and a later Window() update only fires on a real change.
|
||||
applyAttributes(attrs)
|
||||
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
|
||||
|
||||
// The render-bridge globals must point at THIS window's renderer while
|
||||
// its content composes/measures (first composition happens inside
|
||||
// setContent below).
|
||||
installGlobals()
|
||||
|
||||
// Effect context for this window's composition - the screenshot flag injects the
|
||||
// infinite-animation-cancelling policy (see disableInfiniteAnimations above).
|
||||
var vEffectContext = inScope.coroutineContext + frameClock
|
||||
if (disableInfiniteAnimations) vEffectContext += CancelInfiniteAnimationsPolicy
|
||||
val vRecomposer = Recomposer(vEffectContext)
|
||||
recomposer = vRecomposer
|
||||
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
|
||||
val vComposition = Composition(host.applier, vRecomposer)
|
||||
composition = vComposition
|
||||
|
||||
val vUriHandler = object : androidx.compose.ui.platform.UriHandler {
|
||||
override fun openUri(uri: String) { openUrl(uri) }
|
||||
}
|
||||
val vPopupHost = createPopupHostState()
|
||||
popupHost = vPopupHost
|
||||
val vWindowScope = object : ComposeWindowScope {
|
||||
override val window: ComposeNativeWindow = facade
|
||||
}
|
||||
|
||||
vComposition.setContent {
|
||||
CompositionLocalProvider(
|
||||
LocalComposeNativeWindow provides facade,
|
||||
LocalPopupHost provides vPopupHost,
|
||||
// Upstream vendored CompositionLocals.kt declares each of these as
|
||||
// `staticCompositionLocalOf<T> { noLocalProvidedFor("…") }` - reading
|
||||
// one without a Provider throws. Seed them all from the ComposeOwner.
|
||||
androidx.compose.ui.platform.LocalDensity provides host.density,
|
||||
androidx.compose.ui.platform.LocalLayoutDirection provides host.layoutDirection,
|
||||
androidx.compose.ui.platform.LocalFocusManager provides host.focusManager,
|
||||
androidx.compose.ui.platform.LocalGraphicsContext provides host.graphicsContext,
|
||||
androidx.compose.ui.platform.LocalViewConfiguration provides host.viewConfiguration,
|
||||
androidx.compose.ui.platform.LocalInputModeManager provides host.inputModeManager,
|
||||
androidx.compose.ui.platform.LocalHapticFeedback provides host.hapticFeedback,
|
||||
androidx.compose.ui.platform.LocalTextToolbar provides host.textToolbar,
|
||||
androidx.compose.ui.platform.LocalWindowInfo provides host.windowInfo,
|
||||
androidx.compose.ui.platform.LocalSoftwareKeyboardController provides host.softwareKeyboardController,
|
||||
androidx.compose.ui.platform.LocalClipboard provides
|
||||
androidx.compose.ui.platform.platformClipboard(),
|
||||
androidx.compose.ui.platform.LocalFontFamilyResolver provides
|
||||
com.compose.sdl.text.font.projectFontFamilyResolver,
|
||||
androidx.compose.ui.platform.LocalUriHandler provides vUriHandler,
|
||||
// Desktop windows have no system bars / notch / IME insets - the
|
||||
// interface's all-zero defaults are exactly right.
|
||||
androidx.compose.ui.platform.LocalPlatformWindowInsets provides
|
||||
object : androidx.compose.ui.platform.PlatformWindowInsets {},
|
||||
// The window's architecture-components owner backs all three arch
|
||||
// locals, exactly like upstream desktop's window PlatformContext:
|
||||
// - LocalLifecycleOwner: RESUMED for the window's whole life;
|
||||
// lifecycle-aware content (NavDisplay, rememberLifecycleOwner, …)
|
||||
// errors without it.
|
||||
// - LocalViewModelStoreOwner: window-scoped ViewModels (google
|
||||
// lifecycle-viewmodel-compose's local is a plain composition
|
||||
// local; the JB HostDefault route doesn't exist in the google
|
||||
// artifacts this port ships).
|
||||
// - LocalSavedStateRegistryOwner: SavedStateHandle / rememberSaveable
|
||||
// registry parent (nav3's ViewModelStoreNavEntryDecorator requires it).
|
||||
androidx.lifecycle.compose.LocalLifecycleOwner provides architectureOwner,
|
||||
androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner provides architectureOwner,
|
||||
androidx.savedstate.compose.LocalSavedStateRegistryOwner provides architectureOwner,
|
||||
// Runtime-level host defaults - this window's navigation-event
|
||||
// owner (SearchBar / sheets back plumbing). The viewmodel store
|
||||
// is provided through the plain LocalViewModelStoreOwner above
|
||||
// instead: the google lifecycle artifacts this port ships have no
|
||||
// HostDefault key for it (that mechanism is JB-variant-only).
|
||||
androidx.compose.runtime.LocalHostDefaultProvider provides
|
||||
remember {
|
||||
object : androidx.compose.runtime.HostDefaultProvider {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T> getHostDefault(key: androidx.compose.runtime.HostDefaultKey<T>): T = when (key) {
|
||||
androidx.navigationevent.compose.NavigationEventDispatcherOwnerHostDefaultKey -> navigationEventOwner
|
||||
else -> null
|
||||
} as T
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Seed the :ui-internal LocalPointerIconService so Modifier.pointerHoverIcon
|
||||
// drives the SDL cursor (the local + service type can't cross the module boundary).
|
||||
host.ProvidePointerIconService {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Read through the holder so Window() recompositions with a
|
||||
// new content lambda propagate into this composition.
|
||||
val vContent = contentHolder()
|
||||
with(vWindowScope) { vContent() }
|
||||
PopupLayer(vPopupHost)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// First composition done (setContent is synchronous) - promote the
|
||||
// window lifecycle from CREATED to the focus/visibility-derived state.
|
||||
// Composition itself runs at CREATED so enableSavedStateHandles()
|
||||
// callers see a legal state, mirroring upstream desktop's
|
||||
// compose-first-resume-after ordering.
|
||||
architectureOwner.setLifecycleState(
|
||||
when {
|
||||
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
|
||||
windowFocused -> androidx.lifecycle.Lifecycle.State.RESUMED
|
||||
else -> androidx.lifecycle.Lifecycle.State.STARTED
|
||||
}
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Points the render-bridge globals (text measurer / image loader /
|
||||
viewport) at this window's renderer. Must be called before composing,
|
||||
measuring, or drawing this window's tree. */
|
||||
fun installGlobals() {
|
||||
val vRender = renderBackend ?: return
|
||||
currentImageLoader = vRender.imageLoader
|
||||
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
|
||||
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
|
||||
// Register bundled generic fonts (FontFamily.Monospace → NotoSansMono) once
|
||||
// data.kres is loadable; idempotent, no-op if the font isn't bundled.
|
||||
com.compose.sdl.text.registerGenericFonts()
|
||||
// Position the OS IME candidate window the moment a field in THIS window
|
||||
// gains focus, not only on the first TEXT_EDITING event (repointed per
|
||||
// window here so multi-window focus targets the right SDL window).
|
||||
com.compose.sdl.text.input.ImeBridge.onSessionActiveChange = { active ->
|
||||
if (active) updateImeArea()
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Event handling (loop-routed, per window)
|
||||
|
||||
fun onPointerEvent(inEvent: AppEvent.Pointer) {
|
||||
if (!facade.isEnabled) return
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
val vType = when (inEvent.type) {
|
||||
PointerEventType.Press -> 1
|
||||
PointerEventType.Release -> 2
|
||||
else -> 0
|
||||
}
|
||||
val vBtn = when (inEvent.button) {
|
||||
PointerButton.Secondary -> 1
|
||||
PointerButton.Tertiary -> 2
|
||||
else -> 0
|
||||
}
|
||||
// SDL3 delivers mouse coords in logical points on HiDPI - multiply by
|
||||
// DPR so hit-testing lands in the pixel space layout uses.
|
||||
val vDpr = backend.pixelDensity
|
||||
val vPx = inEvent.x * vDpr
|
||||
val vPy = inEvent.y * vDpr
|
||||
if (inEvent.type == PointerEventType.Press) {
|
||||
popupHost?.notifyOutsidePress(vPx.toInt(), vPy.toInt())
|
||||
}
|
||||
host.onPointerRaw(vPx, vPy, vType, vBtn, SDL_GetTicks().toLong())
|
||||
lastMouseX = vPx
|
||||
lastMouseY = vPy
|
||||
hasMousePos = true
|
||||
}
|
||||
|
||||
/** Pointer left the window (SDL_EVENT_WINDOW_MOUSE_LEAVE). Stop the per-frame
|
||||
synthetic hover re-dispatch and fire one Exit at the last position so a
|
||||
hovered widget clears its highlight instead of sticking forever. */
|
||||
fun onPointerExit() {
|
||||
if (!facade.isEnabled) return
|
||||
needsFrame = true
|
||||
if (hasMousePos) {
|
||||
host.onPointerRaw(lastMouseX, lastMouseY, 3, 0, SDL_GetTicks().toLong())
|
||||
}
|
||||
hasMousePos = false
|
||||
}
|
||||
|
||||
fun onWheelEvent(inEvent: AppEvent.MouseWheel) {
|
||||
if (!facade.isEnabled) return
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
val vDpr = backend.pixelDensity
|
||||
host.onWheel(inEvent.x * vDpr, inEvent.y * vDpr, inEvent.deltaX, inEvent.deltaY, SDL_GetTicks().toLong())
|
||||
}
|
||||
|
||||
fun onKeyEvent(inEvent: AppEvent.Key) {
|
||||
if (!facade.isEnabled) return
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
// Compose Desktop's order: window preview → focused content → window
|
||||
// onKeyEvent → app shortcut handler → back navigation (Escape).
|
||||
if (previewKeyHandler().invoke(inEvent.event)) return
|
||||
if (host.dispatchKeyEvent(inEvent.event)) return
|
||||
if (keyHandler().invoke(inEvent.event)) return
|
||||
if (!facade.dispatchKeyShortcut(inEvent.event)) {
|
||||
backNavigationInput.onKeyEvent(inEvent.event)
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies the mutable window attributes. Called at init from the creation
|
||||
flags and again whenever Window() sees one of them change. */
|
||||
fun applyAttributes(inAttrs: WindowAttributes) {
|
||||
facade.setUndecorated(inAttrs.undecorated)
|
||||
facade.setResizable(inAttrs.resizable)
|
||||
facade.setAlwaysOnTop(inAttrs.alwaysOnTop)
|
||||
facade.setFocusable(inAttrs.focusable)
|
||||
facade.setEnabled(inAttrs.enabled)
|
||||
}
|
||||
|
||||
fun onTextInputEvent(inEvent: AppEvent.TextInput) {
|
||||
if (!facade.isEnabled) return
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
// A focused text field runs an IME session (ComposeOwner.textInputSession):
|
||||
// commit through it so the text REPLACES any active composition. Falls back
|
||||
// to synthesising typed KeyEvents when no field is focused - SDL key events
|
||||
// carry UNSHIFTED keycodes (no uppercase/numpad/dead keys), so committed
|
||||
// text is the only layout-correct character source.
|
||||
if (!com.compose.sdl.text.input.ImeBridge.commit(inEvent.text)) {
|
||||
dispatchTypedText(host, inEvent.text)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTextEditingEvent(inEvent: AppEvent.TextEditing) {
|
||||
if (!facade.isEnabled) return
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
// IME preedit: show the composing (underlined) region in the focused field.
|
||||
// No-op if no field is focused. Also nudge the OS candidate window to the
|
||||
// field's on-screen rect.
|
||||
com.compose.sdl.text.input.ImeBridge.compose(inEvent.text)
|
||||
updateImeArea()
|
||||
}
|
||||
|
||||
// Nudge the OS IME candidate window to the focused field's on-screen rect.
|
||||
// Best-effort: no-op without an active session or a laid-out field.
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
private fun updateImeArea() {
|
||||
val vRequest = com.compose.sdl.text.input.ImeBridge.request ?: return
|
||||
val vRect = vRequest.focusedRectInRoot() ?: return
|
||||
val vWindow = backend.window ?: return
|
||||
val vDpr = backend.pixelDensity
|
||||
kotlinx.cinterop.memScoped {
|
||||
// SDL wants the area in logical window points; layout runs in physical px.
|
||||
val vSdlRect = alloc<sdl3.SDL_Rect>()
|
||||
vSdlRect.x = (vRect.left / vDpr).toInt()
|
||||
vSdlRect.y = (vRect.top / vDpr).toInt()
|
||||
vSdlRect.w = (vRect.width / vDpr).toInt().coerceAtLeast(1)
|
||||
vSdlRect.h = (vRect.height / vDpr).toInt().coerceAtLeast(1)
|
||||
sdl3.SDL_SetTextInputArea(vWindow.reinterpret(), vSdlRect.ptr, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDropEvent(inEvent: AppEvent.Drop) {
|
||||
if (!facade.isEnabled) return
|
||||
needsFrame = true
|
||||
installGlobals()
|
||||
// SDL fires drop coords in logical points at DPR-1; scale to the pixel
|
||||
// space layout runs in (Option-B density flow) so hit-testing lands
|
||||
// where the pointer is.
|
||||
val vDpr = backend.pixelDensity
|
||||
when (inEvent.phase) {
|
||||
AppEvent.DropPhase.BEGIN -> host.onDropBegin()
|
||||
AppEvent.DropPhase.POSITION -> host.onDropPosition(inEvent.x * vDpr, inEvent.y * vDpr)
|
||||
AppEvent.DropPhase.FILE -> inEvent.data?.let { host.onDropFile(it) }
|
||||
AppEvent.DropPhase.TEXT -> inEvent.data?.let { host.onDropText(it) }
|
||||
AppEvent.DropPhase.COMPLETE -> host.onDropComplete()
|
||||
}
|
||||
}
|
||||
|
||||
fun onResizedEvent() {
|
||||
needsFrame = true
|
||||
backend.updateWindowSize()
|
||||
facade.onResized()
|
||||
}
|
||||
|
||||
/** OS light/dark theme changed - re-pick the theme-appropriate window icon
|
||||
(no-op if this window has no icon configured or the choice is unchanged). */
|
||||
fun onSystemThemeChanged() {
|
||||
backend.applyThemeIcon()
|
||||
}
|
||||
|
||||
/** OS close button / app-level Quit: honour the veto handler, then hand the
|
||||
decision to the Window() declaration. */
|
||||
fun requestClose() {
|
||||
if (closeDispatched) return
|
||||
if (facade.requestCloseFromUser()) {
|
||||
closeDispatched = true
|
||||
onCloseRequest()
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Frame pump
|
||||
|
||||
fun shouldRender(): Boolean =
|
||||
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
|
||||
|
||||
/** True while this window still has pending work: a state/layout/draw invalidation
|
||||
(needsFrame), recomposer work, or a composition/node animation awaiting the next
|
||||
frame. Sampled by windowHasInvalidations() from onFrame probes - the quiescence
|
||||
signal for render-to-settle screenshot capture. */
|
||||
fun hasInvalidations(): Boolean =
|
||||
needsFrame || recomposer?.hasPendingWork == true ||
|
||||
frameClock.hasAwaiters || host.hasAnimationAwaiters()
|
||||
|
||||
fun renderFrame() {
|
||||
val vRender = renderBackend ?: return
|
||||
needsFrame = false
|
||||
if (recomposer?.hasPendingWork == true) needsFrame = true
|
||||
|
||||
backend.updateWindowSize()
|
||||
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
|
||||
// Charge the GPU back-buffer acquire to its own phase. On Metal this is
|
||||
// where nextDrawable() blocks on vsync (the natural frame pacing), so
|
||||
// folding it into "layout" made the profiler read the vsync wait as
|
||||
// layout cost - a ~6-7ms phantom that hid the real (tiny) layout time.
|
||||
FrameProfiler.phase(" acquire")
|
||||
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
|
||||
// Deliver any state written by this iteration's frame-clock continuations
|
||||
// (withFrameNanos animations - notably smooth wheel scrolling) BEFORE we lay
|
||||
// out. Those writes happen in the per-window pump's sendFrame()/yield(), which
|
||||
// is AFTER the loop's last sendApplyNotifications() - so without this the
|
||||
// relayout they trigger wasn't registered until the next frame, and the frame
|
||||
// drew last frame's positions. That one-frame trail read as "the clip is a
|
||||
// frame late" when scrolling (content briefly drawn past the viewport edge).
|
||||
Snapshot.sendApplyNotifications()
|
||||
host.measureAndLayout()
|
||||
FrameProfiler.phase(" layout")
|
||||
|
||||
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
|
||||
if (hasMousePos) {
|
||||
host.onPointerRaw(lastMouseX, lastMouseY, 0, 0, SDL_GetTicks().toLong())
|
||||
}
|
||||
|
||||
vRender.beginFrame(1f)
|
||||
vRender.drawRoot { canvas -> host.drawRoot(canvas) }
|
||||
if (onFrame != null) {
|
||||
renderingWindow = this
|
||||
val vContinue = onFrame.invoke(vRender, frameIndex)
|
||||
renderingWindow = null
|
||||
// Probe consumers end the app when their scenario completes.
|
||||
if (!vContinue) facade.close()
|
||||
}
|
||||
FrameProfiler.phase(" draw")
|
||||
vRender.endFrame()
|
||||
FrameProfiler.phase(" present")
|
||||
frameIndex++
|
||||
|
||||
// FPS - instantaneous inter-frame rate, EMA-smoothed, title refreshed
|
||||
// ~4x/sec. This shows within ~2 rendered frames of ANY activity rather
|
||||
// than waiting for a full second of unbroken rendering to accumulate
|
||||
// (the old fixed-window counter never got its first update: bursty
|
||||
// interaction kept idling before 1s elapsed, and the idle-skip reset the
|
||||
// window each time - so FPS only appeared during a long enough page
|
||||
// transition). A dt outside 1..100ms is the first frame or a resume from
|
||||
// idle (the gap isn't a real frame interval), so it's not sampled - the
|
||||
// title just holds the last active rate while idle.
|
||||
val vNowMs = SDL_GetTicks()
|
||||
val vDt = (vNowMs - fpsLastFrameMs).toInt()
|
||||
fpsLastFrameMs = vNowMs
|
||||
if (vDt in 1..100) {
|
||||
val vInst = 1000.0 / vDt
|
||||
fpsEma = if (fpsEma <= 0.0) vInst else fpsEma * 0.9 + vInst * 0.1
|
||||
if ((vNowMs - fpsLastTitleMs).toInt() >= 250) {
|
||||
val vFps = (fpsEma + 0.5).toInt()
|
||||
facade.updateFps(vFps)
|
||||
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
|
||||
fpsLastTitleMs = vNowMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idle-skip calls this: drop the frame timer so the resume-from-idle frame's
|
||||
// dt (the whole idle gap) isn't sampled as a real interval. The EMA is left
|
||||
// intact so the title keeps showing the last active rate while idle.
|
||||
fun resetFpsWindow() {
|
||||
fpsLastFrameMs = SDL_GetTicks()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
composition?.dispose()
|
||||
composition = null
|
||||
// Window gone → DESTROYED lifecycle + ViewModels cleared (onCleared
|
||||
// runs), matching upstream desktop's window-scoped owner lifetime.
|
||||
architectureOwner.destroy()
|
||||
recomposer?.cancel()
|
||||
recomposer = null
|
||||
recomposeJob?.cancel()
|
||||
recomposeJob = null
|
||||
renderBackend?.destroy()
|
||||
renderBackend = null
|
||||
backend.destroy(inQuitSdl = false)
|
||||
}
|
||||
}
|
||||
@@ -2090,13 +2090,6 @@ final object androidx.compose.foundation/MarqueeDefaults { // androidx.compose.f
|
||||
final fun <get-Velocity>(): androidx.compose.ui.unit/Dp // androidx.compose.foundation/MarqueeDefaults.Velocity.<get-Velocity>|<get-Velocity>(){}[0]
|
||||
}
|
||||
|
||||
final object com.compose.sdl.icons/IconDefaults { // com.compose.sdl.icons/IconDefaults|null[0]
|
||||
final val DefaultIconSize // com.compose.sdl.icons/IconDefaults.DefaultIconSize|{}DefaultIconSize[0]
|
||||
final fun <get-DefaultIconSize>(): androidx.compose.ui.unit/Dp // com.compose.sdl.icons/IconDefaults.DefaultIconSize.<get-DefaultIconSize>|<get-DefaultIconSize>(){}[0]
|
||||
final val LocalContentColor // com.compose.sdl.icons/IconDefaults.LocalContentColor|{}LocalContentColor[0]
|
||||
final fun <get-LocalContentColor>(): androidx.compose.ui.graphics/Color // com.compose.sdl.icons/IconDefaults.LocalContentColor.<get-LocalContentColor>|<get-LocalContentColor>(){}[0]
|
||||
}
|
||||
|
||||
final object com.compose.sdl.widgets/SplitPaneDefaults { // com.compose.sdl.widgets/SplitPaneDefaults|null[0]
|
||||
final val DividerThickness // com.compose.sdl.widgets/SplitPaneDefaults.DividerThickness|{}DividerThickness[0]
|
||||
final fun <get-DividerThickness>(): androidx.compose.ui.unit/Dp // com.compose.sdl.widgets/SplitPaneDefaults.DividerThickness.<get-DividerThickness>|<get-DividerThickness>(){}[0]
|
||||
@@ -2233,7 +2226,6 @@ final val androidx.compose.foundation/androidx_compose_foundation_MarqueeDefault
|
||||
final val androidx.compose.foundation/androidx_compose_foundation_MutatorMutex$stableprop // androidx.compose.foundation/androidx_compose_foundation_MutatorMutex$stableprop|#static{}androidx_compose_foundation_MutatorMutex$stableprop[0]
|
||||
final val androidx.compose.foundation/androidx_compose_foundation_ScrollState$stableprop // androidx.compose.foundation/androidx_compose_foundation_ScrollState$stableprop|#static{}androidx_compose_foundation_ScrollState$stableprop[0]
|
||||
final val androidx.compose.foundation/androidx_compose_foundation_ScrollbarStyle$stableprop // androidx.compose.foundation/androidx_compose_foundation_ScrollbarStyle$stableprop|#static{}androidx_compose_foundation_ScrollbarStyle$stableprop[0]
|
||||
final val com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop // com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop|#static{}com_compose_sdl_icons_IconDefaults$stableprop[0]
|
||||
final val com.compose.sdl.widgets/com_compose_sdl_widgets_SplitPaneDefaults$stableprop // com.compose.sdl.widgets/com_compose_sdl_widgets_SplitPaneDefaults$stableprop|#static{}com_compose_sdl_widgets_SplitPaneDefaults$stableprop[0]
|
||||
|
||||
final fun (androidx.compose.foundation.content/TransferableContent).androidx.compose.foundation.content/hasMediaType(androidx.compose.foundation.content/MediaType): kotlin/Boolean // androidx.compose.foundation.content/hasMediaType|hasMediaType@androidx.compose.foundation.content.TransferableContent(androidx.compose.foundation.content.MediaType){}[0]
|
||||
@@ -2651,7 +2643,6 @@ final fun androidx.compose.foundation/rememberScrollbarAdapter(androidx.compose.
|
||||
final fun androidx.compose.foundation/rememberScrollbarAdapter(androidx.compose.foundation.text/TextFieldScrollState, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.v2/ScrollbarAdapter // androidx.compose.foundation/rememberScrollbarAdapter|rememberScrollbarAdapter(androidx.compose.foundation.text.TextFieldScrollState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final fun androidx.compose.foundation/rememberScrollbarAdapter(androidx.compose.foundation/ScrollState, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.v2/ScrollbarAdapter // androidx.compose.foundation/rememberScrollbarAdapter|rememberScrollbarAdapter(androidx.compose.foundation.ScrollState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.icons/IconFontIcon(kotlin/Int, kotlin/String, kotlin/String?, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/Dp, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.icons/IconFontIcon|IconFontIcon(kotlin.Int;kotlin.String;kotlin.String?;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.Dp;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop_getter(): kotlin/Int // com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop_getter|com_compose_sdl_icons_IconDefaults$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.modifier/rememberMutableInteractionSource(androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.interaction/MutableInteractionSource // com.compose.sdl.modifier/rememberMutableInteractionSource|rememberMutableInteractionSource(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.text/IconText(kotlin/String, kotlin/String, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/TextUnit, androidx.compose.ui.text.style/TextAlign, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.text/IconText|IconText(kotlin.String;kotlin.String;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.text.style.TextAlign;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.text/IconText(kotlin/String, kotlin/String, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/TextUnit, androidx.compose.ui.text.style/TextAlign?, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.text/IconText|IconText(kotlin.String;kotlin.String;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.text.style.TextAlign?;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
|
||||
+16
-2
@@ -2,7 +2,9 @@ package com.compose.sdl.icons
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
@@ -36,9 +38,21 @@ fun IconFontIcon(
|
||||
size: Dp = IconDefaults.DefaultIconSize,
|
||||
fontVariationSettings: List<FontVariation.Setting>? = null,
|
||||
) {
|
||||
Box(modifier = modifier.size(size)) {
|
||||
// CENTER the glyph in the icon box. The glyph's natural text box is TALLER than
|
||||
// `size` - its height is the font's ascent+descent, and Material Symbols' em box
|
||||
// overshoots the nominal icon size - so the glyph used to render ~13% of the icon
|
||||
// size too LOW. This mirrors the JVM actual's explicit baseline math
|
||||
// (`y = height/2 - (ascent+descent)/2` in MaterialSymbolsIcon.jvm.kt): IconText
|
||||
// sets no lineHeight, so its box height IS ascent+descent and centering that box
|
||||
// centers the same span the JVM centers.
|
||||
//
|
||||
// `wrapContentSize(unbounded = true)` is load-bearing: Modifier.size() hands the
|
||||
// child FIXED constraints, so without it the text is measured at exactly `size`,
|
||||
// nothing overflows, and contentAlignment has nothing to align.
|
||||
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
|
||||
com.compose.sdl.text.IconText(
|
||||
text = codepointToString(codepoint),
|
||||
modifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true),
|
||||
fontFamily = fontFamily,
|
||||
color = tint,
|
||||
fontSize = size.value.sp,
|
||||
@@ -47,7 +61,7 @@ fun IconFontIcon(
|
||||
}
|
||||
}
|
||||
|
||||
object IconDefaults {
|
||||
internal object IconDefaults {
|
||||
val DefaultIconSize: Dp = 24.dp
|
||||
/** STATIC fallback tint for direct IconFontIcon use - :foundation cannot
|
||||
read material3's LocalContentColor (module layering, same as upstream).
|
||||
|
||||
@@ -341,18 +341,10 @@ abstract interface com.compose.sdl.graphics/NativeFinishableCanvas { // com.comp
|
||||
abstract fun finish() // com.compose.sdl.graphics/NativeFinishableCanvas.finish|finish(){}[0]
|
||||
}
|
||||
|
||||
abstract interface com.compose.sdl.graphics/NativePainterCanvas { // com.compose.sdl.graphics/NativePainterCanvas|null[0]
|
||||
abstract fun drawNativePainter(kotlin/String, com.compose.sdl.res/ResourceKind, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/NativePainterCanvas.drawNativePainter|drawNativePainter(kotlin.String;com.compose.sdl.res.ResourceKind;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
|
||||
}
|
||||
|
||||
abstract interface com.compose.sdl.graphics/NativeShadowCanvas { // com.compose.sdl.graphics/NativeShadowCanvas|null[0]
|
||||
abstract fun drawDropShadow(androidx.compose.ui.graphics/Outline, kotlin/Float, androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color) // com.compose.sdl.graphics/NativeShadowCanvas.drawDropShadow|drawDropShadow(androidx.compose.ui.graphics.Outline;kotlin.Float;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color){}[0]
|
||||
}
|
||||
|
||||
abstract interface com.compose.sdl.graphics/NativeShapeClipCanvas { // com.compose.sdl.graphics/NativeShapeClipCanvas|null[0]
|
||||
abstract fun clipRoundRect(androidx.compose.ui.geometry/RoundRect) // com.compose.sdl.graphics/NativeShapeClipCanvas.clipRoundRect|clipRoundRect(androidx.compose.ui.geometry.RoundRect){}[0]
|
||||
}
|
||||
|
||||
abstract interface com.compose.sdl.res/ImageLoader { // com.compose.sdl.res/ImageLoader|null[0]
|
||||
abstract fun intrinsicSize(kotlin/String, com.compose.sdl.res/ResourceKind): androidx.compose.ui.geometry/Size // com.compose.sdl.res/ImageLoader.intrinsicSize|intrinsicSize(kotlin.String;com.compose.sdl.res.ResourceKind){}[0]
|
||||
abstract fun readBytes(kotlin/String): kotlin/ByteArray? // com.compose.sdl.res/ImageLoader.readBytes|readBytes(kotlin.String){}[0]
|
||||
@@ -364,62 +356,6 @@ sealed interface androidx.compose.ui.graphics.shadow/ShadowContext { // androidx
|
||||
open fun createInnerShadowPainter(androidx.compose.ui.graphics/Shape, androidx.compose.ui.graphics.shadow/Shadow): androidx.compose.ui.graphics.shadow/InnerShadowPainter // androidx.compose.ui.graphics.shadow/ShadowContext.createInnerShadowPainter|createInnerShadowPainter(androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.shadow.Shadow){}[0]
|
||||
}
|
||||
|
||||
sealed interface com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand|null[0]
|
||||
final class CubicTo : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.CubicTo|null[0]
|
||||
constructor <init>(kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/PathCommand.CubicTo.<init>|<init>(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
|
||||
|
||||
final val c1x // com.compose.sdl.graphics/PathCommand.CubicTo.c1x|{}c1x[0]
|
||||
final fun <get-c1x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.c1x.<get-c1x>|<get-c1x>(){}[0]
|
||||
final val c1y // com.compose.sdl.graphics/PathCommand.CubicTo.c1y|{}c1y[0]
|
||||
final fun <get-c1y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.c1y.<get-c1y>|<get-c1y>(){}[0]
|
||||
final val c2x // com.compose.sdl.graphics/PathCommand.CubicTo.c2x|{}c2x[0]
|
||||
final fun <get-c2x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.c2x.<get-c2x>|<get-c2x>(){}[0]
|
||||
final val c2y // com.compose.sdl.graphics/PathCommand.CubicTo.c2y|{}c2y[0]
|
||||
final fun <get-c2y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.c2y.<get-c2y>|<get-c2y>(){}[0]
|
||||
final val x // com.compose.sdl.graphics/PathCommand.CubicTo.x|{}x[0]
|
||||
final fun <get-x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.x.<get-x>|<get-x>(){}[0]
|
||||
final val y // com.compose.sdl.graphics/PathCommand.CubicTo.y|{}y[0]
|
||||
final fun <get-y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.CubicTo.y.<get-y>|<get-y>(){}[0]
|
||||
}
|
||||
|
||||
final class LineTo : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.LineTo|null[0]
|
||||
constructor <init>(kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/PathCommand.LineTo.<init>|<init>(kotlin.Float;kotlin.Float){}[0]
|
||||
|
||||
final val x // com.compose.sdl.graphics/PathCommand.LineTo.x|{}x[0]
|
||||
final fun <get-x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.LineTo.x.<get-x>|<get-x>(){}[0]
|
||||
final val y // com.compose.sdl.graphics/PathCommand.LineTo.y|{}y[0]
|
||||
final fun <get-y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.LineTo.y.<get-y>|<get-y>(){}[0]
|
||||
}
|
||||
|
||||
final class MoveTo : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.MoveTo|null[0]
|
||||
constructor <init>(kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/PathCommand.MoveTo.<init>|<init>(kotlin.Float;kotlin.Float){}[0]
|
||||
|
||||
final val x // com.compose.sdl.graphics/PathCommand.MoveTo.x|{}x[0]
|
||||
final fun <get-x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.MoveTo.x.<get-x>|<get-x>(){}[0]
|
||||
final val y // com.compose.sdl.graphics/PathCommand.MoveTo.y|{}y[0]
|
||||
final fun <get-y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.MoveTo.y.<get-y>|<get-y>(){}[0]
|
||||
}
|
||||
|
||||
final class QuadTo : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.QuadTo|null[0]
|
||||
constructor <init>(kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // com.compose.sdl.graphics/PathCommand.QuadTo.<init>|<init>(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
|
||||
|
||||
final val cx // com.compose.sdl.graphics/PathCommand.QuadTo.cx|{}cx[0]
|
||||
final fun <get-cx>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.QuadTo.cx.<get-cx>|<get-cx>(){}[0]
|
||||
final val cy // com.compose.sdl.graphics/PathCommand.QuadTo.cy|{}cy[0]
|
||||
final fun <get-cy>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.QuadTo.cy.<get-cy>|<get-cy>(){}[0]
|
||||
final val x // com.compose.sdl.graphics/PathCommand.QuadTo.x|{}x[0]
|
||||
final fun <get-x>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.QuadTo.x.<get-x>|<get-x>(){}[0]
|
||||
final val y // com.compose.sdl.graphics/PathCommand.QuadTo.y|{}y[0]
|
||||
final fun <get-y>(): kotlin/Float // com.compose.sdl.graphics/PathCommand.QuadTo.y.<get-y>|<get-y>(){}[0]
|
||||
}
|
||||
|
||||
final object Close : com.compose.sdl.graphics/PathCommand { // com.compose.sdl.graphics/PathCommand.Close|null[0]
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl.graphics/PathCommand.Close.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // com.compose.sdl.graphics/PathCommand.Close.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl.graphics/PathCommand.Close.toString|toString(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
abstract class androidx.compose.ui.graphics.colorspace/Adaptation { // androidx.compose.ui.graphics.colorspace/Adaptation|null[0]
|
||||
final object Companion { // androidx.compose.ui.graphics.colorspace/Adaptation.Companion|null[0]
|
||||
final val Bradford // androidx.compose.ui.graphics.colorspace/Adaptation.Companion.Bradford|{}Bradford[0]
|
||||
@@ -1111,19 +1047,6 @@ final class com.compose.sdl.renderer.skia/SkiaLeafDrawer { // com.compose.sdl.re
|
||||
final fun <get-imageCache>(): com.compose.sdl.renderer.skia/SkiaImageCache // com.compose.sdl.renderer.skia/SkiaLeafDrawer.imageCache.<get-imageCache>|<get-imageCache>(){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl.res/ResourcePainter : androidx.compose.ui.graphics.painter/Painter { // com.compose.sdl.res/ResourcePainter|null[0]
|
||||
final val intrinsicSize // com.compose.sdl.res/ResourcePainter.intrinsicSize|{}intrinsicSize[0]
|
||||
final fun <get-intrinsicSize>(): androidx.compose.ui.geometry/Size // com.compose.sdl.res/ResourcePainter.intrinsicSize.<get-intrinsicSize>|<get-intrinsicSize>(){}[0]
|
||||
final val kind // com.compose.sdl.res/ResourcePainter.kind|{}kind[0]
|
||||
final fun <get-kind>(): com.compose.sdl.res/ResourceKind // com.compose.sdl.res/ResourcePainter.kind.<get-kind>|<get-kind>(){}[0]
|
||||
final val resourcePath // com.compose.sdl.res/ResourcePainter.resourcePath|{}resourcePath[0]
|
||||
final fun <get-resourcePath>(): kotlin/String // com.compose.sdl.res/ResourcePainter.resourcePath.<get-resourcePath>|<get-resourcePath>(){}[0]
|
||||
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl.res/ResourcePainter.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // com.compose.sdl.res/ResourcePainter.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl.res/ResourcePainter.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final value class androidx.compose.ui.graphics.colorspace/ColorModel { // androidx.compose.ui.graphics.colorspace/ColorModel|null[0]
|
||||
final val componentCount // androidx.compose.ui.graphics.colorspace/ColorModel.componentCount|{}componentCount[0]
|
||||
final fun <get-componentCount>(): kotlin/Int // androidx.compose.ui.graphics.colorspace/ColorModel.componentCount.<get-componentCount>|<get-componentCount>(){}[0]
|
||||
@@ -2108,10 +2031,6 @@ final object com.compose.sdl.graphics/NativeReleaseQueue { // com.compose.sdl.gr
|
||||
final fun enqueue(kotlin/Function0<kotlin/Unit>) // com.compose.sdl.graphics/NativeReleaseQueue.enqueue|enqueue(kotlin.Function0<kotlin.Unit>){}[0]
|
||||
}
|
||||
|
||||
final object com.compose.sdl.res/AndroidVectorToSvg { // com.compose.sdl.res/AndroidVectorToSvg|null[0]
|
||||
final fun convert(kotlin/String): kotlin/String // com.compose.sdl.res/AndroidVectorToSvg.convert|convert(kotlin.String){}[0]
|
||||
}
|
||||
|
||||
final object com.compose.sdl.res/Res { // com.compose.sdl.res/Res|null[0]
|
||||
final fun readBytes(kotlin/String): kotlin/ByteArray? // com.compose.sdl.res/Res.readBytes|readBytes(kotlin.String){}[0]
|
||||
|
||||
@@ -2261,11 +2180,9 @@ final val com.compose.sdl.graphics/gradientTileMode // com.compose.sdl.graphics/
|
||||
final val com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaEncodedImageDecoder$stableprop // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaEncodedImageDecoder$stableprop|#static{}com_compose_sdl_renderer_skia_SkiaEncodedImageDecoder$stableprop[0]
|
||||
final val com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaImageCache$stableprop // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaImageCache$stableprop|#static{}com_compose_sdl_renderer_skia_SkiaImageCache$stableprop[0]
|
||||
final val com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop|#static{}com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop[0]
|
||||
final val com.compose.sdl.res/com_compose_sdl_res_AndroidVectorToSvg$stableprop // com.compose.sdl.res/com_compose_sdl_res_AndroidVectorToSvg$stableprop|#static{}com_compose_sdl_res_AndroidVectorToSvg$stableprop[0]
|
||||
final val com.compose.sdl.res/com_compose_sdl_res_Res$stableprop // com.compose.sdl.res/com_compose_sdl_res_Res$stableprop|#static{}com_compose_sdl_res_Res$stableprop[0]
|
||||
final val com.compose.sdl.res/com_compose_sdl_res_Res_drawable$stableprop // com.compose.sdl.res/com_compose_sdl_res_Res_drawable$stableprop|#static{}com_compose_sdl_res_Res_drawable$stableprop[0]
|
||||
final val com.compose.sdl.res/com_compose_sdl_res_Res_files$stableprop // com.compose.sdl.res/com_compose_sdl_res_Res_files$stableprop|#static{}com_compose_sdl_res_Res_files$stableprop[0]
|
||||
final val com.compose.sdl.res/com_compose_sdl_res_ResourcePainter$stableprop // com.compose.sdl.res/com_compose_sdl_res_ResourcePainter$stableprop|#static{}com_compose_sdl_res_ResourcePainter$stableprop[0]
|
||||
|
||||
final var androidx.compose.ui.graphics/alphaMultiplier // androidx.compose.ui.graphics/alphaMultiplier|@androidx.compose.ui.graphics.Canvas{}alphaMultiplier[0]
|
||||
final fun (androidx.compose.ui.graphics/Canvas).<get-alphaMultiplier>(): kotlin/Float // androidx.compose.ui.graphics/alphaMultiplier.<get-alphaMultiplier>|<get-alphaMultiplier>@androidx.compose.ui.graphics.Canvas(){}[0]
|
||||
@@ -2456,14 +2373,11 @@ final fun com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaEncode
|
||||
final fun com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaImageCache$stableprop_getter(): kotlin/Int // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaImageCache$stableprop_getter|com_compose_sdl_renderer_skia_SkiaImageCache$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop_getter(): kotlin/Int // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop_getter|com_compose_sdl_renderer_skia_SkiaLeafDrawer$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.renderer.skia/readBackBgra(org.jetbrains.skia/Surface?, kotlin/Int, kotlin/Int): kotlin/Triple<kotlin/Int, kotlin/Int, kotlin/ByteArray>? // com.compose.sdl.renderer.skia/readBackBgra|readBackBgra(org.jetbrains.skia.Surface?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.res/com_compose_sdl_res_AndroidVectorToSvg$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_AndroidVectorToSvg$stableprop_getter|com_compose_sdl_res_AndroidVectorToSvg$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.res/com_compose_sdl_res_Res$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_Res$stableprop_getter|com_compose_sdl_res_Res$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.res/com_compose_sdl_res_Res_drawable$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_Res_drawable$stableprop_getter|com_compose_sdl_res_Res_drawable$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.res/com_compose_sdl_res_Res_files$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_Res_files$stableprop_getter|com_compose_sdl_res_Res_files$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.res/com_compose_sdl_res_ResourcePainter$stableprop_getter(): kotlin/Int // com.compose.sdl.res/com_compose_sdl_res_ResourcePainter$stableprop_getter|com_compose_sdl_res_ResourcePainter$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.res/painterResource(kotlin/String): androidx.compose.ui.graphics.painter/Painter // com.compose.sdl.res/painterResource|painterResource(kotlin.String){}[0]
|
||||
final fun com.compose.sdl.res/painterResource(kotlin/String, com.compose.sdl.res/ResourceKind): androidx.compose.ui.graphics.painter/Painter // com.compose.sdl.res/painterResource|painterResource(kotlin.String;com.compose.sdl.res.ResourceKind){}[0]
|
||||
final fun com.compose.sdl.res/resourceKindForPath(kotlin/String): com.compose.sdl.res/ResourceKind // com.compose.sdl.res/resourceKindForPath|resourceKindForPath(kotlin.String){}[0]
|
||||
final inline fun (androidx.compose.ui.graphics.drawscope/DrawScope).androidx.compose.ui.graphics.drawscope/clipPath(androidx.compose.ui.graphics/Path, androidx.compose.ui.graphics/ClipOp = ..., kotlin/Function1<androidx.compose.ui.graphics.drawscope/DrawScope, kotlin/Unit>) // androidx.compose.ui.graphics.drawscope/clipPath|clipPath@androidx.compose.ui.graphics.drawscope.DrawScope(androidx.compose.ui.graphics.Path;androidx.compose.ui.graphics.ClipOp;kotlin.Function1<androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit>){}[0]
|
||||
final inline fun (androidx.compose.ui.graphics.drawscope/DrawScope).androidx.compose.ui.graphics.drawscope/clipRect(kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., androidx.compose.ui.graphics/ClipOp = ..., kotlin/Function1<androidx.compose.ui.graphics.drawscope/DrawScope, kotlin/Unit>) // androidx.compose.ui.graphics.drawscope/clipRect|clipRect@androidx.compose.ui.graphics.drawscope.DrawScope(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;androidx.compose.ui.graphics.ClipOp;kotlin.Function1<androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit>){}[0]
|
||||
final inline fun (androidx.compose.ui.graphics.drawscope/DrawScope).androidx.compose.ui.graphics.drawscope/draw(androidx.compose.ui.unit/Density, androidx.compose.ui.unit/LayoutDirection, androidx.compose.ui.graphics/Canvas, androidx.compose.ui.geometry/Size, androidx.compose.ui.graphics.layer/GraphicsLayer? = ..., kotlin/Function1<androidx.compose.ui.graphics.drawscope/DrawScope, kotlin/Unit>) // androidx.compose.ui.graphics.drawscope/draw|draw@androidx.compose.ui.graphics.drawscope.DrawScope(androidx.compose.ui.unit.Density;androidx.compose.ui.unit.LayoutDirection;androidx.compose.ui.graphics.Canvas;androidx.compose.ui.geometry.Size;androidx.compose.ui.graphics.layer.GraphicsLayer?;kotlin.Function1<androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit>){}[0]
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import com.compose.sdl.res.ResourceKind
|
||||
the given rect, and this seam carries no ui-layout (ContentScale) dependency -
|
||||
keeping :ui-graphics free of :ui.
|
||||
*/
|
||||
interface NativePainterCanvas {
|
||||
internal interface NativePainterCanvas {
|
||||
fun drawNativePainter(
|
||||
inResourcePath: String,
|
||||
inKind: ResourceKind,
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ package com.compose.sdl.graphics
|
||||
carries a `commands: List<PathCommand>` that the SDL3 / Skia renderers walk
|
||||
directly. No official equivalent, so lives in com.compose.sdl
|
||||
per FIDELITY.md's relocate rule. */
|
||||
sealed interface PathCommand {
|
||||
internal sealed interface PathCommand {
|
||||
class MoveTo(val x: Float, val y: Float) : PathCommand
|
||||
class LineTo(val x: Float, val y: Float) : PathCommand
|
||||
class QuadTo(val cx: Float, val cy: Float, val x: Float, val y: Float) : PathCommand
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import androidx.compose.ui.geometry.RoundRect
|
||||
`Canvas.restore()` - the backend composites and pops it on the matching
|
||||
`restore()`.
|
||||
*/
|
||||
interface NativeShapeClipCanvas {
|
||||
internal interface NativeShapeClipCanvas {
|
||||
// Pushes a rounded-rect clip for subsequent draws until the matching
|
||||
// Canvas.restore(). [inRoundRect] is in the CURRENT canvas-local coordinate
|
||||
// space (the same space draw calls use after the enclosing translate).
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ package com.compose.sdl.res
|
||||
Not supported: <group> transforms (paths are flattened, transforms ignored),
|
||||
gradients, clip-paths, trim-path. This covers the common single-/multi-path
|
||||
icon case such as exported Material icons. */
|
||||
object AndroidVectorToSvg {
|
||||
internal object AndroidVectorToSvg {
|
||||
|
||||
// Tag values never contain '>' (pathData is letters/digits/.,- /space) and
|
||||
// quoted attribute values can't either, so [^>] within a tag is safe.
|
||||
|
||||
@@ -18,7 +18,7 @@ enum class ResourceKind {
|
||||
}
|
||||
|
||||
/** Picks a ResourceKind from a file extension. */
|
||||
fun resourceKindForPath(path: String): ResourceKind {
|
||||
internal fun resourceKindForPath(path: String): ResourceKind {
|
||||
val vExt = path.substringAfterLast('.', "").lowercase()
|
||||
return when (vExt) {
|
||||
"svg" -> ResourceKind.Svg
|
||||
|
||||
@@ -27,17 +27,17 @@ import com.compose.sdl.graphics.NativePainterCanvas
|
||||
If the Canvas doesn't implement NativePainterCanvas (offscreen layer capture
|
||||
for graphics-layer effects) the paint is skipped.
|
||||
*/
|
||||
class ResourcePainter internal constructor(
|
||||
internal class ResourcePainter internal constructor(
|
||||
val resourcePath: String,
|
||||
val kind: ResourceKind,
|
||||
) : Painter() {
|
||||
|
||||
private var fAlpha: Float = 1f
|
||||
private var mAlpha: Float = 1f
|
||||
|
||||
override val intrinsicSize: Size
|
||||
get() = currentImageLoader.intrinsicSize(resourcePath, kind)
|
||||
|
||||
override fun applyAlpha(alpha: Float): Boolean { fAlpha = alpha; return true }
|
||||
override fun applyAlpha(alpha: Float): Boolean { mAlpha = alpha; return true }
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
drawIntoCanvas { vCanvas ->
|
||||
@@ -45,7 +45,7 @@ class ResourcePainter internal constructor(
|
||||
// translate + scaledSize, so we paint the resource FillBounds into the
|
||||
// full DrawScope size. Alpha is threaded via applyAlpha.
|
||||
(vCanvas as? NativePainterCanvas)?.drawNativePainter(
|
||||
resourcePath, kind, 0f, 0f, size.width, size.height, fAlpha,
|
||||
resourcePath, kind, 0f, 0f, size.width, size.height, mAlpha,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -28,12 +28,12 @@ import kotlinx.atomicfu.locks.synchronized
|
||||
*/
|
||||
object NativeReleaseQueue {
|
||||
|
||||
private val fLock = SynchronizedObject()
|
||||
private var fPending = ArrayList<() -> Unit>()
|
||||
private val mLock = SynchronizedObject()
|
||||
private var mPending = ArrayList<() -> Unit>()
|
||||
|
||||
/** Enqueue a release action. Safe to call from any thread. */
|
||||
fun enqueue(action: () -> Unit) {
|
||||
synchronized(fLock) { fPending.add(action) }
|
||||
synchronized(mLock) { mPending.add(action) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,10 +41,10 @@ object NativeReleaseQueue {
|
||||
* renderer APIs that aren't thread-safe. Returns the number drained.
|
||||
*/
|
||||
fun drain(): Int {
|
||||
val vBatch = synchronized(fLock) {
|
||||
if (fPending.isEmpty()) return 0
|
||||
val vTaken = fPending
|
||||
fPending = ArrayList()
|
||||
val vBatch = synchronized(mLock) {
|
||||
if (mPending.isEmpty()) return 0
|
||||
val vTaken = mPending
|
||||
mPending = ArrayList()
|
||||
vTaken
|
||||
}
|
||||
for (vAction in vBatch) {
|
||||
|
||||
+23
-23
@@ -38,7 +38,7 @@ class SkiaImageCache {
|
||||
// (registerMemoryResource - e.g. downloaded PNGs) from growing image memory
|
||||
// without limit (issue #2). On-screen images stay hot; an evicted one just
|
||||
// re-decodes on next use.
|
||||
private val fCache = LinkedHashMap<String, Image?>()
|
||||
private val mCache = LinkedHashMap<String, Image?>()
|
||||
|
||||
// SVG / Android-vector kinds are RESOLUTION-INDEPENDENT: instead of caching one
|
||||
// intrinsic-size raster and letting drawImageRect upscale it (blurry when an icon
|
||||
@@ -46,8 +46,8 @@ class SkiaImageCache {
|
||||
// destination pixel size and cache that raster keyed by "path@WxH". Mirrors
|
||||
// upstream's size-driven DrawCache for SVGPainter. Intrinsic dims are cached
|
||||
// separately so the layout pass (intrinsicSize) doesn't force a raster.
|
||||
private val fSvgRasterCache = LinkedHashMap<String, Image?>()
|
||||
private val fSvgIntrinsic = HashMap<String, androidx.compose.ui.geometry.Size>()
|
||||
private val mSvgRasterCache = LinkedHashMap<String, Image?>()
|
||||
private val mSvgIntrinsic = HashMap<String, androidx.compose.ui.geometry.Size>()
|
||||
|
||||
fun intrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size {
|
||||
if (inKind == ResourceKind.Svg || inKind == ResourceKind.AndroidVector) {
|
||||
@@ -58,18 +58,18 @@ class SkiaImageCache {
|
||||
}
|
||||
|
||||
private fun get(inPath: String, inKind: ResourceKind): Image? {
|
||||
if (fCache.containsKey(inPath)) {
|
||||
if (mCache.containsKey(inPath)) {
|
||||
// Re-insert to mark most-recently-used (LinkedHashMap keeps first =
|
||||
// least-recently-used for eviction below).
|
||||
val vExisting = fCache.remove(inPath)
|
||||
fCache[inPath] = vExisting
|
||||
val vExisting = mCache.remove(inPath)
|
||||
mCache[inPath] = vExisting
|
||||
return vExisting
|
||||
}
|
||||
val vImage = decode(inPath, inKind)
|
||||
fCache[inPath] = vImage
|
||||
if (fCache.size > MAX_CACHED_IMAGES) {
|
||||
val vEldest = fCache.keys.firstOrNull()
|
||||
if (vEldest != null) fCache.remove(vEldest)?.close()
|
||||
mCache[inPath] = vImage
|
||||
if (mCache.size > MAX_CACHED_IMAGES) {
|
||||
val vEldest = mCache.keys.firstOrNull()
|
||||
if (vEldest != null) mCache.remove(vEldest)?.close()
|
||||
}
|
||||
return vImage
|
||||
}
|
||||
@@ -136,7 +136,7 @@ class SkiaImageCache {
|
||||
|
||||
/** Intrinsic (viewport) size of the vector, parsed once and cached. */
|
||||
private fun svgIntrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size {
|
||||
fSvgIntrinsic[inPath]?.let { return it }
|
||||
mSvgIntrinsic[inPath]?.let { return it }
|
||||
val vBytes = svgBytes(inPath, inKind) ?: return androidx.compose.ui.geometry.Size.Unspecified
|
||||
val vSize = runCatching {
|
||||
val vDom = SVGDOM(Data.makeFromBytes(vBytes))
|
||||
@@ -146,7 +146,7 @@ class SkiaImageCache {
|
||||
vDom.close()
|
||||
androidx.compose.ui.geometry.Size(vW, vH)
|
||||
}.getOrDefault(androidx.compose.ui.geometry.Size.Unspecified)
|
||||
fSvgIntrinsic[inPath] = vSize
|
||||
mSvgIntrinsic[inPath] = vSize
|
||||
return vSize
|
||||
}
|
||||
|
||||
@@ -157,9 +157,9 @@ class SkiaImageCache {
|
||||
val vWpx = inW.roundToInt().coerceAtLeast(1)
|
||||
val vHpx = inH.roundToInt().coerceAtLeast(1)
|
||||
val vKey = "$inPath@${vWpx}x$vHpx"
|
||||
val vImg = if (fSvgRasterCache.containsKey(vKey)) {
|
||||
val vExisting = fSvgRasterCache.remove(vKey)
|
||||
fSvgRasterCache[vKey] = vExisting
|
||||
val vImg = if (mSvgRasterCache.containsKey(vKey)) {
|
||||
val vExisting = mSvgRasterCache.remove(vKey)
|
||||
mSvgRasterCache[vKey] = vExisting
|
||||
vExisting
|
||||
} else {
|
||||
val vIntrinsic = svgIntrinsicSize(inPath, inKind)
|
||||
@@ -167,9 +167,9 @@ class SkiaImageCache {
|
||||
val vRaster = if (vBytes != null && vIntrinsic.isSpecified) {
|
||||
rasterizeSvgAt(vBytes, vIntrinsic.width, vIntrinsic.height, vWpx, vHpx)
|
||||
} else null
|
||||
fSvgRasterCache[vKey] = vRaster
|
||||
if (fSvgRasterCache.size > MAX_CACHED_IMAGES) {
|
||||
fSvgRasterCache.keys.firstOrNull()?.let { fSvgRasterCache.remove(it)?.close() }
|
||||
mSvgRasterCache[vKey] = vRaster
|
||||
if (mSvgRasterCache.size > MAX_CACHED_IMAGES) {
|
||||
mSvgRasterCache.keys.firstOrNull()?.let { mSvgRasterCache.remove(it)?.close() }
|
||||
}
|
||||
vRaster
|
||||
} ?: return
|
||||
@@ -183,11 +183,11 @@ class SkiaImageCache {
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
for (vImg in fCache.values) vImg?.close()
|
||||
fCache.clear()
|
||||
for (vImg in fSvgRasterCache.values) vImg?.close()
|
||||
fSvgRasterCache.clear()
|
||||
fSvgIntrinsic.clear()
|
||||
for (vImg in mCache.values) vImg?.close()
|
||||
mCache.clear()
|
||||
for (vImg in mSvgRasterCache.values) vImg?.close()
|
||||
mSvgRasterCache.clear()
|
||||
mSvgIntrinsic.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,35 +18,35 @@ package com.compose.sdl.icons
|
||||
the renderer where to find the bytes. */
|
||||
object IconFont {
|
||||
|
||||
private val fFonts = mutableMapOf<String, ByteArray>()
|
||||
private val mFonts = mutableMapOf<String, ByteArray>()
|
||||
// Families that render as single variable-axis glyphs (Material Symbols),
|
||||
// vs ordinary text fonts - icon families are drawn one codepoint at a time,
|
||||
// while text families (e.g. a bundled monospace) go through normal
|
||||
// full-string rendering.
|
||||
private val fIconFamilies = mutableSetOf<String>()
|
||||
private val mIconFamilies = mutableSetOf<String>()
|
||||
|
||||
/** Register a TEXT font family's bytes (rendered as full strings; e.g. a
|
||||
bundled monospace). Idempotent - a second call with the same family
|
||||
replaces the previous bytes. */
|
||||
fun register(inFamily: String, inBytes: ByteArray) {
|
||||
fFonts[inFamily] = inBytes
|
||||
mFonts[inFamily] = inBytes
|
||||
}
|
||||
|
||||
/** Register an ICON font family (Material Symbols et al.) - variable-axis.
|
||||
Same byte store as register(), but also flags the family as an icon
|
||||
font. */
|
||||
fun registerIcon(inFamily: String, inBytes: ByteArray) {
|
||||
fFonts[inFamily] = inBytes
|
||||
fIconFamilies += inFamily
|
||||
mFonts[inFamily] = inBytes
|
||||
mIconFamilies += inFamily
|
||||
}
|
||||
|
||||
/** Bytes for a registered family, or null if not registered. Renderers
|
||||
fall back to the default font when this returns null. */
|
||||
fun bytesFor(inFamily: String): ByteArray? = fFonts[inFamily]
|
||||
fun bytesFor(inFamily: String): ByteArray? = mFonts[inFamily]
|
||||
|
||||
/** True only for families registered via registerIcon() - icon fonts that
|
||||
need single-glyph (variable-axis) rendering, not text fonts. */
|
||||
fun isIconFamily(inFamily: String): Boolean = inFamily in fIconFamilies
|
||||
fun isIconFamily(inFamily: String): Boolean = inFamily in mIconFamilies
|
||||
|
||||
val families: Set<String> get() = fFonts.keys
|
||||
val families: Set<String> get() = mFonts.keys
|
||||
}
|
||||
|
||||
+76
-113
@@ -1370,17 +1370,6 @@ abstract interface androidx.compose.ui/UiMediaScope { // androidx.compose.ui/UiM
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface com.compose.sdl.renderer.skia/SkiaBridge { // com.compose.sdl.renderer.skia/SkiaBridge|null[0]
|
||||
abstract val canvas // com.compose.sdl.renderer.skia/SkiaBridge.canvas|{}canvas[0]
|
||||
abstract fun <get-canvas>(): org.jetbrains.skia/Canvas // com.compose.sdl.renderer.skia/SkiaBridge.canvas.<get-canvas>|<get-canvas>(){}[0]
|
||||
|
||||
abstract fun destroy() // com.compose.sdl.renderer.skia/SkiaBridge.destroy|destroy(){}[0]
|
||||
abstract fun ensureSize(kotlin/Int, kotlin/Int): kotlin/Boolean // com.compose.sdl.renderer.skia/SkiaBridge.ensureSize|ensureSize(kotlin.Int;kotlin.Int){}[0]
|
||||
abstract fun present() // com.compose.sdl.renderer.skia/SkiaBridge.present|present(){}[0]
|
||||
abstract fun snapshot(): org.jetbrains.skia/Image? // com.compose.sdl.renderer.skia/SkiaBridge.snapshot|snapshot(){}[0]
|
||||
abstract fun snapshotBgra(): kotlin/Triple<kotlin/Int, kotlin/Int, kotlin/ByteArray>? // com.compose.sdl.renderer.skia/SkiaBridge.snapshotBgra|snapshotBgra(){}[0]
|
||||
}
|
||||
|
||||
abstract interface com.compose.sdl/ComposeWindowScope { // com.compose.sdl/ComposeWindowScope|null[0]
|
||||
abstract val window // com.compose.sdl/ComposeWindowScope.window|{}window[0]
|
||||
abstract fun <get-window>(): com.compose.sdl/ComposeNativeWindow // com.compose.sdl/ComposeWindowScope.window.<get-window>|<get-window>(){}[0]
|
||||
@@ -1392,10 +1381,10 @@ abstract interface com.compose.sdl/RenderBackend { // com.compose.sdl/RenderBack
|
||||
|
||||
abstract fun beginFrame(kotlin/Float) // com.compose.sdl/RenderBackend.beginFrame|beginFrame(kotlin.Float){}[0]
|
||||
abstract fun destroy() // com.compose.sdl/RenderBackend.destroy|destroy(){}[0]
|
||||
abstract fun drawRoot(kotlin/Function1<androidx.compose.ui.graphics/Canvas, kotlin/Unit>) // com.compose.sdl/RenderBackend.drawRoot|drawRoot(kotlin.Function1<androidx.compose.ui.graphics.Canvas,kotlin.Unit>){}[0]
|
||||
abstract fun endFrame() // com.compose.sdl/RenderBackend.endFrame|endFrame(){}[0]
|
||||
abstract fun ensureSize(kotlin/Int, kotlin/Int): kotlin/Boolean // com.compose.sdl/RenderBackend.ensureSize|ensureSize(kotlin.Int;kotlin.Int){}[0]
|
||||
abstract fun snapshotBgra(): kotlin/Triple<kotlin/Int, kotlin/Int, kotlin/ByteArray>? // com.compose.sdl/RenderBackend.snapshotBgra|snapshotBgra(){}[0]
|
||||
open fun drawRoot(kotlin/Function1<androidx.compose.ui.graphics/Canvas, kotlin/Unit>) // com.compose.sdl/RenderBackend.drawRoot|drawRoot(kotlin.Function1<androidx.compose.ui.graphics.Canvas,kotlin.Unit>){}[0]
|
||||
}
|
||||
|
||||
sealed interface androidx.compose.ui.autofill/ContentDataType { // androidx.compose.ui.autofill/ContentDataType|null[0]
|
||||
@@ -2788,23 +2777,6 @@ final class androidx.compose.ui/CombinedModifier : androidx.compose.ui/Modifier
|
||||
final fun toString(): kotlin/String // androidx.compose.ui/CombinedModifier.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl.input/LegacyPointerEvent { // com.compose.sdl.input/LegacyPointerEvent|null[0]
|
||||
constructor <init>(kotlin/Float, kotlin/Float, androidx.compose.ui.input.pointer/PointerEventType, androidx.compose.ui.input.pointer/PointerButton = ...) // com.compose.sdl.input/LegacyPointerEvent.<init>|<init>(kotlin.Float;kotlin.Float;androidx.compose.ui.input.pointer.PointerEventType;androidx.compose.ui.input.pointer.PointerButton){}[0]
|
||||
|
||||
final val button // com.compose.sdl.input/LegacyPointerEvent.button|{}button[0]
|
||||
final fun <get-button>(): androidx.compose.ui.input.pointer/PointerButton // com.compose.sdl.input/LegacyPointerEvent.button.<get-button>|<get-button>(){}[0]
|
||||
final val type // com.compose.sdl.input/LegacyPointerEvent.type|{}type[0]
|
||||
final fun <get-type>(): androidx.compose.ui.input.pointer/PointerEventType // com.compose.sdl.input/LegacyPointerEvent.type.<get-type>|<get-type>(){}[0]
|
||||
final val x // com.compose.sdl.input/LegacyPointerEvent.x|{}x[0]
|
||||
final fun <get-x>(): kotlin/Float // com.compose.sdl.input/LegacyPointerEvent.x.<get-x>|<get-x>(){}[0]
|
||||
final val y // com.compose.sdl.input/LegacyPointerEvent.y|{}y[0]
|
||||
final fun <get-y>(): kotlin/Float // com.compose.sdl.input/LegacyPointerEvent.y.<get-y>|<get-y>(){}[0]
|
||||
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl.input/LegacyPointerEvent.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // com.compose.sdl.input/LegacyPointerEvent.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl.input/LegacyPointerEvent.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl.node/ComposeRootHost { // com.compose.sdl.node/ComposeRootHost|null[0]
|
||||
constructor <init>(kotlin/Float = ...) // com.compose.sdl.node/ComposeRootHost.<init>|<init>(kotlin.Float){}[0]
|
||||
|
||||
@@ -2850,31 +2822,6 @@ final class com.compose.sdl.node/ComposeRootHost { // com.compose.sdl.node/Compo
|
||||
final fun setWindowFocused(kotlin/Boolean) // com.compose.sdl.node/ComposeRootHost.setWindowFocused|setWindowFocused(kotlin.Boolean){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl.renderer.skia/SkiaSurfaceBridge : com.compose.sdl.renderer.skia/SkiaBridge { // com.compose.sdl.renderer.skia/SkiaSurfaceBridge|null[0]
|
||||
constructor <init>(com.compose.sdl/SDL3Backend) // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.<init>|<init>(com.compose.sdl.SDL3Backend){}[0]
|
||||
|
||||
final val canvas // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.canvas|{}canvas[0]
|
||||
final fun <get-canvas>(): org.jetbrains.skia/Canvas // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.canvas.<get-canvas>|<get-canvas>(){}[0]
|
||||
final val height // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.height|{}height[0]
|
||||
final fun <get-height>(): kotlin/Int // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.height.<get-height>|<get-height>(){}[0]
|
||||
final val width // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.width|{}width[0]
|
||||
final fun <get-width>(): kotlin/Int // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.width.<get-width>|<get-width>(){}[0]
|
||||
|
||||
final fun destroy() // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.destroy|destroy(){}[0]
|
||||
final fun ensureSize(kotlin/Int, kotlin/Int): kotlin/Boolean // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.ensureSize|ensureSize(kotlin.Int;kotlin.Int){}[0]
|
||||
final fun present() // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.present|present(){}[0]
|
||||
final fun snapshot(): org.jetbrains.skia/Image? // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.snapshot|snapshot(){}[0]
|
||||
final fun snapshotBgra(): kotlin/Triple<kotlin/Int, kotlin/Int, kotlin/ByteArray>? // com.compose.sdl.renderer.skia/SkiaSurfaceBridge.snapshotBgra|snapshotBgra(){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl.window/PopupExitHandle { // com.compose.sdl.window/PopupExitHandle|null[0]
|
||||
final val isExiting // com.compose.sdl.window/PopupExitHandle.isExiting|{}isExiting[0]
|
||||
final fun <get-isExiting>(): androidx.compose.runtime/State<kotlin/Boolean> // com.compose.sdl.window/PopupExitHandle.isExiting.<get-isExiting>|<get-isExiting>(){}[0]
|
||||
|
||||
final fun enableExitTransition() // com.compose.sdl.window/PopupExitHandle.enableExitTransition|enableExitTransition(){}[0]
|
||||
final fun finish() // com.compose.sdl.window/PopupExitHandle.finish|finish(){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl.window/PopupHostState { // com.compose.sdl.window/PopupHostState|null[0]
|
||||
final fun notifyOutsidePress(kotlin/Int, kotlin/Int) // com.compose.sdl.window/PopupHostState.notifyOutsidePress|notifyOutsidePress(kotlin.Int;kotlin.Int){}[0]
|
||||
}
|
||||
@@ -2888,14 +2835,24 @@ final class com.compose.sdl/ComposeNativeWindow { // com.compose.sdl/ComposeNati
|
||||
final fun <get-gpuMode>(): com.compose.sdl/GpuMode // com.compose.sdl/ComposeNativeWindow.gpuMode.<get-gpuMode>|<get-gpuMode>(){}[0]
|
||||
final val height // com.compose.sdl/ComposeNativeWindow.height|{}height[0]
|
||||
final fun <get-height>(): kotlin/Int // com.compose.sdl/ComposeNativeWindow.height.<get-height>|<get-height>(){}[0]
|
||||
final val isAlwaysOnTop // com.compose.sdl/ComposeNativeWindow.isAlwaysOnTop|{}isAlwaysOnTop[0]
|
||||
final fun <get-isAlwaysOnTop>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isAlwaysOnTop.<get-isAlwaysOnTop>|<get-isAlwaysOnTop>(){}[0]
|
||||
final val isCloseRequested // com.compose.sdl/ComposeNativeWindow.isCloseRequested|{}isCloseRequested[0]
|
||||
final fun <get-isCloseRequested>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isCloseRequested.<get-isCloseRequested>|<get-isCloseRequested>(){}[0]
|
||||
final val isEnabled // com.compose.sdl/ComposeNativeWindow.isEnabled|{}isEnabled[0]
|
||||
final fun <get-isEnabled>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isEnabled.<get-isEnabled>|<get-isEnabled>(){}[0]
|
||||
final val isFocusable // com.compose.sdl/ComposeNativeWindow.isFocusable|{}isFocusable[0]
|
||||
final fun <get-isFocusable>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isFocusable.<get-isFocusable>|<get-isFocusable>(){}[0]
|
||||
final val isFullscreen // com.compose.sdl/ComposeNativeWindow.isFullscreen|{}isFullscreen[0]
|
||||
final fun <get-isFullscreen>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isFullscreen.<get-isFullscreen>|<get-isFullscreen>(){}[0]
|
||||
final val isMaximized // com.compose.sdl/ComposeNativeWindow.isMaximized|{}isMaximized[0]
|
||||
final fun <get-isMaximized>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isMaximized.<get-isMaximized>|<get-isMaximized>(){}[0]
|
||||
final val isMinimized // com.compose.sdl/ComposeNativeWindow.isMinimized|{}isMinimized[0]
|
||||
final fun <get-isMinimized>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isMinimized.<get-isMinimized>|<get-isMinimized>(){}[0]
|
||||
final val isResizable // com.compose.sdl/ComposeNativeWindow.isResizable|{}isResizable[0]
|
||||
final fun <get-isResizable>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isResizable.<get-isResizable>|<get-isResizable>(){}[0]
|
||||
final val isUndecorated // com.compose.sdl/ComposeNativeWindow.isUndecorated|{}isUndecorated[0]
|
||||
final fun <get-isUndecorated>(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.isUndecorated.<get-isUndecorated>|<get-isUndecorated>(){}[0]
|
||||
final val pixelDensity // com.compose.sdl/ComposeNativeWindow.pixelDensity|{}pixelDensity[0]
|
||||
final fun <get-pixelDensity>(): kotlin/Float // com.compose.sdl/ComposeNativeWindow.pixelDensity.<get-pixelDensity>|<get-pixelDensity>(){}[0]
|
||||
final val pixelHeight // com.compose.sdl/ComposeNativeWindow.pixelHeight|{}pixelHeight[0]
|
||||
@@ -2915,19 +2872,48 @@ final class com.compose.sdl/ComposeNativeWindow { // com.compose.sdl/ComposeNati
|
||||
final fun minimize() // com.compose.sdl/ComposeNativeWindow.minimize|minimize(){}[0]
|
||||
final fun onResized() // com.compose.sdl/ComposeNativeWindow.onResized|onResized(){}[0]
|
||||
final fun raise() // com.compose.sdl/ComposeNativeWindow.raise|raise(){}[0]
|
||||
final fun rawSdlHandles(): com.compose.sdl/RawSdlHandles // com.compose.sdl/ComposeNativeWindow.rawSdlHandles|rawSdlHandles(){}[0]
|
||||
final fun requestCloseFromUser(): kotlin/Boolean // com.compose.sdl/ComposeNativeWindow.requestCloseFromUser|requestCloseFromUser(){}[0]
|
||||
final fun restore() // com.compose.sdl/ComposeNativeWindow.restore|restore(){}[0]
|
||||
final fun setAlwaysOnTop(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setAlwaysOnTop|setAlwaysOnTop(kotlin.Boolean){}[0]
|
||||
final fun setEnabled(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setEnabled|setEnabled(kotlin.Boolean){}[0]
|
||||
final fun setFocusable(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setFocusable|setFocusable(kotlin.Boolean){}[0]
|
||||
final fun setFullscreen(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setFullscreen|setFullscreen(kotlin.Boolean){}[0]
|
||||
final fun setOnCloseRequest(kotlin/Function0<kotlin/Boolean>?) // com.compose.sdl/ComposeNativeWindow.setOnCloseRequest|setOnCloseRequest(kotlin.Function0<kotlin.Boolean>?){}[0]
|
||||
final fun setOnKeyShortcut(kotlin/Function1<androidx.compose.ui.input.key/KeyEvent, kotlin/Boolean>?) // com.compose.sdl/ComposeNativeWindow.setOnKeyShortcut|setOnKeyShortcut(kotlin.Function1<androidx.compose.ui.input.key.KeyEvent,kotlin.Boolean>?){}[0]
|
||||
final fun setOpacity(kotlin/Float) // com.compose.sdl/ComposeNativeWindow.setOpacity|setOpacity(kotlin.Float){}[0]
|
||||
final fun setResizable(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setResizable|setResizable(kotlin.Boolean){}[0]
|
||||
final fun setSize(kotlin/Int, kotlin/Int) // com.compose.sdl/ComposeNativeWindow.setSize|setSize(kotlin.Int;kotlin.Int){}[0]
|
||||
final fun setTitle(kotlin/String) // com.compose.sdl/ComposeNativeWindow.setTitle|setTitle(kotlin.String){}[0]
|
||||
final fun setUndecorated(kotlin/Boolean) // com.compose.sdl/ComposeNativeWindow.setUndecorated|setUndecorated(kotlin.Boolean){}[0]
|
||||
final fun toggleFullscreen() // com.compose.sdl/ComposeNativeWindow.toggleFullscreen|toggleFullscreen(){}[0]
|
||||
final fun updateFps(kotlin/Int) // com.compose.sdl/ComposeNativeWindow.updateFps|updateFps(kotlin.Int){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl/RawSdlHandles { // com.compose.sdl/RawSdlHandles|null[0]
|
||||
constructor <init>(kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>?, kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>?, kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>?, kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>?) // com.compose.sdl/RawSdlHandles.<init>|<init>(kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?){}[0]
|
||||
|
||||
final val glContext // com.compose.sdl/RawSdlHandles.glContext|{}glContext[0]
|
||||
final fun <get-glContext>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.glContext.<get-glContext>|<get-glContext>(){}[0]
|
||||
final val metalView // com.compose.sdl/RawSdlHandles.metalView|{}metalView[0]
|
||||
final fun <get-metalView>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.metalView.<get-metalView>|<get-metalView>(){}[0]
|
||||
final val renderer // com.compose.sdl/RawSdlHandles.renderer|{}renderer[0]
|
||||
final fun <get-renderer>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.renderer.<get-renderer>|<get-renderer>(){}[0]
|
||||
final val window // com.compose.sdl/RawSdlHandles.window|{}window[0]
|
||||
final fun <get-window>(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.window.<get-window>|<get-window>(){}[0]
|
||||
|
||||
final fun component1(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component1|component1(){}[0]
|
||||
final fun component2(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component2|component2(){}[0]
|
||||
final fun component3(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component3|component3(){}[0]
|
||||
final fun component4(): kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? // com.compose.sdl/RawSdlHandles.component4|component4(){}[0]
|
||||
final fun copy(kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? = ..., kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? = ..., kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? = ..., kotlinx.cinterop/CPointer<out kotlinx.cinterop/CPointed>? = ...): com.compose.sdl/RawSdlHandles // com.compose.sdl/RawSdlHandles.copy|copy(kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?;kotlinx.cinterop.CPointer<out|kotlinx.cinterop.CPointed>?){}[0]
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl/RawSdlHandles.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // com.compose.sdl/RawSdlHandles.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/RawSdlHandles.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final class com.compose.sdl/SDL3Backend { // com.compose.sdl/SDL3Backend|null[0]
|
||||
constructor <init>(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., kotlin.collections/List<kotlin/String> = ..., kotlin.collections/List<kotlin/String> = ...) // com.compose.sdl/SDL3Backend.<init>|<init>(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;kotlin.collections.List<kotlin.String>;kotlin.collections.List<kotlin.String>){}[0]
|
||||
constructor <init>(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.sdl/GpuMode = ..., kotlin.collections/List<kotlin/String> = ..., kotlin.collections/List<kotlin/String> = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // com.compose.sdl/SDL3Backend.<init>|<init>(kotlin.String;kotlin.Int;kotlin.Int;com.compose.sdl.GpuMode;kotlin.collections.List<kotlin.String>;kotlin.collections.List<kotlin.String>;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0]
|
||||
|
||||
final val gpuMode // com.compose.sdl/SDL3Backend.gpuMode|{}gpuMode[0]
|
||||
final fun <get-gpuMode>(): com.compose.sdl/GpuMode // com.compose.sdl/SDL3Backend.gpuMode.<get-gpuMode>|<get-gpuMode>(){}[0]
|
||||
@@ -4161,16 +4147,25 @@ sealed class com.compose.sdl/AppEvent { // com.compose.sdl/AppEvent|null[0]
|
||||
}
|
||||
|
||||
final class Pointer : com.compose.sdl/AppEvent { // com.compose.sdl/AppEvent.Pointer|null[0]
|
||||
constructor <init>(com.compose.sdl.input/LegacyPointerEvent, kotlin/UInt = ...) // com.compose.sdl/AppEvent.Pointer.<init>|<init>(com.compose.sdl.input.LegacyPointerEvent;kotlin.UInt){}[0]
|
||||
constructor <init>(kotlin/Float, kotlin/Float, androidx.compose.ui.input.pointer/PointerEventType, androidx.compose.ui.input.pointer/PointerButton = ..., kotlin/UInt = ...) // com.compose.sdl/AppEvent.Pointer.<init>|<init>(kotlin.Float;kotlin.Float;androidx.compose.ui.input.pointer.PointerEventType;androidx.compose.ui.input.pointer.PointerButton;kotlin.UInt){}[0]
|
||||
|
||||
final val event // com.compose.sdl/AppEvent.Pointer.event|{}event[0]
|
||||
final fun <get-event>(): com.compose.sdl.input/LegacyPointerEvent // com.compose.sdl/AppEvent.Pointer.event.<get-event>|<get-event>(){}[0]
|
||||
final val button // com.compose.sdl/AppEvent.Pointer.button|{}button[0]
|
||||
final fun <get-button>(): androidx.compose.ui.input.pointer/PointerButton // com.compose.sdl/AppEvent.Pointer.button.<get-button>|<get-button>(){}[0]
|
||||
final val type // com.compose.sdl/AppEvent.Pointer.type|{}type[0]
|
||||
final fun <get-type>(): androidx.compose.ui.input.pointer/PointerEventType // com.compose.sdl/AppEvent.Pointer.type.<get-type>|<get-type>(){}[0]
|
||||
final val windowId // com.compose.sdl/AppEvent.Pointer.windowId|{}windowId[0]
|
||||
final fun <get-windowId>(): kotlin/UInt // com.compose.sdl/AppEvent.Pointer.windowId.<get-windowId>|<get-windowId>(){}[0]
|
||||
final val x // com.compose.sdl/AppEvent.Pointer.x|{}x[0]
|
||||
final fun <get-x>(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.x.<get-x>|<get-x>(){}[0]
|
||||
final val y // com.compose.sdl/AppEvent.Pointer.y|{}y[0]
|
||||
final fun <get-y>(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.y.<get-y>|<get-y>(){}[0]
|
||||
|
||||
final fun component1(): com.compose.sdl.input/LegacyPointerEvent // com.compose.sdl/AppEvent.Pointer.component1|component1(){}[0]
|
||||
final fun component2(): kotlin/UInt // com.compose.sdl/AppEvent.Pointer.component2|component2(){}[0]
|
||||
final fun copy(com.compose.sdl.input/LegacyPointerEvent = ..., kotlin/UInt = ...): com.compose.sdl/AppEvent.Pointer // com.compose.sdl/AppEvent.Pointer.copy|copy(com.compose.sdl.input.LegacyPointerEvent;kotlin.UInt){}[0]
|
||||
final fun component1(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.component1|component1(){}[0]
|
||||
final fun component2(): kotlin/Float // com.compose.sdl/AppEvent.Pointer.component2|component2(){}[0]
|
||||
final fun component3(): androidx.compose.ui.input.pointer/PointerEventType // com.compose.sdl/AppEvent.Pointer.component3|component3(){}[0]
|
||||
final fun component4(): androidx.compose.ui.input.pointer/PointerButton // com.compose.sdl/AppEvent.Pointer.component4|component4(){}[0]
|
||||
final fun component5(): kotlin/UInt // com.compose.sdl/AppEvent.Pointer.component5|component5(){}[0]
|
||||
final fun copy(kotlin/Float = ..., kotlin/Float = ..., androidx.compose.ui.input.pointer/PointerEventType = ..., androidx.compose.ui.input.pointer/PointerButton = ..., kotlin/UInt = ...): com.compose.sdl/AppEvent.Pointer // com.compose.sdl/AppEvent.Pointer.copy|copy(kotlin.Float;kotlin.Float;androidx.compose.ui.input.pointer.PointerEventType;androidx.compose.ui.input.pointer.PointerButton;kotlin.UInt){}[0]
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // com.compose.sdl/AppEvent.Pointer.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // com.compose.sdl/AppEvent.Pointer.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/AppEvent.Pointer.toString|toString(){}[0]
|
||||
@@ -4293,22 +4288,23 @@ sealed class com.compose.sdl/AppEvent { // com.compose.sdl/AppEvent|null[0]
|
||||
}
|
||||
|
||||
sealed class com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode|null[0]
|
||||
sealed class Skia : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Skia|null[0]
|
||||
final object Metal : com.compose.sdl/GpuMode.Skia { // com.compose.sdl/GpuMode.Skia.Metal|null[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Skia.Metal.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final object OpenGL : com.compose.sdl/GpuMode.Skia { // com.compose.sdl/GpuMode.Skia.OpenGL|null[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Skia.OpenGL.toString|toString(){}[0]
|
||||
}
|
||||
}
|
||||
final val isGpuAccelerated // com.compose.sdl/GpuMode.isGpuAccelerated|{}isGpuAccelerated[0]
|
||||
final fun <get-isGpuAccelerated>(): kotlin/Boolean // com.compose.sdl/GpuMode.isGpuAccelerated.<get-isGpuAccelerated>|<get-isGpuAccelerated>(){}[0]
|
||||
|
||||
final object Auto : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Auto|null[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Auto.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final object Software : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Software|null[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Software.toString|toString(){}[0]
|
||||
final object CpuRaster : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.CpuRaster|null[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.CpuRaster.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final object Metal : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.Metal|null[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.Metal.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final object OpenGL : com.compose.sdl/GpuMode { // com.compose.sdl/GpuMode.OpenGL|null[0]
|
||||
final fun toString(): kotlin/String // com.compose.sdl/GpuMode.OpenGL.toString|toString(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4520,27 +4516,6 @@ final object androidx.compose.ui/ComposeUiFlags { // androidx.compose.ui/Compose
|
||||
final fun <set-isViewFocusFixEnabled>(kotlin/Boolean) // androidx.compose.ui/ComposeUiFlags.isViewFocusFixEnabled.<set-isViewFocusFixEnabled>|<set-isViewFocusFixEnabled>(kotlin.Boolean){}[0]
|
||||
}
|
||||
|
||||
final object com.compose.sdl.platform/DefaultViewConfiguration : androidx.compose.ui.platform/ViewConfiguration { // com.compose.sdl.platform/DefaultViewConfiguration|null[0]
|
||||
final val doubleTapMinTimeMillis // com.compose.sdl.platform/DefaultViewConfiguration.doubleTapMinTimeMillis|{}doubleTapMinTimeMillis[0]
|
||||
final fun <get-doubleTapMinTimeMillis>(): kotlin/Long // com.compose.sdl.platform/DefaultViewConfiguration.doubleTapMinTimeMillis.<get-doubleTapMinTimeMillis>|<get-doubleTapMinTimeMillis>(){}[0]
|
||||
final val doubleTapTimeoutMillis // com.compose.sdl.platform/DefaultViewConfiguration.doubleTapTimeoutMillis|{}doubleTapTimeoutMillis[0]
|
||||
final fun <get-doubleTapTimeoutMillis>(): kotlin/Long // com.compose.sdl.platform/DefaultViewConfiguration.doubleTapTimeoutMillis.<get-doubleTapTimeoutMillis>|<get-doubleTapTimeoutMillis>(){}[0]
|
||||
final val handwritingGestureLineMargin // com.compose.sdl.platform/DefaultViewConfiguration.handwritingGestureLineMargin|{}handwritingGestureLineMargin[0]
|
||||
final fun <get-handwritingGestureLineMargin>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.handwritingGestureLineMargin.<get-handwritingGestureLineMargin>|<get-handwritingGestureLineMargin>(){}[0]
|
||||
final val handwritingSlop // com.compose.sdl.platform/DefaultViewConfiguration.handwritingSlop|{}handwritingSlop[0]
|
||||
final fun <get-handwritingSlop>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.handwritingSlop.<get-handwritingSlop>|<get-handwritingSlop>(){}[0]
|
||||
final val longPressTimeoutMillis // com.compose.sdl.platform/DefaultViewConfiguration.longPressTimeoutMillis|{}longPressTimeoutMillis[0]
|
||||
final fun <get-longPressTimeoutMillis>(): kotlin/Long // com.compose.sdl.platform/DefaultViewConfiguration.longPressTimeoutMillis.<get-longPressTimeoutMillis>|<get-longPressTimeoutMillis>(){}[0]
|
||||
final val maximumFlingVelocity // com.compose.sdl.platform/DefaultViewConfiguration.maximumFlingVelocity|{}maximumFlingVelocity[0]
|
||||
final fun <get-maximumFlingVelocity>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.maximumFlingVelocity.<get-maximumFlingVelocity>|<get-maximumFlingVelocity>(){}[0]
|
||||
final val minimumFlingVelocity // com.compose.sdl.platform/DefaultViewConfiguration.minimumFlingVelocity|{}minimumFlingVelocity[0]
|
||||
final fun <get-minimumFlingVelocity>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.minimumFlingVelocity.<get-minimumFlingVelocity>|<get-minimumFlingVelocity>(){}[0]
|
||||
final val minimumTouchTargetSize // com.compose.sdl.platform/DefaultViewConfiguration.minimumTouchTargetSize|{}minimumTouchTargetSize[0]
|
||||
final fun <get-minimumTouchTargetSize>(): androidx.compose.ui.unit/DpSize // com.compose.sdl.platform/DefaultViewConfiguration.minimumTouchTargetSize.<get-minimumTouchTargetSize>|<get-minimumTouchTargetSize>(){}[0]
|
||||
final val touchSlop // com.compose.sdl.platform/DefaultViewConfiguration.touchSlop|{}touchSlop[0]
|
||||
final fun <get-touchSlop>(): kotlin/Float // com.compose.sdl.platform/DefaultViewConfiguration.touchSlop.<get-touchSlop>|<get-touchSlop>(){}[0]
|
||||
}
|
||||
|
||||
final object com.compose.sdl.text.input/ImeBridge { // com.compose.sdl.text.input/ImeBridge|null[0]
|
||||
final var onSessionActiveChange // com.compose.sdl.text.input/ImeBridge.onSessionActiveChange|{}onSessionActiveChange[0]
|
||||
final fun <get-onSessionActiveChange>(): kotlin/Function1<kotlin/Boolean, kotlin/Unit>? // com.compose.sdl.text.input/ImeBridge.onSessionActiveChange.<get-onSessionActiveChange>|<get-onSessionActiveChange>(){}[0]
|
||||
@@ -4854,7 +4829,6 @@ final val androidx.compose.ui/androidx_compose_ui_BiasAlignment_Vertical$stablep
|
||||
final val androidx.compose.ui/androidx_compose_ui_CombinedModifier$stableprop // androidx.compose.ui/androidx_compose_ui_CombinedModifier$stableprop|#static{}androidx_compose_ui_CombinedModifier$stableprop[0]
|
||||
final val androidx.compose.ui/androidx_compose_ui_ComposeUiFlags$stableprop // androidx.compose.ui/androidx_compose_ui_ComposeUiFlags$stableprop|#static{}androidx_compose_ui_ComposeUiFlags$stableprop[0]
|
||||
final val androidx.compose.ui/androidx_compose_ui_Modifier_Node$stableprop // androidx.compose.ui/androidx_compose_ui_Modifier_Node$stableprop|#static{}androidx_compose_ui_Modifier_Node$stableprop[0]
|
||||
final val com.compose.sdl.input/com_compose_sdl_input_LegacyPointerEvent$stableprop // com.compose.sdl.input/com_compose_sdl_input_LegacyPointerEvent$stableprop|#static{}com_compose_sdl_input_LegacyPointerEvent$stableprop[0]
|
||||
final val com.compose.sdl.layout/intOffset // com.compose.sdl.layout/intOffset|@androidx.compose.ui.layout.LayoutCoordinates{}intOffset[0]
|
||||
final fun (androidx.compose.ui.layout/LayoutCoordinates).<get-intOffset>(): androidx.compose.ui.unit/IntOffset // com.compose.sdl.layout/intOffset.<get-intOffset>|<get-intOffset>@androidx.compose.ui.layout.LayoutCoordinates(){}[0]
|
||||
final val com.compose.sdl.layout/x // com.compose.sdl.layout/x|@androidx.compose.ui.layout.LayoutCoordinates{}x[0]
|
||||
@@ -4862,14 +4836,9 @@ final val com.compose.sdl.layout/x // com.compose.sdl.layout/x|@androidx.compose
|
||||
final val com.compose.sdl.layout/y // com.compose.sdl.layout/y|@androidx.compose.ui.layout.LayoutCoordinates{}y[0]
|
||||
final fun (androidx.compose.ui.layout/LayoutCoordinates).<get-y>(): kotlin/Int // com.compose.sdl.layout/y.<get-y>|<get-y>@androidx.compose.ui.layout.LayoutCoordinates(){}[0]
|
||||
final val com.compose.sdl.node/com_compose_sdl_node_ComposeRootHost$stableprop // com.compose.sdl.node/com_compose_sdl_node_ComposeRootHost$stableprop|#static{}com_compose_sdl_node_ComposeRootHost$stableprop[0]
|
||||
final val com.compose.sdl.platform/com_compose_sdl_platform_DefaultViewConfiguration$stableprop // com.compose.sdl.platform/com_compose_sdl_platform_DefaultViewConfiguration$stableprop|#static{}com_compose_sdl_platform_DefaultViewConfiguration$stableprop[0]
|
||||
final val com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop|#static{}com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop[0]
|
||||
final val com.compose.sdl.text.input/com_compose_sdl_text_input_ImeBridge$stableprop // com.compose.sdl.text.input/com_compose_sdl_text_input_ImeBridge$stableprop|#static{}com_compose_sdl_text_input_ImeBridge$stableprop[0]
|
||||
final val com.compose.sdl.window/LocalPopupExitHandle // com.compose.sdl.window/LocalPopupExitHandle|{}LocalPopupExitHandle[0]
|
||||
final fun <get-LocalPopupExitHandle>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl.window/PopupExitHandle?> // com.compose.sdl.window/LocalPopupExitHandle.<get-LocalPopupExitHandle>|<get-LocalPopupExitHandle>(){}[0]
|
||||
final val com.compose.sdl.window/LocalPopupHost // com.compose.sdl.window/LocalPopupHost|{}LocalPopupHost[0]
|
||||
final fun <get-LocalPopupHost>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl.window/PopupHostState> // com.compose.sdl.window/LocalPopupHost.<get-LocalPopupHost>|<get-LocalPopupHost>(){}[0]
|
||||
final val com.compose.sdl.window/com_compose_sdl_window_PopupExitHandle$stableprop // com.compose.sdl.window/com_compose_sdl_window_PopupExitHandle$stableprop|#static{}com_compose_sdl_window_PopupExitHandle$stableprop[0]
|
||||
final val com.compose.sdl.window/com_compose_sdl_window_PopupHostState$stableprop // com.compose.sdl.window/com_compose_sdl_window_PopupHostState$stableprop|#static{}com_compose_sdl_window_PopupHostState$stableprop[0]
|
||||
final val com.compose.sdl/LocalComposeNativeWindow // com.compose.sdl/LocalComposeNativeWindow|{}LocalComposeNativeWindow[0]
|
||||
final fun <get-LocalComposeNativeWindow>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl/ComposeNativeWindow> // com.compose.sdl/LocalComposeNativeWindow.<get-LocalComposeNativeWindow>|<get-LocalComposeNativeWindow>(){}[0]
|
||||
@@ -4890,10 +4859,10 @@ final val com.compose.sdl/com_compose_sdl_AppEvent_WindowResized$stableprop // c
|
||||
final val com.compose.sdl/com_compose_sdl_ComposeNativeWindow$stableprop // com.compose.sdl/com_compose_sdl_ComposeNativeWindow$stableprop|#static{}com_compose_sdl_ComposeNativeWindow$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode$stableprop // com.compose.sdl/com_compose_sdl_GpuMode$stableprop|#static{}com_compose_sdl_GpuMode$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode_Auto$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Auto$stableprop|#static{}com_compose_sdl_GpuMode_Auto$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode_Skia$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Skia$stableprop|#static{}com_compose_sdl_GpuMode_Skia$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode_Skia_Metal$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Skia_Metal$stableprop|#static{}com_compose_sdl_GpuMode_Skia_Metal$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode_Skia_OpenGL$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Skia_OpenGL$stableprop|#static{}com_compose_sdl_GpuMode_Skia_OpenGL$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode_Software$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Software$stableprop|#static{}com_compose_sdl_GpuMode_Software$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode_CpuRaster$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_CpuRaster$stableprop|#static{}com_compose_sdl_GpuMode_CpuRaster$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode_Metal$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_Metal$stableprop|#static{}com_compose_sdl_GpuMode_Metal$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_GpuMode_OpenGL$stableprop // com.compose.sdl/com_compose_sdl_GpuMode_OpenGL$stableprop|#static{}com_compose_sdl_GpuMode_OpenGL$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_RawSdlHandles$stableprop // com.compose.sdl/com_compose_sdl_RawSdlHandles$stableprop|#static{}com_compose_sdl_RawSdlHandles$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_SDL3Backend$stableprop // com.compose.sdl/com_compose_sdl_SDL3Backend$stableprop|#static{}com_compose_sdl_SDL3Backend$stableprop[0]
|
||||
final val com.compose.sdl/com_compose_sdl_TextLayoutConfig$stableprop // com.compose.sdl/com_compose_sdl_TextLayoutConfig$stableprop|#static{}com_compose_sdl_TextLayoutConfig$stableprop[0]
|
||||
|
||||
@@ -5389,16 +5358,10 @@ final fun androidx.compose.ui/androidx_compose_ui_CombinedModifier$stableprop_ge
|
||||
final fun androidx.compose.ui/androidx_compose_ui_ComposeUiFlags$stableprop_getter(): kotlin/Int // androidx.compose.ui/androidx_compose_ui_ComposeUiFlags$stableprop_getter|androidx_compose_ui_ComposeUiFlags$stableprop_getter(){}[0]
|
||||
final fun androidx.compose.ui/androidx_compose_ui_Modifier_Node$stableprop_getter(): kotlin/Int // androidx.compose.ui/androidx_compose_ui_Modifier_Node$stableprop_getter|androidx_compose_ui_Modifier_Node$stableprop_getter(){}[0]
|
||||
final fun androidx.compose.ui/isUiSystemInDarkTheme(androidx.compose.runtime/Composer?, kotlin/Int): kotlin/Boolean // androidx.compose.ui/isUiSystemInDarkTheme|isUiSystemInDarkTheme(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.input/com_compose_sdl_input_LegacyPointerEvent$stableprop_getter(): kotlin/Int // com.compose.sdl.input/com_compose_sdl_input_LegacyPointerEvent$stableprop_getter|com_compose_sdl_input_LegacyPointerEvent$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.node/com_compose_sdl_node_ComposeRootHost$stableprop_getter(): kotlin/Int // com.compose.sdl.node/com_compose_sdl_node_ComposeRootHost$stableprop_getter|com_compose_sdl_node_ComposeRootHost$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.platform/com_compose_sdl_platform_DefaultViewConfiguration$stableprop_getter(): kotlin/Int // com.compose.sdl.platform/com_compose_sdl_platform_DefaultViewConfiguration$stableprop_getter|com_compose_sdl_platform_DefaultViewConfiguration$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop_getter(): kotlin/Int // com.compose.sdl.renderer.skia/com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop_getter|com_compose_sdl_renderer_skia_SkiaSurfaceBridge$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.text.input/com_compose_sdl_text_input_ImeBridge$stableprop_getter(): kotlin/Int // com.compose.sdl.text.input/com_compose_sdl_text_input_ImeBridge$stableprop_getter|com_compose_sdl_text_input_ImeBridge$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.text/registerGenericFonts() // com.compose.sdl.text/registerGenericFonts|registerGenericFonts(){}[0]
|
||||
final fun com.compose.sdl.window/PopupLayer(com.compose.sdl.window/PopupHostState, androidx.compose.runtime/Composer?, kotlin/Int) // com.compose.sdl.window/PopupLayer|PopupLayer(com.compose.sdl.window.PopupHostState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.window/PopupOutsideDismiss(kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Function0<kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // com.compose.sdl.window/PopupOutsideDismiss|PopupOutsideDismiss(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Function0<kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.window/PositionedPopup(kotlin/Int, kotlin/Int, kotlin/Function0<kotlin/Unit>, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // com.compose.sdl.window/PositionedPopup|PositionedPopup(kotlin.Int;kotlin.Int;kotlin.Function0<kotlin.Unit>;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final fun com.compose.sdl.window/com_compose_sdl_window_PopupExitHandle$stableprop_getter(): kotlin/Int // com.compose.sdl.window/com_compose_sdl_window_PopupExitHandle$stableprop_getter|com_compose_sdl_window_PopupExitHandle$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.window/com_compose_sdl_window_PopupHostState$stableprop_getter(): kotlin/Int // com.compose.sdl.window/com_compose_sdl_window_PopupHostState$stableprop_getter|com_compose_sdl_window_PopupHostState$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl.window/createPopupHostState(): com.compose.sdl.window/PopupHostState // com.compose.sdl.window/createPopupHostState|createPopupHostState(){}[0]
|
||||
final fun com.compose.sdl/appDataDir(kotlin/String, kotlin/String): kotlin/String? // com.compose.sdl/appDataDir|appDataDir(kotlin.String;kotlin.String){}[0]
|
||||
@@ -5420,10 +5383,10 @@ final fun com.compose.sdl/com_compose_sdl_AppEvent_WindowResized$stableprop_gett
|
||||
final fun com.compose.sdl/com_compose_sdl_ComposeNativeWindow$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_ComposeNativeWindow$stableprop_getter|com_compose_sdl_ComposeNativeWindow$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode$stableprop_getter|com_compose_sdl_GpuMode$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode_Auto$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Auto$stableprop_getter|com_compose_sdl_GpuMode_Auto$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode_Skia$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Skia$stableprop_getter|com_compose_sdl_GpuMode_Skia$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode_Skia_Metal$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Skia_Metal$stableprop_getter|com_compose_sdl_GpuMode_Skia_Metal$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode_Skia_OpenGL$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Skia_OpenGL$stableprop_getter|com_compose_sdl_GpuMode_Skia_OpenGL$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode_Software$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Software$stableprop_getter|com_compose_sdl_GpuMode_Software$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode_CpuRaster$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_CpuRaster$stableprop_getter|com_compose_sdl_GpuMode_CpuRaster$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode_Metal$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_Metal$stableprop_getter|com_compose_sdl_GpuMode_Metal$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_GpuMode_OpenGL$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_GpuMode_OpenGL$stableprop_getter|com_compose_sdl_GpuMode_OpenGL$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_RawSdlHandles$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_RawSdlHandles$stableprop_getter|com_compose_sdl_RawSdlHandles$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_SDL3Backend$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_SDL3Backend$stableprop_getter|com_compose_sdl_SDL3Backend$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/com_compose_sdl_TextLayoutConfig$stableprop_getter(): kotlin/Int // com.compose.sdl/com_compose_sdl_TextLayoutConfig$stableprop_getter|com_compose_sdl_TextLayoutConfig$stableprop_getter(){}[0]
|
||||
final fun com.compose.sdl/createRenderBackend(com.compose.sdl/SDL3Backend, com.compose.sdl/GpuMode): com.compose.sdl/RenderBackend? // com.compose.sdl/createRenderBackend|createRenderBackend(com.compose.sdl.SDL3Backend;com.compose.sdl.GpuMode){}[0]
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.compose.ui.unit.DpSize
|
||||
* StubOwner). Constant values match the upstream `PlatformContext.DefaultViewConfiguration`
|
||||
* (longPressTimeout=500ms, doubleTapTimeout=300ms, doubleTapMinTime=40ms, touchSlop=8f).
|
||||
*/
|
||||
object DefaultViewConfiguration : ViewConfiguration {
|
||||
internal object DefaultViewConfiguration : ViewConfiguration {
|
||||
override val longPressTimeoutMillis: Long = 500
|
||||
override val doubleTapTimeoutMillis: Long = 300
|
||||
override val doubleTapMinTimeMillis: Long = 40
|
||||
|
||||
@@ -98,23 +98,23 @@ class PopupHostState internal constructor() {
|
||||
fun contains(inX: Int, inY: Int) = inX >= x && inX < x + w && inY >= y && inY < y + h
|
||||
}
|
||||
|
||||
private val fDismissers = mutableListOf<Dismisser>()
|
||||
private val mDismissers = mutableListOf<Dismisser>()
|
||||
|
||||
internal fun setDismisser(inId: Any, inX: Int, inY: Int, inW: Int, inH: Int, inOnDismiss: () -> Unit) {
|
||||
val vD = fDismissers.firstOrNull { it.id === inId } ?: Dismisser(inId).also { fDismissers.add(it) }
|
||||
val vD = mDismissers.firstOrNull { it.id === inId } ?: Dismisser(inId).also { mDismissers.add(it) }
|
||||
vD.x = inX; vD.y = inY; vD.w = inW; vD.h = inH; vD.onDismiss = inOnDismiss
|
||||
}
|
||||
|
||||
internal fun removeDismisser(inId: Any) {
|
||||
fDismissers.removeAll { it.id === inId }
|
||||
mDismissers.removeAll { it.id === inId }
|
||||
}
|
||||
|
||||
/** Dismiss every registered popup whose content rect does NOT contain the
|
||||
press. Called from the window's press dispatch; never consumes the press. */
|
||||
fun notifyOutsidePress(inX: Int, inY: Int) {
|
||||
if (fDismissers.isEmpty()) return
|
||||
if (mDismissers.isEmpty()) return
|
||||
// Copy: an onDismiss typically removes the dismisser (state write).
|
||||
for (vD in fDismissers.toList()) {
|
||||
for (vD in mDismissers.toList()) {
|
||||
if (vD.w > 0 && vD.h > 0 && !vD.contains(inX, inY)) vD.onDismiss()
|
||||
}
|
||||
}
|
||||
@@ -139,26 +139,26 @@ fun createPopupHostState(): PopupHostState = PopupHostState()
|
||||
the entry. The hosted content (still composed - it lives in the HOST's
|
||||
composition, not the owner's) observes that, plays its animation, and MUST
|
||||
end with [finish], which actually removes the entry. */
|
||||
class PopupExitHandle internal constructor(
|
||||
private val fHost: PopupHostState, // owning host - target of finish()
|
||||
private val fEntry: PopupHostState.Entry, // the entry this handle controls
|
||||
internal class PopupExitHandle internal constructor(
|
||||
private val mHost: PopupHostState, // owning host - target of finish()
|
||||
private val mEntry: PopupHostState.Entry, // the entry this handle controls
|
||||
) {
|
||||
/** True once the owning Popup left the composition and the host is waiting
|
||||
for this entry's exit animation. Observable - recomposes the content. */
|
||||
val isExiting: State<Boolean> get() = fEntry.exiting
|
||||
val isExiting: State<Boolean> get() = mEntry.exiting
|
||||
|
||||
/** Opt in to exit deferral. Idempotent; call from a SideEffect. */
|
||||
fun enableExitTransition() { fEntry.hasExitTransition = true }
|
||||
fun enableExitTransition() { mEntry.hasExitTransition = true }
|
||||
|
||||
/** Ends the deferral - the entry is removed from the host for real. */
|
||||
fun finish() { fHost.forceRemove(fEntry.id) }
|
||||
fun finish() { mHost.forceRemove(mEntry.id) }
|
||||
}
|
||||
|
||||
/** Per-entry handle, provided by PopupLayer around each hosted content. Null
|
||||
outside a popup layer. NB: safe to expose through the caller-locals rewrap
|
||||
in Popup (CompositionLocalProvider(callerContext)) - the caller never
|
||||
provides this local, so the layer's per-entry value stays visible. */
|
||||
val LocalPopupExitHandle = staticCompositionLocalOf<PopupExitHandle?> { null }
|
||||
internal val LocalPopupExitHandle = staticCompositionLocalOf<PopupExitHandle?> { null }
|
||||
|
||||
// ==================
|
||||
// MARK: PopupLayer
|
||||
@@ -187,7 +187,7 @@ fun PopupLayer(inHost: PopupHostState) {
|
||||
popup host so the dismissing press is NOT consumed (it still reaches whatever
|
||||
is under it - no dead first click). */
|
||||
@Composable
|
||||
fun PopupOutsideDismiss(inX: Int, inY: Int, inW: Int, inH: Int, onDismissRequest: () -> Unit) {
|
||||
internal fun PopupOutsideDismiss(inX: Int, inY: Int, inW: Int, inH: Int, onDismissRequest: () -> Unit) {
|
||||
val vHost = LocalPopupHost.current
|
||||
val vId = remember { Any() }
|
||||
SideEffect { vHost.setDismisser(vId, inX, inY, inW, inH, onDismissRequest) }
|
||||
@@ -203,7 +203,7 @@ fun PopupOutsideDismiss(inX: Int, inY: Int, inW: Int, inH: Int, onDismissRequest
|
||||
`x`/`y` are LAYOUT PIXELS (matches `LayoutCoordinates.positionInRoot`), not
|
||||
`Dp` - layout runs in physical pixels under the Option-B density flow. */
|
||||
@Composable
|
||||
fun PositionedPopup(
|
||||
internal fun PositionedPopup(
|
||||
x: Int,
|
||||
y: Int,
|
||||
onDismissRequest: () -> Unit,
|
||||
|
||||
+15
-15
@@ -103,14 +103,14 @@ private const val kImageMime = "image/png"
|
||||
// call with an image). Held on nativeHeap because SDL keeps the callback alive
|
||||
// until the clipboard is cleared or another SDL_SetClipboardData replaces us -
|
||||
// a Kotlin heap reference would break as soon as the caller's coroutine
|
||||
// returned. Read by [fClipboardDataCallback], freed by [fClipboardCleanupCallback].
|
||||
private var fOutgoingImagePtr: CPointer<ByteVar>? = null
|
||||
private var fOutgoingImageSize: Int = 0
|
||||
// returned. Read by [mClipboardDataCallback], freed by [mClipboardCleanupCallback].
|
||||
private var mOutgoingImagePtr: CPointer<ByteVar>? = null
|
||||
private var mOutgoingImageSize: Int = 0
|
||||
|
||||
// SDL fires this on the main event thread when another app asks the clipboard
|
||||
// for a MIME we advertised. We serve back the pointer + size we stashed at
|
||||
// SDL_SetClipboardData time. Return NULL if the MIME doesn't match.
|
||||
private val fClipboardDataCallback = staticCFunction {
|
||||
private val mClipboardDataCallback = staticCFunction {
|
||||
userdata: COpaquePointer?,
|
||||
mimeType: CPointer<ByteVar>?,
|
||||
sizePtr: CPointer<platform.posix.size_tVar>?,
|
||||
@@ -118,20 +118,20 @@ private val fClipboardDataCallback = staticCFunction {
|
||||
userdata.hashCode() // unused
|
||||
val vRequested = mimeType?.toKString() ?: return@staticCFunction null
|
||||
if (vRequested != kImageMime) return@staticCFunction null
|
||||
val vPtr = fOutgoingImagePtr ?: return@staticCFunction null
|
||||
sizePtr?.pointed?.value = fOutgoingImageSize.toULong()
|
||||
val vPtr = mOutgoingImagePtr ?: return@staticCFunction null
|
||||
sizePtr?.pointed?.value = mOutgoingImageSize.toULong()
|
||||
vPtr as COpaquePointer
|
||||
}
|
||||
|
||||
// SDL calls this when the clipboard we set is cleared or overwritten (by us
|
||||
// or another app). Frees the nativeHeap allocation.
|
||||
private val fClipboardCleanupCallback = staticCFunction {
|
||||
private val mClipboardCleanupCallback = staticCFunction {
|
||||
userdata: COpaquePointer?,
|
||||
->
|
||||
userdata.hashCode() // unused
|
||||
fOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
|
||||
fOutgoingImagePtr = null
|
||||
fOutgoingImageSize = 0
|
||||
mOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
|
||||
mOutgoingImagePtr = null
|
||||
mOutgoingImageSize = 0
|
||||
Unit
|
||||
}
|
||||
|
||||
@@ -172,19 +172,19 @@ private fun sdlReadImage(): ByteArray? = memScoped {
|
||||
private fun sdlWriteImage(bytes: ByteArray) {
|
||||
// Free any previously-held buffer proactively (the cleanup callback also
|
||||
// fires from SDL, but doing it eagerly here handles same-thread re-set).
|
||||
fOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
|
||||
mOutgoingImagePtr?.let { nativeHeap.free(it.rawValue) }
|
||||
|
||||
val vPtr = nativeHeap.allocArray<ByteVar>(bytes.size)
|
||||
for (i in bytes.indices) vPtr[i] = bytes[i]
|
||||
fOutgoingImagePtr = vPtr
|
||||
fOutgoingImageSize = bytes.size
|
||||
mOutgoingImagePtr = vPtr
|
||||
mOutgoingImageSize = bytes.size
|
||||
|
||||
memScoped {
|
||||
val vMimeArray = allocArray<CPointerVar<ByteVar>>(1)
|
||||
vMimeArray[0] = kImageMime.cstr.ptr
|
||||
SDL_SetClipboardData(
|
||||
fClipboardDataCallback,
|
||||
fClipboardCleanupCallback,
|
||||
mClipboardDataCallback,
|
||||
mClipboardCleanupCallback,
|
||||
null,
|
||||
vMimeArray,
|
||||
1.toULong(),
|
||||
|
||||
+4
-4
@@ -18,12 +18,12 @@ import androidx.compose.ui.unit.IntRect
|
||||
* no-ops.
|
||||
*/
|
||||
private class StubSemanticsRegion : SemanticsRegion {
|
||||
private var fBounds: IntRect = IntRect.Zero
|
||||
override fun set(rect: IntRect) { fBounds = rect }
|
||||
private var mBounds: IntRect = IntRect.Zero
|
||||
override fun set(rect: IntRect) { mBounds = rect }
|
||||
override fun intersect(region: SemanticsRegion): Boolean = false
|
||||
override fun difference(rect: IntRect): Boolean = false
|
||||
override val bounds: IntRect get() = fBounds
|
||||
override val isEmpty: Boolean get() = fBounds == IntRect.Zero
|
||||
override val bounds: IntRect get() = mBounds
|
||||
override val isEmpty: Boolean get() = mBounds == IntRect.Zero
|
||||
}
|
||||
|
||||
internal actual fun SemanticsRegion(): SemanticsRegion = StubSemanticsRegion()
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import kotlinx.cinterop.COpaquePointer
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.cinterop.toKString
|
||||
import sdl3.SDL_GetRendererName
|
||||
@@ -12,7 +13,12 @@ import sdl3.SDL_MaximizeWindow
|
||||
import sdl3.SDL_MinimizeWindow
|
||||
import sdl3.SDL_RaiseWindow
|
||||
import sdl3.SDL_RestoreWindow
|
||||
import sdl3.SDL_SetWindowAlwaysOnTop
|
||||
import sdl3.SDL_SetWindowBordered
|
||||
import sdl3.SDL_SetWindowFocusable
|
||||
import sdl3.SDL_SetWindowFullscreen
|
||||
import sdl3.SDL_SetWindowOpacity
|
||||
import sdl3.SDL_SetWindowResizable
|
||||
import sdl3.SDL_SetWindowSize
|
||||
import sdl3.SDL_SetWindowTitle
|
||||
|
||||
@@ -34,42 +40,42 @@ class ComposeNativeWindow constructor(
|
||||
val gpuMode: GpuMode,
|
||||
initialTitle: String,
|
||||
) {
|
||||
private var fWidth by mutableStateOf(backend.windowWidth)
|
||||
private var fHeight by mutableStateOf(backend.windowHeight)
|
||||
private var fPixelWidth by mutableStateOf(backend.pixelWidth)
|
||||
private var fPixelHeight by mutableStateOf(backend.pixelHeight)
|
||||
private var fTitle by mutableStateOf(initialTitle)
|
||||
private var fMinimized by mutableStateOf(false)
|
||||
private var fMaximized by mutableStateOf(false)
|
||||
private var fFullscreen by mutableStateOf(false)
|
||||
private var fCloseRequested by mutableStateOf(false)
|
||||
private var fFps by mutableStateOf(0)
|
||||
private var mWidth by mutableStateOf(backend.windowWidth)
|
||||
private var mHeight by mutableStateOf(backend.windowHeight)
|
||||
private var mPixelWidth by mutableStateOf(backend.pixelWidth)
|
||||
private var mPixelHeight by mutableStateOf(backend.pixelHeight)
|
||||
private var mTitle by mutableStateOf(initialTitle)
|
||||
private var mMinimized by mutableStateOf(false)
|
||||
private var mMaximized by mutableStateOf(false)
|
||||
private var mFullscreen by mutableStateOf(false)
|
||||
private var mCloseRequested by mutableStateOf(false)
|
||||
private var mFps by mutableStateOf(0)
|
||||
|
||||
// ============
|
||||
// State
|
||||
|
||||
/** Logical (point) width - the same units layout uses. */
|
||||
val width: Int get() = fWidth
|
||||
val height: Int get() = fHeight
|
||||
val width: Int get() = mWidth
|
||||
val height: Int get() = mHeight
|
||||
/** Physical (pixel) back-buffer size; on Retina this is `width * pixelDensity`. */
|
||||
val pixelWidth: Int get() = fPixelWidth
|
||||
val pixelHeight: Int get() = fPixelHeight
|
||||
val title: String get() = fTitle
|
||||
val isMinimized: Boolean get() = fMinimized
|
||||
val isMaximized: Boolean get() = fMaximized
|
||||
val isFullscreen: Boolean get() = fFullscreen
|
||||
val pixelWidth: Int get() = mPixelWidth
|
||||
val pixelHeight: Int get() = mPixelHeight
|
||||
val title: String get() = mTitle
|
||||
val isMinimized: Boolean get() = mMinimized
|
||||
val isMaximized: Boolean get() = mMaximized
|
||||
val isFullscreen: Boolean get() = mFullscreen
|
||||
val pixelDensity: Float get() = backend.pixelDensity
|
||||
/** Frames per second, refreshed ~once a second by the render loop. Snapshot-
|
||||
backed, so reading it in a composable recomposes when it changes. */
|
||||
val fps: Int get() = fFps
|
||||
val fps: Int get() = mFps
|
||||
|
||||
/** Human-readable name of the rendering pipeline:
|
||||
"Skia / Metal", "Skia / OpenGL", "Skia / CPU raster". */
|
||||
val rendererName: String
|
||||
get() = when (gpuMode) {
|
||||
is GpuMode.Skia.Metal -> "Skia / Metal"
|
||||
is GpuMode.Skia.OpenGL -> "Skia / OpenGL"
|
||||
is GpuMode.Software -> "Skia / CPU raster"
|
||||
is GpuMode.Metal -> "Skia / Metal"
|
||||
is GpuMode.OpenGL -> "Skia / OpenGL"
|
||||
is GpuMode.CpuRaster -> "Skia / CPU raster"
|
||||
is GpuMode.Auto -> "Auto (unresolved)"
|
||||
}
|
||||
|
||||
@@ -79,7 +85,7 @@ class ComposeNativeWindow constructor(
|
||||
fun setTitle(inTitle: String) {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_SetWindowTitle(vWindow, inTitle)
|
||||
fTitle = inTitle
|
||||
mTitle = inTitle
|
||||
}
|
||||
|
||||
/** Logical size in points - SDL fires AppEvent.WindowResized which
|
||||
@@ -91,56 +97,111 @@ class ComposeNativeWindow constructor(
|
||||
|
||||
fun minimize() {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_MinimizeWindow(vWindow); fMinimized = true
|
||||
SDL_MinimizeWindow(vWindow); mMinimized = true
|
||||
}
|
||||
|
||||
fun maximize() {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_MaximizeWindow(vWindow); fMaximized = true; fMinimized = false
|
||||
SDL_MaximizeWindow(vWindow); mMaximized = true; mMinimized = false
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_RestoreWindow(vWindow); fMaximized = false; fMinimized = false
|
||||
SDL_RestoreWindow(vWindow); mMaximized = false; mMinimized = false
|
||||
}
|
||||
|
||||
fun setFullscreen(inFullscreen: Boolean) {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_SetWindowFullscreen(vWindow, inFullscreen); fFullscreen = inFullscreen
|
||||
SDL_SetWindowFullscreen(vWindow, inFullscreen); mFullscreen = inFullscreen
|
||||
}
|
||||
|
||||
fun toggleFullscreen() = setFullscreen(!fFullscreen)
|
||||
fun toggleFullscreen() = setFullscreen(!mFullscreen)
|
||||
|
||||
fun raise() {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_RaiseWindow(vWindow)
|
||||
}
|
||||
|
||||
// ============
|
||||
// Mutable window attributes (Compose Desktop's Window() parameters)
|
||||
//
|
||||
// Window() re-applies these from the composition whenever they change, so
|
||||
// app code normally passes them as parameters rather than calling these.
|
||||
// `transparent` is absent on purpose: SDL needs SDL_WINDOW_TRANSPARENT at
|
||||
// creation and offers no setter, so it is a construction-time attribute.
|
||||
|
||||
private var mUndecorated = false
|
||||
private var mResizable = true
|
||||
private var mAlwaysOnTop = false
|
||||
private var mFocusable = true
|
||||
private var mEnabled = true
|
||||
|
||||
val isUndecorated: Boolean get() = mUndecorated
|
||||
val isResizable: Boolean get() = mResizable
|
||||
val isAlwaysOnTop: Boolean get() = mAlwaysOnTop
|
||||
val isFocusable: Boolean get() = mFocusable
|
||||
|
||||
/** False hides the window's title bar and border. */
|
||||
fun setUndecorated(inUndecorated: Boolean) {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_SetWindowBordered(vWindow, !inUndecorated); mUndecorated = inUndecorated
|
||||
}
|
||||
|
||||
fun setResizable(inResizable: Boolean) {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_SetWindowResizable(vWindow, inResizable); mResizable = inResizable
|
||||
}
|
||||
|
||||
fun setAlwaysOnTop(inAlwaysOnTop: Boolean) {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_SetWindowAlwaysOnTop(vWindow, inAlwaysOnTop); mAlwaysOnTop = inAlwaysOnTop
|
||||
}
|
||||
|
||||
/** False stops the window taking keyboard focus when clicked or raised. */
|
||||
fun setFocusable(inFocusable: Boolean) {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_SetWindowFocusable(vWindow, inFocusable); mFocusable = inFocusable
|
||||
}
|
||||
|
||||
/** Window opacity, 0f (fully transparent) to 1f. Needs a compositing window
|
||||
manager; SDL reports failure on platforms without one. */
|
||||
fun setOpacity(inOpacity: Float) {
|
||||
val vWindow = backend.window?.reinterpret<cnames.structs.SDL_Window>() ?: return
|
||||
SDL_SetWindowOpacity(vWindow, inOpacity.coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
/** True while the window accepts input. A disabled window still renders and
|
||||
repaints; it just drops pointer / keyboard / text / wheel / drop events,
|
||||
which is how Compose Desktop's `enabled = false` behaves. */
|
||||
val isEnabled: Boolean get() = mEnabled
|
||||
|
||||
fun setEnabled(inEnabled: Boolean) { mEnabled = inEnabled }
|
||||
|
||||
/** Asks composeWindow's main loop to break out at the next frame.
|
||||
Same effect as the user closing the window via the OS. Bypasses any
|
||||
onCloseRequest handler - this is the "really quit now" path. */
|
||||
fun close() { fCloseRequested = true }
|
||||
fun close() { mCloseRequested = true }
|
||||
|
||||
// ============
|
||||
// Close interception
|
||||
|
||||
private var fOnCloseRequest: (() -> Boolean)? = null
|
||||
private var mOnCloseRequest: (() -> Boolean)? = null
|
||||
|
||||
/** Register a handler invoked when the user tries to close the window (OS
|
||||
close button / Quit). Return true to let the close proceed, false to veto
|
||||
it (e.g. to show an "unsaved changes" dialog first, then call close()
|
||||
once the user confirms). Pass null to clear. */
|
||||
fun setOnCloseRequest(inHandler: (() -> Boolean)?) { fOnCloseRequest = inHandler }
|
||||
fun setOnCloseRequest(inHandler: (() -> Boolean)?) { mOnCloseRequest = inHandler }
|
||||
|
||||
// ============
|
||||
// Global key shortcuts
|
||||
|
||||
private var fOnKeyShortcut: ((KeyEvent) -> Boolean)? = null
|
||||
private var mOnKeyShortcut: ((KeyEvent) -> Boolean)? = null
|
||||
|
||||
/** Register a handler for key events the focused node didn't consume - for
|
||||
app-wide shortcuts (Ctrl+S, etc.). Receives every unconsumed key (and all
|
||||
keys when nothing is focused). Return true if handled. Pass null to clear. */
|
||||
fun setOnKeyShortcut(inHandler: ((KeyEvent) -> Boolean)?) { fOnKeyShortcut = inHandler }
|
||||
fun setOnKeyShortcut(inHandler: ((KeyEvent) -> Boolean)?) { mOnKeyShortcut = inHandler }
|
||||
|
||||
// ============
|
||||
// Framework hooks - driven by composeWindow's main loop (which lives in
|
||||
@@ -148,26 +209,71 @@ class ComposeNativeWindow constructor(
|
||||
// intended for app code.
|
||||
|
||||
fun onResized() {
|
||||
fWidth = backend.windowWidth
|
||||
fHeight = backend.windowHeight
|
||||
fPixelWidth = backend.pixelWidth
|
||||
fPixelHeight = backend.pixelHeight
|
||||
mWidth = backend.windowWidth
|
||||
mHeight = backend.windowHeight
|
||||
mPixelWidth = backend.pixelWidth
|
||||
mPixelHeight = backend.pixelHeight
|
||||
}
|
||||
|
||||
/** Called by the main loop ~once a second with the measured frame rate. */
|
||||
fun updateFps(inFps: Int) { fFps = inFps }
|
||||
fun updateFps(inFps: Int) { mFps = inFps }
|
||||
|
||||
val isCloseRequested: Boolean get() = fCloseRequested
|
||||
val isCloseRequested: Boolean get() = mCloseRequested
|
||||
|
||||
/** Driven by composeWindow's main loop on an OS close / Quit event. Returns
|
||||
true if the close should proceed (no handler, or the handler allowed it). */
|
||||
fun requestCloseFromUser(): Boolean = fOnCloseRequest?.invoke() ?: true
|
||||
fun requestCloseFromUser(): Boolean = mOnCloseRequest?.invoke() ?: true
|
||||
|
||||
/** Driven by composeWindow's main loop for keys the focused node didn't
|
||||
consume. Returns true if the shortcut handler handled it. */
|
||||
fun dispatchKeyShortcut(inEvent: KeyEvent): Boolean = fOnKeyShortcut?.invoke(inEvent) ?: false
|
||||
fun dispatchKeyShortcut(inEvent: KeyEvent): Boolean = mOnKeyShortcut?.invoke(inEvent) ?: false
|
||||
|
||||
// ============
|
||||
// Raw SDL escape hatch
|
||||
|
||||
/**
|
||||
The live SDL handles behind this window, for calling the `sdl3.*` cinterop
|
||||
directly when the Compose-level API doesn't cover something.
|
||||
|
||||
Returns a snapshot: the pointers are only valid while the window is alive,
|
||||
so re-read it rather than caching, and never use it after the window closes.
|
||||
Whatever you do through it is outside the port's control - resizing,
|
||||
destroying or re-creating the window or its renderer behind Compose's back
|
||||
will desync the renderer state.
|
||||
*/
|
||||
fun rawSdlHandles(): RawSdlHandles = RawSdlHandles(
|
||||
window = backend.window,
|
||||
renderer = backend.renderer,
|
||||
glContext = backend.glContext,
|
||||
metalView = backend.metalView,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
Opaque SDL pointers for one window, as handed out by
|
||||
[ComposeNativeWindow.rawSdlHandles].
|
||||
|
||||
`reinterpret()` each to the `cnames.structs.*` type the SDL function expects:
|
||||
|
||||
val vHandles = window.rawSdlHandles()
|
||||
vHandles.window?.let { sdl3.SDL_FlashWindow(it.reinterpret(), FLASH_BRIEFLY) }
|
||||
|
||||
Which pointers are non-null depends on the resolved [GpuMode]: `renderer` is
|
||||
set only for the CPU-raster (Software) backend, `glContext` only for Skia
|
||||
OpenGL, and `metalView` only for Skia Metal. `window` is always set on a
|
||||
successfully created window.
|
||||
*/
|
||||
data class RawSdlHandles(
|
||||
/** `SDL_Window*` - always present while the window lives. */
|
||||
val window: COpaquePointer?,
|
||||
/** `SDL_Renderer*` - CPU-raster (Software) backend only. */
|
||||
val renderer: COpaquePointer?,
|
||||
/** `SDL_GLContext` - Skia OpenGL backend only. */
|
||||
val glContext: COpaquePointer?,
|
||||
/** `SDL_MetalView` - Skia Metal backend only. */
|
||||
val metalView: COpaquePointer?,
|
||||
)
|
||||
|
||||
// ==================
|
||||
// MARK: Scope + CompositionLocal
|
||||
// ==================
|
||||
|
||||
@@ -15,7 +15,7 @@ import sdl3.SDL_ShowSaveFileDialog
|
||||
|
||||
The C callback can't capture Kotlin state, so the result handler is parked in
|
||||
a StableRef passed as the dialog's `userdata` and disposed inside the callback. */
|
||||
private val fDialogCallback = staticCFunction { inUserData: COpaquePointer?, inFileList: CPointer<CPointerVar<ByteVar>>?, inFilter: Int ->
|
||||
private val mDialogCallback = staticCFunction { inUserData: COpaquePointer?, inFileList: CPointer<CPointerVar<ByteVar>>?, inFilter: Int ->
|
||||
inFilter.hashCode() // unused (selected filter index)
|
||||
val vRef = inUserData?.asStableRef<(String?) -> Unit>() ?: return@staticCFunction
|
||||
val vHandler = vRef.get()
|
||||
@@ -31,7 +31,7 @@ private val fDialogCallback = staticCFunction { inUserData: COpaquePointer?, inF
|
||||
fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> Unit) {
|
||||
val vRef = StableRef.create(inOnResult)
|
||||
SDL_ShowSaveFileDialog(
|
||||
fDialogCallback,
|
||||
mDialogCallback,
|
||||
vRef.asCPointer(),
|
||||
null, // parent window (null = unparented)
|
||||
null, // no file-type filters
|
||||
@@ -45,7 +45,7 @@ fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> U
|
||||
fun showOpenFileDialog(inOnResult: (String?) -> Unit) {
|
||||
val vRef = StableRef.create(inOnResult)
|
||||
SDL_ShowOpenFileDialog(
|
||||
fDialogCallback,
|
||||
mDialogCallback,
|
||||
vRef.asCPointer(),
|
||||
null, // parent window
|
||||
null, // filters
|
||||
|
||||
@@ -4,28 +4,38 @@ package com.compose.sdl
|
||||
// MARK: GpuMode
|
||||
// ==================
|
||||
|
||||
/** Which GPU path the Skia renderer uses.
|
||||
*
|
||||
* ┌─ Auto - pick the best for this platform (Metal on macOS, OpenGL on
|
||||
* │ Linux, OpenGL on Windows). If the GPU context/bridge can't be
|
||||
* │ created, ComposeWindow falls back to Software.
|
||||
* ├─ Software - Skia CPU raster: paints a host pixel buffer that SDL_Renderer
|
||||
* │ uploads as a texture each frame. No GPU context needed.
|
||||
* └─ Skia.* - Skia GPU bridges
|
||||
* ├─ OpenGL - Skia GL backend on an SDL OpenGL (WGL/GLX) context
|
||||
* └─ Metal - Skia Metal on a CAMetalLayer via SDL_Metal_CreateView (macOS)
|
||||
*
|
||||
* AUTO resolves via rendererPreferredGpuMode() per target, at composeWindow entry. */
|
||||
/**
|
||||
Which drawing path the renderer uses.
|
||||
|
||||
Every mode is Skia - the port renders through Skia on all targets. The axis
|
||||
here is only how Skia's output reaches the screen:
|
||||
|
||||
Auto resolve per platform at window creation (Metal on macOS, OpenGL
|
||||
on Linux and Windows). If the GPU context can't be created, the
|
||||
window falls back to CpuRaster rather than failing to open.
|
||||
Metal Skia's Metal backend on a CAMetalLayer (SDL_Metal_CreateView), macOS.
|
||||
OpenGL Skia's GL backend on an SDL OpenGL (WGL/GLX) context.
|
||||
CpuRaster Skia paints a host pixel buffer that SDL_Renderer uploads as a
|
||||
texture each frame. No GPU context needed.
|
||||
|
||||
This used to read `Software` vs a nested `Skia.*` group, which dated from when
|
||||
the alternative to Skia was SDL's own renderer. With SDL reduced to windowing
|
||||
that split was actively misleading - "Software" was always Skia too.
|
||||
*/
|
||||
sealed class GpuMode {
|
||||
/** Let the platform pick: Skia.Metal on macOS, Skia.OpenGL on Linux/Windows. */
|
||||
/** Let the platform pick: [Metal] on macOS, [OpenGL] on Linux and Windows. */
|
||||
object Auto : GpuMode() { override fun toString() = "Auto" }
|
||||
|
||||
/** Skia CPU raster - a host pixel buffer uploaded via SDL_Renderer each frame. */
|
||||
object Software : GpuMode() { override fun toString() = "Software" }
|
||||
/** Skia Metal on a CAMetalLayer via SDL_Metal_CreateView (macOS only). */
|
||||
object Metal : GpuMode() { override fun toString() = "Metal" }
|
||||
|
||||
/** Skia GPU bridges. */
|
||||
sealed class Skia : GpuMode() {
|
||||
object OpenGL : Skia() { override fun toString() = "Skia.OpenGL" }
|
||||
object Metal : Skia() { override fun toString() = "Skia.Metal" }
|
||||
}
|
||||
/** Skia GL on an SDL OpenGL context (WGL on Windows, GLX on Linux). */
|
||||
object OpenGL : GpuMode() { override fun toString() = "OpenGL" }
|
||||
|
||||
/** Skia CPU raster - a host pixel buffer uploaded via SDL_Renderer each frame. */
|
||||
object CpuRaster : GpuMode() { override fun toString() = "CpuRaster" }
|
||||
|
||||
/** True for the GPU-backed modes. [Auto] is false because it is unresolved:
|
||||
ask after the window has resolved it, via ComposeNativeWindow.gpuMode. */
|
||||
val isGpuAccelerated: Boolean get() = this is Metal || this is OpenGL
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import sdl3.SDL_OpenURL
|
||||
UI until the file manager was up. None of the three platform backends
|
||||
requires the main thread, so a small background scope launches them and
|
||||
(optionally) posts the result back to Main. */
|
||||
private val fOpenExternalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val mOpenExternalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/** Opens a URL or file:// URI with the OS's default handler (browser, file
|
||||
manager, …) via SDL_OpenURL - asynchronously, so the UI keeps pumping while
|
||||
@@ -30,7 +30,7 @@ private val fOpenExternalScope = CoroutineScope(SupervisorJob() + Dispatchers.De
|
||||
thread with SDL_OpenURL's success flag. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun openUrl(inUrl: String, inOnResult: ((Boolean) -> Unit)? = null) {
|
||||
fOpenExternalScope.launch {
|
||||
mOpenExternalScope.launch {
|
||||
val vOk = SDL_OpenURL(inUrl)
|
||||
if (inOnResult != null) withContext(Dispatchers.Main) { inOnResult(vOk) }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,21 @@ import com.compose.sdl.res.ImageLoader
|
||||
(official Skiko on macOS/Linux, the fork on mingwX64); text measurement +
|
||||
drawing go through the skiko paragraph engine (androidx.compose.ui.text
|
||||
Paragraph actuals), so there is no renderer-owned measurer here. */
|
||||
/*
|
||||
WHY THIS INTERFACE EXISTS WITH ONE IMPLEMENTATION - do not "simplify" it away.
|
||||
|
||||
SkiaRenderBackend is its only implementor, which reads like leftover
|
||||
dual-backend scaffolding from when SDL's own renderer was the alternative. It
|
||||
is not. SkiaRenderBackend lives in skikoRendererMain, BELOW nativeMain, so
|
||||
nativeMain-level code cannot name it: :desktop-native-window's WindowInstance
|
||||
holds the renderer in nativeMain and would not compile against the concrete
|
||||
type. Verified by trying it - compileNativeMainKotlinMetadata fails with
|
||||
"Unresolved reference 'renderer'".
|
||||
|
||||
So this interface is the nativeMain-visible boundary for an implementation
|
||||
that must live lower down, exactly like the expect fun createRenderBackend
|
||||
that returns it. Same reasoning, same constraint.
|
||||
*/
|
||||
interface RenderBackend {
|
||||
/** Loader the shared ImageMeasurePolicy / Res.readBytes plug into. Backed
|
||||
by the same decode cache the renderer uses to paint images, so a
|
||||
@@ -30,8 +45,13 @@ interface RenderBackend {
|
||||
DrawModifierNode pipeline. Taking a (Canvas)->Unit instead of the host keeps
|
||||
RenderBackend + its implementations independent of the node/host layer - the
|
||||
decoupling that lets the renderers live in :ui-graphics (no ui-graphics→ui
|
||||
cycle). Default no-op. */
|
||||
fun drawRoot(inDraw: (canvas: androidx.compose.ui.graphics.Canvas) -> Unit) {}
|
||||
cycle).
|
||||
|
||||
ABSTRACT, not a no-op default: the empty body dates from when a second,
|
||||
non-Skia backend could opt out of it, and with the Skia backend now the
|
||||
only implementation an unimplemented drawRoot would silently render
|
||||
nothing rather than fail to compile. */
|
||||
fun drawRoot(inDraw: (canvas: androidx.compose.ui.graphics.Canvas) -> Unit)
|
||||
|
||||
/** Flush + present whatever was just drawn. */
|
||||
fun endFrame()
|
||||
|
||||
@@ -43,7 +43,7 @@ import sdl3.SDL_GetBasePath
|
||||
// no zlib/gzip wrapper). ZIP64 is not supported - the resource set is
|
||||
// small enough that the standard 4 GB / 65535-entry limits don't apply.
|
||||
|
||||
private class ComposeResourceArchive(private val fHandle: FileHandle) {
|
||||
private class ComposeResourceArchive(private val mHandle: FileHandle) {
|
||||
|
||||
// One entry's location + sizing + compression method from the central
|
||||
// directory. method 0 = stored, 8 = deflated.
|
||||
@@ -53,18 +53,18 @@ private class ComposeResourceArchive(private val fHandle: FileHandle) {
|
||||
val uncompressedSize: Long,
|
||||
val method: Int,
|
||||
)
|
||||
private val fEntries: Map<String, Entry>
|
||||
private val mEntries: Map<String, Entry>
|
||||
|
||||
init {
|
||||
fEntries = readCentralDirectory()
|
||||
mEntries = readCentralDirectory()
|
||||
}
|
||||
|
||||
fun has(inPath: String): Boolean = fEntries.containsKey(inPath)
|
||||
fun has(inPath: String): Boolean = mEntries.containsKey(inPath)
|
||||
|
||||
/** Reads an entry's bytes (inflated if needed) or null if the entry is
|
||||
missing / uses an unsupported compression method. */
|
||||
fun readBytes(inPath: String): ByteArray? {
|
||||
val vEntry = fEntries[inPath] ?: return null
|
||||
val vEntry = mEntries[inPath] ?: return null
|
||||
if (vEntry.uncompressedSize <= 0L) return ByteArray(0)
|
||||
|
||||
// Local file header layout: 30 fixed bytes, then filename_len + extra_len.
|
||||
@@ -126,7 +126,7 @@ private class ComposeResourceArchive(private val fHandle: FileHandle) {
|
||||
// End of Central Directory record: 22 bytes minimum, with up to 65535
|
||||
// bytes of comment trailing. Read the last 64 KiB + 22 and scan backward
|
||||
// for the EOCD signature.
|
||||
val vFileLen: Long = fHandle.size()
|
||||
val vFileLen: Long = mHandle.size()
|
||||
if (vFileLen < 22L) return emptyMap()
|
||||
val vTailLen = minOf(vFileLen, 65557L).toInt()
|
||||
val vTail = ByteArray(vTailLen)
|
||||
@@ -182,14 +182,14 @@ private class ComposeResourceArchive(private val fHandle: FileHandle) {
|
||||
if (outBuf.isEmpty()) return true
|
||||
var vRead = 0
|
||||
while (vRead < outBuf.size) {
|
||||
val vN = fHandle.read(inOffset + vRead, outBuf, vRead, outBuf.size - vRead)
|
||||
val vN = mHandle.read(inOffset + vRead, outBuf, vRead, outBuf.size - vRead)
|
||||
if (vN <= 0) return false
|
||||
vRead += vN
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun close() = fHandle.close()
|
||||
fun close() = mHandle.close()
|
||||
|
||||
private fun le16(inBuf: ByteArray, inOff: Int): Int =
|
||||
(inBuf[inOff].toInt() and 0xFF) or
|
||||
|
||||
@@ -11,12 +11,21 @@ class SDL3Backend(
|
||||
private val title: String = "ComposeNativeSDL3",
|
||||
private val width: Int = 800,
|
||||
private val height: Int = 600,
|
||||
val gpuMode: GpuMode = GpuMode.Software,
|
||||
val gpuMode: GpuMode = GpuMode.CpuRaster,
|
||||
// Resource paths (inside data.kres) of the pre-decoded .rgba icon blobs.
|
||||
// The first-listed size is irrelevant - the largest becomes the base and
|
||||
// the rest become alternate resolutions. Dark set falls back to light.
|
||||
private val iconLightResourcePaths: List<String> = emptyList(),
|
||||
private val iconDarkResourcePaths: List<String> = emptyList(),
|
||||
// Creation-time window attributes, mirroring Compose Desktop's Window().
|
||||
// `undecorated`, `resizable`, `alwaysOnTop` and `focusable` can also be
|
||||
// changed later (see ComposeNativeWindow); `transparent` cannot - SDL needs
|
||||
// SDL_WINDOW_TRANSPARENT at creation, so it is honoured here only.
|
||||
private val undecorated: Boolean = false,
|
||||
private val transparent: Boolean = false,
|
||||
private val resizable: Boolean = true,
|
||||
private val alwaysOnTop: Boolean = false,
|
||||
private val focusable: Boolean = true,
|
||||
) {
|
||||
init {
|
||||
require(gpuMode !is GpuMode.Auto) {
|
||||
@@ -66,7 +75,7 @@ class SDL3Backend(
|
||||
com.compose.sdl.res.preferredLocaleProvider = ::sdlPreferredLocale
|
||||
com.compose.sdl.res.preferredLocaleTagsProvider = { com.compose.sdl.text.systemPreferredLocaleTags() }
|
||||
|
||||
if (gpuMode is GpuMode.Skia.OpenGL) {
|
||||
if (gpuMode is GpuMode.OpenGL) {
|
||||
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_MAJOR_VERSION, 3)
|
||||
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_MINOR_VERSION, 2)
|
||||
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_CONTEXT_PROFILE_MASK,
|
||||
@@ -75,10 +84,16 @@ class SDL3Backend(
|
||||
SDL_GL_SetAttribute(SDL_GLAttr.SDL_GL_STENCIL_SIZE, 8)
|
||||
}
|
||||
|
||||
val flags = SDL_WINDOW_RESIZABLE or SDL_WINDOW_HIGH_PIXEL_DENSITY or when (gpuMode) {
|
||||
is GpuMode.Skia.OpenGL -> SDL_WINDOW_OPENGL
|
||||
is GpuMode.Skia.Metal -> SDL_WINDOW_METAL
|
||||
is GpuMode.Software -> 0UL
|
||||
var attrFlags = SDL_WINDOW_HIGH_PIXEL_DENSITY
|
||||
if (resizable) attrFlags = attrFlags or SDL_WINDOW_RESIZABLE
|
||||
if (undecorated) attrFlags = attrFlags or SDL_WINDOW_BORDERLESS
|
||||
if (transparent) attrFlags = attrFlags or SDL_WINDOW_TRANSPARENT
|
||||
if (alwaysOnTop) attrFlags = attrFlags or SDL_WINDOW_ALWAYS_ON_TOP
|
||||
if (!focusable) attrFlags = attrFlags or SDL_WINDOW_NOT_FOCUSABLE
|
||||
val flags = attrFlags or when (gpuMode) {
|
||||
is GpuMode.OpenGL -> SDL_WINDOW_OPENGL
|
||||
is GpuMode.Metal -> SDL_WINDOW_METAL
|
||||
is GpuMode.CpuRaster -> 0UL
|
||||
is GpuMode.Auto -> error("unreachable")
|
||||
}
|
||||
window = SDL_CreateWindow(title, width, height, flags)
|
||||
@@ -88,7 +103,7 @@ class SDL3Backend(
|
||||
}
|
||||
|
||||
when (gpuMode) {
|
||||
is GpuMode.Skia.OpenGL -> {
|
||||
is GpuMode.OpenGL -> {
|
||||
glContext = SDL_GL_CreateContext(window?.reinterpret())
|
||||
if (glContext == null) {
|
||||
println("SDL_GL_CreateContext failed: ${SDL_GetError()?.toKString()}")
|
||||
@@ -101,7 +116,7 @@ class SDL3Backend(
|
||||
// keeps its fallback frame cap.
|
||||
vsyncEnabled = SDL_GL_SetSwapInterval(1)
|
||||
}
|
||||
is GpuMode.Skia.Metal -> {
|
||||
is GpuMode.Metal -> {
|
||||
metalView = SDL_Metal_CreateView(window?.reinterpret())
|
||||
if (metalView == null) {
|
||||
println("SDL_Metal_CreateView failed: ${SDL_GetError()?.toKString()}")
|
||||
@@ -111,7 +126,7 @@ class SDL3Backend(
|
||||
// blocks - so Metal is vsync-paced and the loop skips its delay.
|
||||
vsyncEnabled = true
|
||||
}
|
||||
is GpuMode.Software -> {
|
||||
is GpuMode.CpuRaster -> {
|
||||
// Skia CPU raster: SkiaSurfaceBridge paints a host buffer that
|
||||
// this SDL_Renderer uploads as a texture each frame.
|
||||
renderer = SDL_CreateRenderer(window?.reinterpret(), null)
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.pointer.PointerButton
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import com.compose.sdl.input.LegacyPointerEvent
|
||||
import kotlinx.cinterop.*
|
||||
import sdl3.*
|
||||
|
||||
@@ -17,7 +16,16 @@ import sdl3.*
|
||||
events; the loop routes those to its first window). Quit is app-level. */
|
||||
sealed class AppEvent {
|
||||
data object Quit : AppEvent()
|
||||
data class Pointer(val event: LegacyPointerEvent, val windowId: UInt = 0u) : AppEvent()
|
||||
/** A single-pointer SDL mouse event. Flat like its MouseWheel sibling: the
|
||||
fields used to sit behind a `LegacyPointerEvent` wrapper that the mapper
|
||||
built and the window loop immediately took apart again. */
|
||||
data class Pointer(
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val type: PointerEventType,
|
||||
val button: PointerButton = PointerButton.Primary,
|
||||
val windowId: UInt = 0u,
|
||||
) : AppEvent()
|
||||
data class Key(val event: KeyEvent, val windowId: UInt = 0u) : AppEvent()
|
||||
data class TextInput(val text: String, val windowId: UInt = 0u) : AppEvent()
|
||||
/** IME preedit / composition (SDL_EVENT_TEXT_EDITING). Empty text = composition
|
||||
@@ -81,28 +89,31 @@ private fun mapEvent(e: SDL_Event): AppEvent? {
|
||||
|
||||
SDL_EVENT_MOUSE_BUTTON_DOWN -> {
|
||||
val mb = e.button
|
||||
AppEvent.Pointer(LegacyPointerEvent(
|
||||
AppEvent.Pointer(
|
||||
x = mb.x, y = mb.y,
|
||||
type = PointerEventType.Press,
|
||||
button = mapButton(mb.button)
|
||||
), mb.windowID)
|
||||
button = mapButton(mb.button),
|
||||
windowId = mb.windowID,
|
||||
)
|
||||
}
|
||||
|
||||
SDL_EVENT_MOUSE_BUTTON_UP -> {
|
||||
val mb = e.button
|
||||
AppEvent.Pointer(LegacyPointerEvent(
|
||||
AppEvent.Pointer(
|
||||
x = mb.x, y = mb.y,
|
||||
type = PointerEventType.Release,
|
||||
button = mapButton(mb.button)
|
||||
), mb.windowID)
|
||||
button = mapButton(mb.button),
|
||||
windowId = mb.windowID,
|
||||
)
|
||||
}
|
||||
|
||||
SDL_EVENT_MOUSE_MOTION -> {
|
||||
val mm = e.motion
|
||||
AppEvent.Pointer(LegacyPointerEvent(
|
||||
AppEvent.Pointer(
|
||||
x = mm.x, y = mm.y,
|
||||
type = PointerEventType.Move
|
||||
), mm.windowID)
|
||||
type = PointerEventType.Move,
|
||||
windowId = mm.windowID,
|
||||
)
|
||||
}
|
||||
|
||||
SDL_EVENT_KEY_DOWN -> mapKey(e.key, KeyEventType.KeyDown)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.compose.sdl.input
|
||||
|
||||
import androidx.compose.ui.input.pointer.PointerButton
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
|
||||
// ==================
|
||||
// MARK: LegacyPointerEvent (project-only)
|
||||
// ==================
|
||||
|
||||
/** Project-only legacy event class used by the SDL3 input pipeline
|
||||
(`SDL3EventMapper` → `AppEvent.Pointer` → `ComposeWindow`'s event loop).
|
||||
The name `PointerEvent` is now reserved for upstream's multi-touch
|
||||
vendored event (in androidx.compose.ui.input.pointer.PointerEvent);
|
||||
this single-pointer (x, y, type, button) shape is the bridge between
|
||||
raw SDL events and upstream-shaped PointerInputChange dispatch.
|
||||
|
||||
ComposeWindow constructs an upstream PointerEvent from this for
|
||||
delivery to Modifier.pointerInput blocks. */
|
||||
class LegacyPointerEvent(
|
||||
// Kept as Float: SDL delivers sub-pixel cursor coordinates, and on HiDPI the
|
||||
// value is multiplied by DPR before dispatch, so truncating to Int here would
|
||||
// quantize the caret to whole-DPR steps (2px on a 2x display) near glyph edges.
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val type: PointerEventType,
|
||||
val button: PointerButton = PointerButton.Primary,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean = other is LegacyPointerEvent &&
|
||||
other.x == x && other.y == y && other.type == type && other.button == button
|
||||
override fun hashCode(): Int {
|
||||
var h = x.hashCode(); h = 31 * h + y.hashCode(); h = 31 * h + type.hashCode(); h = 31 * h + button.hashCode(); return h
|
||||
}
|
||||
override fun toString(): String =
|
||||
"LegacyPointerEvent(x=$x, y=$y, type=$type, button=$button)"
|
||||
}
|
||||
@@ -29,12 +29,12 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
// the root keeps LayoutNode's ErrorMeasurePolicy ("Undefined measure").
|
||||
measurePolicy = androidx.compose.ui.layout.RootMeasurePolicy
|
||||
}
|
||||
private val fOwner = ComposeOwner(rootNode, Density(inDensity), LayoutDirection.Ltr)
|
||||
private val fApplier = NodeApplier(rootNode)
|
||||
private val mOwner = ComposeOwner(rootNode, Density(inDensity), LayoutDirection.Ltr)
|
||||
private val mApplier = NodeApplier(rootNode)
|
||||
|
||||
// Upcast to the public supertype so the internal NodeApplier / LayoutNode
|
||||
// don't leak across the module boundary; Composition accepts Applier<*>.
|
||||
val applier: Applier<*> get() = fApplier
|
||||
val applier: Applier<*> get() = mApplier
|
||||
|
||||
fun attach() {
|
||||
// Install the focus root + drag-and-drop root on the root LayoutNode
|
||||
@@ -43,8 +43,8 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
// focus root the focus tree has no origin and requestFocus never
|
||||
// sticks; without the DnD root the root DragAndDropNode never receives
|
||||
// events from Sdl3DragAndDropOwner's dispatch calls.
|
||||
rootNode.modifier = fOwner.focusOwner.modifier.then(fOwner.dragAndDropManager.modifier)
|
||||
fOwner.attach()
|
||||
rootNode.modifier = mOwner.focusOwner.modifier.then(mOwner.dragAndDropManager.modifier)
|
||||
mOwner.attach()
|
||||
// Turn on snapshot observation so writes to `mutableStateOf` (ScrollState.value,
|
||||
// LazyList firstVisibleItemIndex, etc.) fire the appropriate invalidation
|
||||
// callbacks - requestRelayout / requestRemeasure - routing through the vendored
|
||||
@@ -52,53 +52,53 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
// state but the placement lambda inside ScrollNode.measure never re-runs, so
|
||||
// the child layer's move(IntOffset(0, -scroll)) never happens → scroll offset
|
||||
// is invisible even though the state is updating.
|
||||
fOwner.snapshotObserver.startObserving()
|
||||
mOwner.snapshotObserver.startObserving()
|
||||
}
|
||||
|
||||
fun setConstraints(inWidth: Int, inHeight: Int) {
|
||||
fOwner.setRootConstraints(Constraints.fixed(inWidth, inHeight))
|
||||
mOwner.setRootConstraints(Constraints.fixed(inWidth, inHeight))
|
||||
}
|
||||
|
||||
// Pump the owner's node-animation frame clock (scroll fling / animateScrollToItem / node
|
||||
// Animatables). Called once per frame by ComposeWindow with a monotonic nanos timestamp.
|
||||
fun sendAnimationFrame(inNanos: Long) {
|
||||
fOwner.animationFrameClock.sendFrame(inNanos)
|
||||
mOwner.animationFrameClock.sendFrame(inNanos)
|
||||
}
|
||||
|
||||
// True while a node-level animation (scroll fling, node Animatable) awaits the next
|
||||
// animation frame - the node half of the window's quiescence signal (render-to-quiescence
|
||||
// screenshot capture asks the window whether ANY work is still pending).
|
||||
fun hasAnimationAwaiters(): Boolean = fOwner.animationFrameClock.hasAwaiters
|
||||
fun hasAnimationAwaiters(): Boolean = mOwner.animationFrameClock.hasAwaiters
|
||||
|
||||
fun measureAndLayout() {
|
||||
// Flush deferred end-of-apply work (focus invalidation etc.) before measuring, so
|
||||
// requestFocus() from composition OR from a pointer event this frame takes effect -
|
||||
// e.g. focus-on-click, which schedules a focus invalidation during event dispatch.
|
||||
fOwner.onEndApplyChanges()
|
||||
fOwner.measureAndLayout()
|
||||
mOwner.onEndApplyChanges()
|
||||
mOwner.measureAndLayout()
|
||||
// Drop snapshot observations whose scope is no longer valid (detached nodes,
|
||||
// destroyed layers, disposed draw scopes). Upstream RootNodeOwner does this after
|
||||
// the measure pass; without it, every disposed subtree's measure/layout/DRAW
|
||||
// observation scopes linger in the OwnerSnapshotObserver forever - each pins its
|
||||
// observed object graph (a leak the P2.2 soak caught on ripple/indication draws).
|
||||
fOwner.snapshotObserver.clearInvalidObservations()
|
||||
mOwner.snapshotObserver.clearInvalidObservations()
|
||||
}
|
||||
|
||||
// Draw the composed tree into [canvas] - re-records dirty layers, then walks
|
||||
// the root so clean layers replay their cached content. Backends call this
|
||||
// instead of rootNode.draw so retained-layer bookkeeping runs each frame.
|
||||
fun drawRoot(canvas: androidx.compose.ui.graphics.Canvas) {
|
||||
fOwner.renderRoot(canvas)
|
||||
mOwner.renderRoot(canvas)
|
||||
}
|
||||
|
||||
// The window wires this to set needsFrame - a layer whose content changed
|
||||
// (OwnedLayer.invalidate) schedules a frame even with nothing else pending.
|
||||
fun setInvalidationCallback(callback: () -> Unit) {
|
||||
fOwner.onInvalidate = callback
|
||||
mOwner.onInvalidate = callback
|
||||
}
|
||||
|
||||
// The owner's FocusOwner IS a FocusManager - the window provides it as LocalFocusManager.
|
||||
val focusManager: androidx.compose.ui.focus.FocusManager get() = fOwner.focusOwner
|
||||
val focusManager: androidx.compose.ui.focus.FocusManager get() = mOwner.focusOwner
|
||||
|
||||
// ==================
|
||||
// MARK: Owner-backed values for the composition-locals seed
|
||||
@@ -107,19 +107,19 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
// Provider; ComposeWindow wraps setContent in a CompositionLocalProvider whose
|
||||
// values come from the ComposeOwner attached below. These accessors expose the
|
||||
// right fields without leaking the `internal Owner` interface across modules.
|
||||
val density: androidx.compose.ui.unit.Density get() = fOwner.density
|
||||
val layoutDirection: androidx.compose.ui.unit.LayoutDirection get() = fOwner.layoutDirection
|
||||
val viewConfiguration: androidx.compose.ui.platform.ViewConfiguration get() = fOwner.viewConfiguration
|
||||
val graphicsContext: androidx.compose.ui.graphics.GraphicsContext get() = fOwner.graphicsContext
|
||||
val inputModeManager: androidx.compose.ui.input.InputModeManager get() = fOwner.inputModeManager
|
||||
val hapticFeedback: androidx.compose.ui.hapticfeedback.HapticFeedback get() = fOwner.hapticFeedBack
|
||||
val textToolbar: androidx.compose.ui.platform.TextToolbar get() = fOwner.textToolbar
|
||||
val windowInfo: androidx.compose.ui.platform.WindowInfo get() = fOwner.windowInfo
|
||||
val density: androidx.compose.ui.unit.Density get() = mOwner.density
|
||||
val layoutDirection: androidx.compose.ui.unit.LayoutDirection get() = mOwner.layoutDirection
|
||||
val viewConfiguration: androidx.compose.ui.platform.ViewConfiguration get() = mOwner.viewConfiguration
|
||||
val graphicsContext: androidx.compose.ui.graphics.GraphicsContext get() = mOwner.graphicsContext
|
||||
val inputModeManager: androidx.compose.ui.input.InputModeManager get() = mOwner.inputModeManager
|
||||
val hapticFeedback: androidx.compose.ui.hapticfeedback.HapticFeedback get() = mOwner.hapticFeedBack
|
||||
val textToolbar: androidx.compose.ui.platform.TextToolbar get() = mOwner.textToolbar
|
||||
val windowInfo: androidx.compose.ui.platform.WindowInfo get() = mOwner.windowInfo
|
||||
|
||||
// Push SDL window focus into windowInfo.isWindowFocused (focus-reactive UI).
|
||||
fun setWindowFocused(inFocused: Boolean) = fOwner.setWindowFocused(inFocused)
|
||||
fun setWindowFocused(inFocused: Boolean) = mOwner.setWindowFocused(inFocused)
|
||||
val softwareKeyboardController: androidx.compose.ui.platform.SoftwareKeyboardController
|
||||
get() = fOwner.softwareKeyboardController
|
||||
get() = mOwner.softwareKeyboardController
|
||||
|
||||
// PointerIconService and its composition local are :ui-internal, so ComposeWindow
|
||||
// can't provide the local directly. This wrapper seeds it from the owner so
|
||||
@@ -127,7 +127,7 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
@androidx.compose.runtime.Composable
|
||||
fun ProvidePointerIconService(content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
androidx.compose.ui.platform.LocalPointerIconService provides fOwner.pointerIconService,
|
||||
androidx.compose.ui.platform.LocalPointerIconService provides mOwner.pointerIconService,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
onKeyEvent) - drives text-field editing keys (backspace / arrows / enter) and
|
||||
clickable Enter/Space activation. Returns true if some node consumed it. */
|
||||
fun dispatchKeyEvent(inEvent: androidx.compose.ui.input.key.KeyEvent): Boolean =
|
||||
runCatching { fOwner.focusOwner.dispatchKeyEvent(inEvent) }.getOrDefault(false)
|
||||
runCatching { mOwner.focusOwner.dispatchKeyEvent(inEvent) }.getOrDefault(false)
|
||||
|
||||
// ==================
|
||||
// MARK: Input - feed the vendored PointerInputEventProcessor
|
||||
@@ -152,13 +152,13 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
// `expect PointerInputEvent` has no commonMain constructor, so the actual build+dispatch
|
||||
// lives in nativeMain (feedPointerToProcessor).
|
||||
fun onPointerRaw(inX: Float, inY: Float, inType: Int, inButton: Int, inUptime: Long) {
|
||||
feedPointerToProcessor(fOwner, inType, inButton, inUptime, inX, inY)
|
||||
feedPointerToProcessor(mOwner, inType, inButton, inUptime, inX, inY)
|
||||
}
|
||||
|
||||
// Mouse wheel - feed a scroll PointerInputEvent to the processor so the vendored
|
||||
// Modifier.scrollable (MouseWheelScrollingLogic) handles it, exactly like upstream.
|
||||
fun onWheel(inX: Float, inY: Float, inDeltaX: Float, inDeltaY: Float, inUptime: Long) {
|
||||
feedScrollToProcessor(fOwner, inX, inY, inDeltaX, inDeltaY, inUptime)
|
||||
feedScrollToProcessor(mOwner, inX, inY, inDeltaX, inDeltaY, inUptime)
|
||||
}
|
||||
|
||||
// ==================
|
||||
@@ -169,11 +169,11 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
// COMPLETE to these methods; Sdl3DragAndDropOwner accumulates the drop
|
||||
// session and dispatches through the root DragAndDropNode on COMPLETE.
|
||||
|
||||
fun onDropBegin() = fOwner.dragAndDropManager.dropBegin()
|
||||
fun onDropPosition(inX: Float, inY: Float) = fOwner.dragAndDropManager.dropPosition(inX, inY)
|
||||
fun onDropFile(inPath: String) = fOwner.dragAndDropManager.dropFile(inPath)
|
||||
fun onDropText(inText: String) = fOwner.dragAndDropManager.dropText(inText)
|
||||
fun onDropComplete() = fOwner.dragAndDropManager.dropComplete()
|
||||
fun onDropBegin() = mOwner.dragAndDropManager.dropBegin()
|
||||
fun onDropPosition(inX: Float, inY: Float) = mOwner.dragAndDropManager.dropPosition(inX, inY)
|
||||
fun onDropFile(inPath: String) = mOwner.dragAndDropManager.dropFile(inPath)
|
||||
fun onDropText(inText: String) = mOwner.dragAndDropManager.dropText(inText)
|
||||
fun onDropComplete() = mOwner.dragAndDropManager.dropComplete()
|
||||
}
|
||||
|
||||
// NOTE: feedPointerToProcessor / feedScrollToProcessor are plain nativeMain
|
||||
|
||||
+9
-9
@@ -18,9 +18,9 @@ import com.compose.sdl.node.impl.ComposeOwner
|
||||
mouse down unless `buttons.isPrimaryPressed`, and drag/scroll gestures need the held button to
|
||||
persist across Move events, so the synthesized PointerInputEvent must carry a live button mask -
|
||||
not just the changed button. A single mouse => module-level flags are enough. */
|
||||
private var fPrimaryDown = false
|
||||
private var fSecondaryDown = false
|
||||
private var fTertiaryDown = false
|
||||
private var mPrimaryDown = false
|
||||
private var mSecondaryDown = false
|
||||
private var mTertiaryDown = false
|
||||
|
||||
/** Builds the internal PointerInputEvent (its constructor is native-only) from a single mouse
|
||||
pointer and drives the owner's PointerInputEventProcessor. inType: 0=Move 1=Press 2=Release 3=Exit;
|
||||
@@ -34,14 +34,14 @@ internal fun feedPointerToProcessor(
|
||||
inY: Float,
|
||||
) {
|
||||
when (inType) {
|
||||
1 -> when (inButton) { 0 -> fPrimaryDown = true; 1 -> fSecondaryDown = true; 2 -> fTertiaryDown = true }
|
||||
2 -> when (inButton) { 0 -> fPrimaryDown = false; 1 -> fSecondaryDown = false; 2 -> fTertiaryDown = false }
|
||||
1 -> when (inButton) { 0 -> mPrimaryDown = true; 1 -> mSecondaryDown = true; 2 -> mTertiaryDown = true }
|
||||
2 -> when (inButton) { 0 -> mPrimaryDown = false; 1 -> mSecondaryDown = false; 2 -> mTertiaryDown = false }
|
||||
}
|
||||
|
||||
val vButtons = PointerButtons(
|
||||
isPrimaryPressed = fPrimaryDown,
|
||||
isSecondaryPressed = fSecondaryDown,
|
||||
isTertiaryPressed = fTertiaryDown,
|
||||
isPrimaryPressed = mPrimaryDown,
|
||||
isSecondaryPressed = mSecondaryDown,
|
||||
isTertiaryPressed = mTertiaryDown,
|
||||
)
|
||||
val vButton = if (inType == 1 || inType == 2) {
|
||||
when (inButton) {
|
||||
@@ -57,7 +57,7 @@ internal fun feedPointerToProcessor(
|
||||
uptime = inUptime,
|
||||
positionOnScreen = vPos,
|
||||
position = vPos,
|
||||
down = fPrimaryDown || fSecondaryDown || fTertiaryDown,
|
||||
down = mPrimaryDown || mSecondaryDown || mTertiaryDown,
|
||||
pressure = 1f,
|
||||
type = PointerType.Mouse,
|
||||
activeHover = inType == 0, // Exit (3) also reports no active hover
|
||||
|
||||
@@ -80,28 +80,28 @@ internal class ComposeOwner(
|
||||
) : Owner, OwnedLayerManager {
|
||||
|
||||
// The vendored measure/layout state machine, rooted at [root].
|
||||
private val fDelegate = MeasureAndLayoutDelegate(root)
|
||||
private val mDelegate = MeasureAndLayoutDelegate(root)
|
||||
|
||||
// The vendored pointer-input dispatcher. Routes a PointerInputEvent through
|
||||
// HitPathTracker to the tree's PointerInputModifierNodes (hover / gesture),
|
||||
// synthesizing Enter/Exit. Fed from ComposeWindow via ComposeRootHost.
|
||||
private val fPointerProcessor = androidx.compose.ui.input.pointer.PointerInputEventProcessor(root)
|
||||
private val mPointerProcessor = androidx.compose.ui.input.pointer.PointerInputEventProcessor(root)
|
||||
|
||||
// Window focus + container size backing [windowInfo], snapshot-backed so
|
||||
// focus-reactive UI and LocalWindowInfo.containerSize readers recompose. Fed by
|
||||
// the window: focus via setWindowFocused (SDL activation), size from setRootConstraints.
|
||||
private val fWindowFocused = androidx.compose.runtime.mutableStateOf(true)
|
||||
private val fContainerSize = androidx.compose.runtime.mutableStateOf(androidx.compose.ui.unit.IntSize.Zero)
|
||||
private val mWindowFocused = androidx.compose.runtime.mutableStateOf(true)
|
||||
private val mContainerSize = androidx.compose.runtime.mutableStateOf(androidx.compose.ui.unit.IntSize.Zero)
|
||||
|
||||
// Called by the window on SDL focus gain/loss.
|
||||
internal fun setWindowFocused(inFocused: Boolean) { fWindowFocused.value = inFocused }
|
||||
internal fun setWindowFocused(inFocused: Boolean) { mWindowFocused.value = inFocused }
|
||||
|
||||
// Dispatch one pointer event to the upstream PointerInputModifierNode tree.
|
||||
// [this] is the PositionCalculator (Owner : PositionCalculator).
|
||||
internal fun processPointerInput(inEvent: androidx.compose.ui.input.pointer.PointerInputEvent): Boolean {
|
||||
val vResult = fPointerProcessor.process(inEvent, this)
|
||||
val vResult = mPointerProcessor.process(inEvent, this)
|
||||
// Apply the cursor the hover pipeline just set for this event (deduped in SdlCursors).
|
||||
com.compose.sdl.SdlCursors.apply(fPointerIcon)
|
||||
com.compose.sdl.SdlCursors.apply(mPointerIcon)
|
||||
return vResult.dispatchedToAPointerInputModifier
|
||||
}
|
||||
|
||||
@@ -115,11 +115,11 @@ internal class ComposeOwner(
|
||||
// Sets the constraints the root is measured against (window pixel size in
|
||||
// logical points). Call each frame before [measureAndLayout].
|
||||
fun setRootConstraints(inConstraints: Constraints) {
|
||||
fDelegate.updateRootConstraints(inConstraints)
|
||||
mDelegate.updateRootConstraints(inConstraints)
|
||||
// Report the container size (physical px, the port's layout coord space) to
|
||||
// windowInfo; updates on every resize since the window re-measures per frame.
|
||||
if (inConstraints.hasBoundedWidth && inConstraints.hasBoundedHeight) {
|
||||
fContainerSize.value =
|
||||
mContainerSize.value =
|
||||
androidx.compose.ui.unit.IntSize(inConstraints.maxWidth, inConstraints.maxHeight)
|
||||
}
|
||||
}
|
||||
@@ -138,8 +138,8 @@ internal class ComposeOwner(
|
||||
forceRequest: Boolean,
|
||||
scheduleMeasureAndLayout: Boolean,
|
||||
) {
|
||||
if (affectsLookahead) fDelegate.requestLookaheadRemeasure(layoutNode, forceRequest)
|
||||
else fDelegate.requestRemeasure(layoutNode, forceRequest)
|
||||
if (affectsLookahead) mDelegate.requestLookaheadRemeasure(layoutNode, forceRequest)
|
||||
else mDelegate.requestRemeasure(layoutNode, forceRequest)
|
||||
}
|
||||
|
||||
override fun onRequestRelayout(
|
||||
@@ -147,25 +147,25 @@ internal class ComposeOwner(
|
||||
affectsLookahead: Boolean,
|
||||
forceRequest: Boolean,
|
||||
) {
|
||||
if (affectsLookahead) fDelegate.requestLookaheadRelayout(layoutNode, forceRequest)
|
||||
else fDelegate.requestRelayout(layoutNode, forceRequest)
|
||||
if (affectsLookahead) mDelegate.requestLookaheadRelayout(layoutNode, forceRequest)
|
||||
else mDelegate.requestRelayout(layoutNode, forceRequest)
|
||||
}
|
||||
|
||||
override fun measureAndLayout(sendPointerUpdate: Boolean) {
|
||||
fDelegate.measureAndLayout()
|
||||
fDelegate.dispatchOnPositionedCallbacks()
|
||||
mDelegate.measureAndLayout()
|
||||
mDelegate.dispatchOnPositionedCallbacks()
|
||||
}
|
||||
|
||||
override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) {
|
||||
fDelegate.measureAndLayout(layoutNode, constraints)
|
||||
mDelegate.measureAndLayout(layoutNode, constraints)
|
||||
}
|
||||
|
||||
override fun forceMeasureTheSubtree(layoutNode: LayoutNode, affectsLookahead: Boolean) {
|
||||
fDelegate.forceMeasureTheSubtree(layoutNode, affectsLookahead)
|
||||
mDelegate.forceMeasureTheSubtree(layoutNode, affectsLookahead)
|
||||
}
|
||||
|
||||
override fun onDetach(node: LayoutNode) {
|
||||
fDelegate.onNodeDetached(node)
|
||||
mDelegate.onNodeDetached(node)
|
||||
// Drop the detached node's read-observation scopes, exactly as upstream
|
||||
// RootNodeOwner.onDetach does. Without this, every disposed node's measure/
|
||||
// layout/draw observation scopes accumulate in the snapshot observer forever
|
||||
@@ -174,7 +174,7 @@ internal class ComposeOwner(
|
||||
}
|
||||
|
||||
override fun registerOnLayoutCompletedListener(listener: Owner.OnLayoutCompletedListener) {
|
||||
fDelegate.registerOnLayoutCompletedListener(listener)
|
||||
mDelegate.registerOnLayoutCompletedListener(listener)
|
||||
}
|
||||
|
||||
override fun requestOnPositionedCallback(layoutNode: LayoutNode) = Unit
|
||||
@@ -345,12 +345,12 @@ internal class ComposeOwner(
|
||||
}
|
||||
// Desired hover cursor, set by the vendored HoverIconModifierNode
|
||||
// (Modifier.pointerHoverIcon) and applied via SDL in processPointerInput. null = default.
|
||||
private var fPointerIcon: androidx.compose.ui.input.pointer.PointerIcon =
|
||||
private var mPointerIcon: androidx.compose.ui.input.pointer.PointerIcon =
|
||||
androidx.compose.ui.input.pointer.PointerIcon.Default
|
||||
override val pointerIconService: PointerIconService = object : PointerIconService {
|
||||
override fun getIcon(): androidx.compose.ui.input.pointer.PointerIcon = fPointerIcon
|
||||
override fun getIcon(): androidx.compose.ui.input.pointer.PointerIcon = mPointerIcon
|
||||
override fun setIcon(value: androidx.compose.ui.input.pointer.PointerIcon?) {
|
||||
fPointerIcon = value ?: androidx.compose.ui.input.pointer.PointerIcon.Default
|
||||
mPointerIcon = value ?: androidx.compose.ui.input.pointer.PointerIcon.Default
|
||||
}
|
||||
override fun getStylusHoverIcon(): androidx.compose.ui.input.pointer.PointerIcon? = null
|
||||
override fun setStylusHoverIcon(value: androidx.compose.ui.input.pointer.PointerIcon?) {}
|
||||
@@ -368,13 +368,13 @@ internal class ComposeOwner(
|
||||
owner = this,
|
||||
)
|
||||
override val windowInfo: WindowInfo = object : WindowInfo {
|
||||
override val isWindowFocused: Boolean get() = fWindowFocused.value
|
||||
override val containerSize: androidx.compose.ui.unit.IntSize get() = fContainerSize.value
|
||||
override val isWindowFocused: Boolean get() = mWindowFocused.value
|
||||
override val containerSize: androidx.compose.ui.unit.IntSize get() = mContainerSize.value
|
||||
override val containerDpSize: androidx.compose.ui.unit.DpSize
|
||||
get() = with(density) {
|
||||
androidx.compose.ui.unit.DpSize(
|
||||
fContainerSize.value.width.toDp(),
|
||||
fContainerSize.value.height.toDp(),
|
||||
mContainerSize.value.width.toDp(),
|
||||
mContainerSize.value.height.toDp(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -412,16 +412,16 @@ internal class ComposeOwner(
|
||||
// End-of-apply-changes listeners - the focus system (FocusInvalidationManager) and other
|
||||
// engine parts register here to defer work until changes are applied; without this the
|
||||
// focus invalidation never flushes and requestFocus() (e.g. focus-on-click) never sticks.
|
||||
private val fEndApplyChangesListeners = mutableListOf<() -> Unit>()
|
||||
private val mEndApplyChangesListeners = mutableListOf<() -> Unit>()
|
||||
|
||||
override fun registerOnEndApplyChangesListener(listener: () -> Unit) {
|
||||
if (listener !in fEndApplyChangesListeners) fEndApplyChangesListeners.add(listener)
|
||||
if (listener !in mEndApplyChangesListeners) mEndApplyChangesListeners.add(listener)
|
||||
}
|
||||
|
||||
override fun onEndApplyChanges() {
|
||||
// Drain in order; listeners may re-register, so loop until empty.
|
||||
while (fEndApplyChangesListeners.isNotEmpty()) {
|
||||
val vListener = fEndApplyChangesListeners.removeAt(0)
|
||||
while (mEndApplyChangesListeners.isNotEmpty()) {
|
||||
val vListener = mEndApplyChangesListeners.removeAt(0)
|
||||
vListener()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ import com.compose.sdl.loadComposeResourceBytes
|
||||
*
|
||||
* Idempotent; called from ComposeWindow.installGlobals (data.kres is loadable by then).
|
||||
*/
|
||||
private var fRegistered = false
|
||||
private var mRegistered = false
|
||||
|
||||
fun registerGenericFonts() {
|
||||
if (fRegistered) return
|
||||
fRegistered = true
|
||||
if (mRegistered) return
|
||||
mRegistered = true
|
||||
loadComposeResourceBytes("font/NotoSansMono.ttf")?.let { IconFont.register("generic:monospace", it) }
|
||||
}
|
||||
|
||||
+42
-42
@@ -38,15 +38,15 @@ import sdl3.SDL_Metal_GetLayer
|
||||
(cheap - the DirectContext + device + queue persist). */
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
private var fDevice: MTLDeviceProtocol? = null
|
||||
private var fQueue: MTLCommandQueueProtocol? = null
|
||||
private var fLayer: CAMetalLayer? = null
|
||||
private var fContext: DirectContext? = null
|
||||
private var fSurface: Surface? = null
|
||||
private var fRT: BackendRenderTarget? = null
|
||||
private var fDrawable: CAMetalDrawableProtocol? = null
|
||||
private var fWidth = 0
|
||||
private var fHeight = 0
|
||||
private var mDevice: MTLDeviceProtocol? = null
|
||||
private var mQueue: MTLCommandQueueProtocol? = null
|
||||
private var mLayer: CAMetalLayer? = null
|
||||
private var mContext: DirectContext? = null
|
||||
private var mSurface: Surface? = null
|
||||
private var mRT: BackendRenderTarget? = null
|
||||
private var mDrawable: CAMetalDrawableProtocol? = null
|
||||
private var mWidth = 0
|
||||
private var mHeight = 0
|
||||
|
||||
fun init(): Boolean {
|
||||
val vView = backend.metalView ?: run {
|
||||
@@ -84,22 +84,22 @@ internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
return false
|
||||
}
|
||||
|
||||
fDevice = vDevice
|
||||
fQueue = vQueue
|
||||
fLayer = vLayer
|
||||
fContext = vContext
|
||||
mDevice = vDevice
|
||||
mQueue = vQueue
|
||||
mLayer = vLayer
|
||||
mContext = vContext
|
||||
return true
|
||||
}
|
||||
|
||||
override val canvas: Canvas
|
||||
get() = requireNotNull(fSurface) { "SkiaMetalBridge not initialised" }.canvas
|
||||
get() = requireNotNull(mSurface) { "SkiaMetalBridge not initialised" }.canvas
|
||||
|
||||
override fun ensureSize(inWidth: Int, inHeight: Int): Boolean {
|
||||
if (inWidth <= 0 || inHeight <= 0) return false
|
||||
val vLayer = fLayer ?: return false
|
||||
val vContext = fContext ?: return false
|
||||
val vLayer = mLayer ?: return false
|
||||
val vContext = mContext ?: return false
|
||||
|
||||
if (inWidth != fWidth || inHeight != fHeight) {
|
||||
if (inWidth != mWidth || inHeight != mHeight) {
|
||||
vLayer.drawableSize = CGSizeMake(inWidth.toDouble(), inHeight.toDouble())
|
||||
// Keep contentsScale in lock-step with the live backing density.
|
||||
// Setting it only in init() can leave it stale at 1.0 when the
|
||||
@@ -107,18 +107,18 @@ internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
// drawable then gets downscaled to the layer's logical backing and
|
||||
// re-upscaled by the display, softening text and aliasing edges.
|
||||
vLayer.contentsScale = backend.pixelDensity.toDouble()
|
||||
fWidth = inWidth
|
||||
fHeight = inHeight
|
||||
mWidth = inWidth
|
||||
mHeight = inHeight
|
||||
println("SkiaMetalBridge: drawable ${inWidth}x${inHeight} @ contentsScale ${backend.pixelDensity}")
|
||||
}
|
||||
|
||||
// Per-frame: drop the previous frame's drawable wrappers and grab a
|
||||
// fresh one. nextDrawable() may block (~16ms vsync) when 3 drawables
|
||||
// are already in flight - that's the natural pacing mechanism.
|
||||
fSurface?.close()
|
||||
fRT?.close()
|
||||
fSurface = null
|
||||
fRT = null
|
||||
mSurface?.close()
|
||||
mRT?.close()
|
||||
mSurface = null
|
||||
mRT = null
|
||||
|
||||
val vDrawable = vLayer.nextDrawable() ?: run {
|
||||
println("SkiaMetalBridge: nextDrawable returned null")
|
||||
@@ -142,38 +142,38 @@ internal class SkiaMetalBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
println("SkiaMetalBridge: Surface.makeFromBackendRenderTarget returned null")
|
||||
return false
|
||||
}
|
||||
fRT = vRT
|
||||
fSurface = vSurface
|
||||
fDrawable = vDrawable
|
||||
mRT = vRT
|
||||
mSurface = vSurface
|
||||
mDrawable = vDrawable
|
||||
return true
|
||||
}
|
||||
|
||||
override fun present() {
|
||||
val vSurface = fSurface ?: return
|
||||
val vQueue = fQueue ?: return
|
||||
val vDrawable = fDrawable ?: return
|
||||
val vSurface = mSurface ?: return
|
||||
val vQueue = mQueue ?: return
|
||||
val vDrawable = mDrawable ?: return
|
||||
vSurface.flushAndSubmit()
|
||||
val vCommandBuffer = vQueue.commandBuffer() ?: return
|
||||
vCommandBuffer.presentDrawable(vDrawable)
|
||||
vCommandBuffer.commit()
|
||||
fDrawable = null
|
||||
mDrawable = null
|
||||
}
|
||||
|
||||
override fun snapshot(): Image? = fSurface?.makeImageSnapshot()
|
||||
override fun snapshot(): Image? = mSurface?.makeImageSnapshot()
|
||||
|
||||
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? =
|
||||
readBackBgra(fSurface, fWidth, fHeight)
|
||||
readBackBgra(mSurface, mWidth, mHeight)
|
||||
|
||||
override fun destroy() {
|
||||
fSurface?.close()
|
||||
fRT?.close()
|
||||
fContext?.close()
|
||||
fSurface = null
|
||||
fRT = null
|
||||
fContext = null
|
||||
fLayer = null
|
||||
fQueue = null
|
||||
fDevice = null
|
||||
fDrawable = null
|
||||
mSurface?.close()
|
||||
mRT?.close()
|
||||
mContext?.close()
|
||||
mSurface = null
|
||||
mRT = null
|
||||
mContext = null
|
||||
mLayer = null
|
||||
mQueue = null
|
||||
mDevice = null
|
||||
mDrawable = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge?
|
||||
actual fun rendererPreferredGpuMode(): GpuMode {
|
||||
@OptIn(ExperimentalNativeApi::class)
|
||||
return when (Platform.osFamily) {
|
||||
OsFamily.MACOSX -> GpuMode.Skia.Metal
|
||||
OsFamily.LINUX -> GpuMode.Skia.OpenGL
|
||||
OsFamily.MACOSX -> GpuMode.Metal
|
||||
OsFamily.LINUX -> GpuMode.OpenGL
|
||||
// Windows (mingwX64, Route 1a): GPU via Skia's GL backend on an SDL WGL
|
||||
// context (SkiaGLBridge, shared with Linux). CPU raster stays available
|
||||
// as --gpu=software. (A native D3D12 bridge is the milestone-2 upgrade.)
|
||||
OsFamily.WINDOWS -> GpuMode.Skia.OpenGL
|
||||
else -> GpuMode.Software
|
||||
OsFamily.WINDOWS -> GpuMode.OpenGL
|
||||
else -> GpuMode.CpuRaster
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import org.jetbrains.skia.Image
|
||||
per frame; the concrete implementation owns whatever GPU / CPU
|
||||
resources it needs (raster buffer + SDL_Texture, or a GL/Metal backend
|
||||
render target). */
|
||||
interface SkiaBridge {
|
||||
internal interface SkiaBridge {
|
||||
val canvas: Canvas
|
||||
fun ensureSize(inWidth: Int, inHeight: Int): Boolean
|
||||
fun present()
|
||||
|
||||
+56
-27
@@ -14,6 +14,7 @@ import org.jetbrains.skia.ImageInfo
|
||||
import org.jetbrains.skia.Surface
|
||||
import org.jetbrains.skia.SurfaceColorFormat
|
||||
import org.jetbrains.skia.SurfaceOrigin
|
||||
import sdl3.SDL_GL_MakeCurrent
|
||||
import sdl3.SDL_GL_SwapWindow
|
||||
|
||||
// ==================
|
||||
@@ -28,16 +29,37 @@ import sdl3.SDL_GL_SwapWindow
|
||||
DirectContext is reused. The default GL framebuffer (id 0) is wrapped
|
||||
directly; we don't manage an offscreen FBO. */
|
||||
internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
private var fContext: DirectContext? = null
|
||||
private var fRT: BackendRenderTarget? = null
|
||||
private var fSurface: Surface? = null
|
||||
private var fWidth = 0
|
||||
private var fHeight = 0
|
||||
private var mContext: DirectContext? = null
|
||||
private var mRT: BackendRenderTarget? = null
|
||||
private var mSurface: Surface? = null
|
||||
private var mWidth = 0
|
||||
private var mHeight = 0
|
||||
|
||||
/**
|
||||
Binds THIS window's GL context to the calling thread.
|
||||
|
||||
A GL context is per-window but "current" is per-THREAD state, so with more
|
||||
than one window open the last one to touch GL owns the thread. SDL makes a
|
||||
context current once at creation (SDL3Backend.init) and nothing re-bound it
|
||||
per frame, so window A issued its draw and swap against window B's context:
|
||||
silent corruption while both live, and a hard crash (SIGSEGV in present)
|
||||
the moment B is destroyed and the current context is left dangling.
|
||||
|
||||
Every entry point that touches GL calls this first. Cheap when already
|
||||
current - SDL short-circuits a redundant bind, so the single-window path
|
||||
pays nothing.
|
||||
*/
|
||||
private fun makeCurrent() {
|
||||
val vWindow = backend.window ?: return
|
||||
val vContext = backend.glContext ?: return
|
||||
SDL_GL_MakeCurrent(vWindow.reinterpret(), vContext.reinterpret())
|
||||
}
|
||||
|
||||
fun init(): Boolean {
|
||||
return try {
|
||||
fContext = DirectContext.makeGL()
|
||||
fContext != null
|
||||
makeCurrent()
|
||||
mContext = DirectContext.makeGL()
|
||||
mContext != null
|
||||
} catch (t: Throwable) {
|
||||
println("SkiaGLBridge: DirectContext.makeGL failed: ${t.message}")
|
||||
false
|
||||
@@ -45,17 +67,20 @@ internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
}
|
||||
|
||||
override val canvas: Canvas
|
||||
get() = requireNotNull(fSurface) { "SkiaGLBridge not initialised" }.canvas
|
||||
get() = requireNotNull(mSurface) { "SkiaGLBridge not initialised" }.canvas
|
||||
|
||||
override fun ensureSize(inWidth: Int, inHeight: Int): Boolean {
|
||||
if (inWidth == fWidth && inHeight == fHeight && fSurface != null) return true
|
||||
// BEFORE the unchanged-size early return: this runs once per frame and is
|
||||
// what re-binds this window's context for the draw phase that follows.
|
||||
makeCurrent()
|
||||
if (inWidth == mWidth && inHeight == mHeight && mSurface != null) return true
|
||||
if (inWidth <= 0 || inHeight <= 0) return false
|
||||
val vContext = fContext ?: return false
|
||||
val vContext = mContext ?: return false
|
||||
|
||||
fSurface?.close()
|
||||
fRT?.close()
|
||||
fSurface = null
|
||||
fRT = null
|
||||
mSurface?.close()
|
||||
mRT?.close()
|
||||
mSurface = null
|
||||
mRT = null
|
||||
|
||||
// GL_RGBA8 = 0x8058. fbId = 0 binds the default framebuffer.
|
||||
// sampleCnt = 0 means no MSAA; stencilBits = 8 is what Skia recommends.
|
||||
@@ -77,31 +102,35 @@ internal class SkiaGLBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
println("SkiaGLBridge: Surface.makeFromBackendRenderTarget returned null")
|
||||
return false
|
||||
}
|
||||
fRT = vRT
|
||||
fSurface = vSurface
|
||||
fWidth = inWidth
|
||||
fHeight = inHeight
|
||||
mRT = vRT
|
||||
mSurface = vSurface
|
||||
mWidth = inWidth
|
||||
mHeight = inHeight
|
||||
return true
|
||||
}
|
||||
|
||||
override fun present() {
|
||||
val vSurface = fSurface ?: return
|
||||
val vSurface = mSurface ?: return
|
||||
val vWindow = backend.window ?: return
|
||||
makeCurrent()
|
||||
vSurface.flushAndSubmit()
|
||||
SDL_GL_SwapWindow(vWindow.reinterpret())
|
||||
}
|
||||
|
||||
override fun snapshot(): Image? = fSurface?.makeImageSnapshot()
|
||||
override fun snapshot(): Image? = mSurface?.makeImageSnapshot()
|
||||
|
||||
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? =
|
||||
readBackBgra(fSurface, fWidth, fHeight)
|
||||
readBackBgra(mSurface, mWidth, mHeight)
|
||||
|
||||
override fun destroy() {
|
||||
fSurface?.close()
|
||||
fRT?.close()
|
||||
fContext?.close()
|
||||
fSurface = null
|
||||
fRT = null
|
||||
fContext = null
|
||||
// Free this window's GPU objects against its OWN context, not whichever
|
||||
// window happens to be current.
|
||||
makeCurrent()
|
||||
mSurface?.close()
|
||||
mRT?.close()
|
||||
mContext?.close()
|
||||
mSurface = null
|
||||
mRT = null
|
||||
mContext = null
|
||||
}
|
||||
}
|
||||
|
||||
+20
-20
@@ -29,9 +29,9 @@ internal class SkiaRenderBackend(
|
||||
private val gpuMode: GpuMode,
|
||||
) : RenderBackend {
|
||||
|
||||
private val fBridge: SkiaBridge = buildBridge()
|
||||
private val fSkiaImageCache = SkiaImageCache()
|
||||
private var fCurrentCanvas: Canvas? = null
|
||||
private val mBridge: SkiaBridge = buildBridge()
|
||||
private val mSkiaImageCache = SkiaImageCache()
|
||||
private var mCurrentCanvas: Canvas? = null
|
||||
|
||||
init {
|
||||
// skiko's paragraph engine always supports variable-font axes (Material
|
||||
@@ -60,40 +60,40 @@ internal class SkiaRenderBackend(
|
||||
// Setting it once in the ctor left it dangling at a CLOSED window's (destroyed)
|
||||
// image cache after a multi-window teardown → crash on the surviving window's
|
||||
// next frame, so it is re-pointed at THIS backend before every frame.
|
||||
private val fLeafDrawer = SkiaLeafDrawer(fSkiaImageCache)
|
||||
private val mLeafDrawer = SkiaLeafDrawer(mSkiaImageCache)
|
||||
|
||||
override val imageLoader: ImageLoader = object : ImageLoader {
|
||||
override fun intrinsicSize(inPath: String, inKind: ResourceKind): Size =
|
||||
fSkiaImageCache.intrinsicSize(inPath, inKind)
|
||||
mSkiaImageCache.intrinsicSize(inPath, inKind)
|
||||
override fun readBytes(inPath: String): ByteArray? =
|
||||
loadComposeResourceBytes(inPath)
|
||||
}
|
||||
|
||||
private fun buildBridge(): SkiaBridge = when (gpuMode) {
|
||||
is GpuMode.Skia.Metal -> makeMetalBridge(sdl) ?: error("Skia.Metal isn't supported on this target")
|
||||
is GpuMode.Skia.OpenGL -> SkiaGLBridge(sdl).also { require(it.init()) { "Skia.OpenGL init failed" } }
|
||||
is GpuMode.Software -> SkiaSurfaceBridge(sdl)
|
||||
is GpuMode.Metal -> makeMetalBridge(sdl) ?: error("Skia.Metal isn't supported on this target")
|
||||
is GpuMode.OpenGL -> SkiaGLBridge(sdl).also { require(it.init()) { "Skia.OpenGL init failed" } }
|
||||
is GpuMode.CpuRaster -> SkiaSurfaceBridge(sdl)
|
||||
is GpuMode.Auto -> error("SkiaRenderBackend received non-Skia gpuMode $gpuMode")
|
||||
}
|
||||
|
||||
override fun ensureSize(inPixelWidth: Int, inPixelHeight: Int): Boolean =
|
||||
fBridge.ensureSize(inPixelWidth, inPixelHeight)
|
||||
mBridge.ensureSize(inPixelWidth, inPixelHeight)
|
||||
|
||||
override fun beginFrame(inDpr: Float) {
|
||||
val canvas = fBridge.canvas
|
||||
val canvas = mBridge.canvas
|
||||
// Clear the surface to Material dark. Without this the Metal back
|
||||
// buffer shows uninitialized GPU memory (pink). Clearing before scale
|
||||
// so it covers the whole physical target.
|
||||
canvas.clear(SkColor.makeARGB(0xFF, 0x12, 0x12, 0x12))
|
||||
canvas.save()
|
||||
if (inDpr != 1f) canvas.scale(inDpr, inDpr)
|
||||
fCurrentCanvas = canvas
|
||||
mCurrentCanvas = canvas
|
||||
}
|
||||
|
||||
override fun drawRoot(inDraw: (androidx.compose.ui.graphics.Canvas) -> Unit) {
|
||||
val canvas = fCurrentCanvas ?: return
|
||||
val canvas = mCurrentCanvas ?: return
|
||||
// Point the leaf-draw global at THIS window's renderers before drawing.
|
||||
skiaLeafDrawer = fLeafDrawer
|
||||
skiaLeafDrawer = mLeafDrawer
|
||||
// Wrap the live skia canvas as upstream's SkiaBackedCanvas (real gradients/
|
||||
// paint/shader). Its port draw contracts forward to skiaLeafDrawer. The
|
||||
// ImageBitmap-backed offscreen (VectorPainter / DrawCache) now goes through
|
||||
@@ -104,18 +104,18 @@ internal class SkiaRenderBackend(
|
||||
}
|
||||
|
||||
override fun endFrame() {
|
||||
fCurrentCanvas?.restore()
|
||||
fCurrentCanvas = null
|
||||
fBridge.present()
|
||||
mCurrentCanvas?.restore()
|
||||
mCurrentCanvas = null
|
||||
mBridge.present()
|
||||
}
|
||||
|
||||
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = fBridge.snapshotBgra()
|
||||
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = mBridge.snapshotBgra()
|
||||
|
||||
override fun destroy() {
|
||||
// Drop the global if it still points at this (about-to-be-destroyed) backend,
|
||||
// so a stray draw before the next window's drawRoot can't hit freed renderers.
|
||||
if (skiaLeafDrawer === fLeafDrawer) skiaLeafDrawer = null
|
||||
fSkiaImageCache.destroy()
|
||||
fBridge.destroy()
|
||||
if (skiaLeafDrawer === mLeafDrawer) skiaLeafDrawer = null
|
||||
mSkiaImageCache.destroy()
|
||||
mBridge.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
+32
-32
@@ -18,21 +18,21 @@ import sdl3.*
|
||||
/** Owns the CPU pixel buffer Skia draws into, plus the streaming SDL_Texture
|
||||
used to push that buffer onto the window. All three (buffer, Skia surface,
|
||||
SDL texture) get reallocated when the window resizes. */
|
||||
class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
private var fWidth = 0
|
||||
private var fHeight = 0
|
||||
private var fPixels: CPointer<UByteVar>? = null
|
||||
private var fSurface: Surface? = null
|
||||
private var fTexture: COpaquePointer? = null
|
||||
internal class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
private var mWidth = 0
|
||||
private var mHeight = 0
|
||||
private var mPixels: CPointer<UByteVar>? = null
|
||||
private var mSurface: Surface? = null
|
||||
private var mTexture: COpaquePointer? = null
|
||||
|
||||
override val canvas: Canvas
|
||||
get() = requireNotNull(fSurface) { "SkiaSurfaceBridge not initialised" }.canvas
|
||||
get() = requireNotNull(mSurface) { "SkiaSurfaceBridge not initialised" }.canvas
|
||||
|
||||
val width: Int get() = fWidth
|
||||
val height: Int get() = fHeight
|
||||
val width: Int get() = mWidth
|
||||
val height: Int get() = mHeight
|
||||
|
||||
override fun ensureSize(inWidth: Int, inHeight: Int): Boolean {
|
||||
if (inWidth == fWidth && inHeight == fHeight && fSurface != null) return true
|
||||
if (inWidth == mWidth && inHeight == mHeight && mSurface != null) return true
|
||||
if (inWidth <= 0 || inHeight <= 0) return false
|
||||
destroy()
|
||||
|
||||
@@ -60,34 +60,34 @@ class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
}
|
||||
SDL_SetTextureBlendMode(vTexture.reinterpret(), SDL_BLENDMODE_NONE)
|
||||
|
||||
fPixels = vPixels
|
||||
fSurface = vSurface
|
||||
fTexture = vTexture
|
||||
fWidth = inWidth
|
||||
fHeight = inHeight
|
||||
mPixels = vPixels
|
||||
mSurface = vSurface
|
||||
mTexture = vTexture
|
||||
mWidth = inWidth
|
||||
mHeight = inHeight
|
||||
return true
|
||||
}
|
||||
|
||||
/** Skia → SDL_Texture → screen, once per frame. */
|
||||
override fun present() {
|
||||
val vRenderer = backend.renderer ?: return
|
||||
val vTexture = fTexture ?: return
|
||||
val vSurface = fSurface ?: return
|
||||
val vPixels = fPixels ?: return
|
||||
val vTexture = mTexture ?: return
|
||||
val vSurface = mSurface ?: return
|
||||
val vPixels = mPixels ?: return
|
||||
|
||||
vSurface.flushAndSubmit()
|
||||
SDL_UpdateTexture(vTexture.reinterpret(), null, vPixels, fWidth * 4)
|
||||
SDL_UpdateTexture(vTexture.reinterpret(), null, vPixels, mWidth * 4)
|
||||
SDL_RenderTexture(vRenderer.reinterpret(), vTexture.reinterpret(), null, null)
|
||||
SDL_RenderPresent(vRenderer.reinterpret())
|
||||
}
|
||||
|
||||
override fun snapshot(): Image? = fSurface?.makeImageSnapshot()
|
||||
override fun snapshot(): Image? = mSurface?.makeImageSnapshot()
|
||||
|
||||
/** CPU bridge already has the raw pixels in fPixels (RGBA8888 premul).
|
||||
/** CPU bridge already has the raw pixels in mPixels (RGBA8888 premul).
|
||||
Repack to BGRA so we share one BMP writer with the GPU bridges. */
|
||||
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? {
|
||||
val vPixels = fPixels ?: return null
|
||||
val vCount = fWidth * fHeight
|
||||
val vPixels = mPixels ?: return null
|
||||
val vCount = mWidth * mHeight
|
||||
val vBytes = ByteArray(vCount * 4)
|
||||
for (i in 0 until vCount) {
|
||||
val vR = vPixels[i * 4 + 0]
|
||||
@@ -99,17 +99,17 @@ class SkiaSurfaceBridge(private val backend: SDL3Backend) : SkiaBridge {
|
||||
vBytes[i * 4 + 2] = vR.toByte()
|
||||
vBytes[i * 4 + 3] = vA.toByte()
|
||||
}
|
||||
return Triple(fWidth, fHeight, vBytes)
|
||||
return Triple(mWidth, mHeight, vBytes)
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
fTexture?.let { SDL_DestroyTexture(it.reinterpret()) }
|
||||
fSurface?.close()
|
||||
fPixels?.let { nativeHeap.free(it) }
|
||||
fTexture = null
|
||||
fSurface = null
|
||||
fPixels = null
|
||||
fWidth = 0
|
||||
fHeight = 0
|
||||
mTexture?.let { SDL_DestroyTexture(it.reinterpret()) }
|
||||
mSurface?.close()
|
||||
mPixels?.let { nativeHeap.free(it) }
|
||||
mTexture = null
|
||||
mSurface = null
|
||||
mPixels = null
|
||||
mWidth = 0
|
||||
mHeight = 0
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import com.compose.sdl.renderer.skia.SkiaBridge
|
||||
// MARK: Windows (mingwX64) GPU defaults
|
||||
// ==================
|
||||
|
||||
// No Metal on Windows. The default Skia bridge is CPU raster (GpuMode.Software →
|
||||
// No Metal on Windows. The default Skia bridge is CPU raster (GpuMode.CpuRaster →
|
||||
// SkiaSurfaceBridge), so no GPU-context bridge is provided here yet. A D3D/Vulkan
|
||||
// bridge can be added later (Route 1a milestone 2).
|
||||
internal actual fun makeMetalBridge(backend: SDL3Backend): SkiaBridge? = null
|
||||
|
||||
@@ -2,7 +2,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -10,29 +9,17 @@ import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.decodeToImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.compose.sdl.Window
|
||||
import com.compose.sdl.nativeComposeApp
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
import demo.registry.allCategories
|
||||
import demo.shell.App
|
||||
import screens.ExtraWindows
|
||||
import utils.encodeBmpBgra32
|
||||
import utils.parseArgs
|
||||
import utils.writeFile
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.allocArray
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.toKString
|
||||
|
||||
// ==================
|
||||
// MARK: Entry point
|
||||
@@ -79,6 +66,12 @@ fun main(args: Array<String>) {
|
||||
runSearchEscTest()
|
||||
return
|
||||
}
|
||||
// Verifies the Compose Desktop window attributes (undecorated / resizable /
|
||||
// alwaysOnTop / focusable / enabled) reach SDL, plus the raw SDL escape hatch.
|
||||
if (args.any { it == "--winattrtest" }) {
|
||||
runWindowAttrTest()
|
||||
return
|
||||
}
|
||||
// Verifies nativeComposeApp multi-window: two Windows render concurrently,
|
||||
// one closes via state, the app keeps running on the survivor.
|
||||
if (args.any { it == "--multiwintest" }) {
|
||||
@@ -288,947 +281,3 @@ private fun com.compose.sdl.ComposeWindowScope.ExtraWindowContent(inId: Int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a window with a BackHandler, injects an Escape key through the live SDL
|
||||
path, and asserts the handler fired - proving ComposeWindow's
|
||||
BackNavigationInput drives the NavigationEventDispatcher (the mechanism that
|
||||
collapses an expanded m3 SearchBar on Escape). */
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
private fun runBackTest() {
|
||||
val vBackNoFocus = mutableStateOf(false)
|
||||
val vBackFocused = mutableStateOf(false)
|
||||
val vText = mutableStateOf("")
|
||||
nativeComposeWindow(
|
||||
title = "backtest",
|
||||
width = 400,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
when (frameIndex) {
|
||||
// Phase 1: Escape with NOTHING focused.
|
||||
20 -> {
|
||||
com.compose.sdl.injectKey(41, true) // SDL_SCANCODE_ESCAPE
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
// Phase 2: click the text field to focus it, then Escape -
|
||||
// the user-facing SearchBar scenario (field focused while
|
||||
// the back handler should collapse the bar).
|
||||
30 -> { com.compose.sdl.injectMouseEvent(1, 200f, 100f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 100f); true }
|
||||
44 -> {
|
||||
com.compose.sdl.injectKey(41, true)
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
70 -> {
|
||||
println("backtest: noFocus=${vBackNoFocus.value} focused=${vBackFocused.value}")
|
||||
println(
|
||||
if (vBackNoFocus.value && vBackFocused.value)
|
||||
"backtest: PASS (Escape completed back navigation with and without a focused field)"
|
||||
else
|
||||
"backtest: FAIL (noFocus=${vBackNoFocus.value} focusedField=${vBackFocused.value})"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
androidx.compose.ui.backhandler.BackHandler(enabled = true) {
|
||||
if (!vBackNoFocus.value) vBackNoFocus.value = true else vBackFocused.value = true
|
||||
}
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// The NEW state-based field - the one m3 SearchBar's InputField
|
||||
// uses - so the probe exercises its key handler, not the legacy
|
||||
// value-based one.
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
state = androidx.compose.foundation.text.input.rememberTextFieldState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drives a SharedTransitionLayout shared-element morph both directions from
|
||||
the frame counter (no clicks) - it requires the LOOKAHEAD pass, which dies
|
||||
with "LookaheadDelegate has not been measured yet" if the owner drops
|
||||
affectsLookahead measure/relayout requests. Reaching the end frame = PASS. */
|
||||
@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
|
||||
private fun runSharedTest() {
|
||||
val vBig = mutableStateOf(false)
|
||||
nativeComposeWindow(
|
||||
title = "sharedtest",
|
||||
width = 300,
|
||||
height = 300,
|
||||
onFrame = { _, vFrame ->
|
||||
when (vFrame) {
|
||||
20 -> { vBig.value = true; true } // small → big morph
|
||||
70 -> { vBig.value = false; true } // big → small morph
|
||||
130 -> {
|
||||
println("sharedtest: PASS (shared-element morph ran both directions)")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
androidx.compose.animation.SharedTransitionLayout {
|
||||
Column {
|
||||
androidx.compose.animation.AnimatedVisibility(visible = !vBig.value) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key = "box"),
|
||||
animatedVisibilityScope = this@AnimatedVisibility,
|
||||
)
|
||||
.size(48.dp)
|
||||
.background(Color(0xFF7C4DFF), RoundedCornerShape(8.dp)),
|
||||
)
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(visible = vBig.value) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key = "box"),
|
||||
animatedVisibilityScope = this@AnimatedVisibility,
|
||||
)
|
||||
.size(160.dp)
|
||||
.background(Color(0xFF26A69A), RoundedCornerShape(24.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots TWO windows via nativeComposeApp, asserts both render, closes the
|
||||
second by flipping the state that composes its Window(), and asserts the app
|
||||
keeps running on the first - the multi-window lifecycle end-to-end. */
|
||||
private fun runMultiWindowTest() {
|
||||
val vShowSecond = mutableStateOf(true)
|
||||
var vSecondFrames = 0
|
||||
var vResult = "FAIL (main window never reached the end frame)"
|
||||
nativeComposeApp {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "multiwin main",
|
||||
width = 400,
|
||||
height = 200,
|
||||
onFrame = { _, vFrame ->
|
||||
when (vFrame) {
|
||||
40 -> {
|
||||
if (vSecondFrames == 0) {
|
||||
vResult = "FAIL (second window never rendered)"
|
||||
false
|
||||
} else {
|
||||
vShowSecond.value = false // close the second window via state
|
||||
true
|
||||
}
|
||||
}
|
||||
80 -> {
|
||||
vResult =
|
||||
if (!vShowSecond.value && vSecondFrames > 0)
|
||||
"PASS (both windows rendered; second closed via state; app survived on the first)"
|
||||
else "FAIL (second=$vSecondFrames showSecond=${vShowSecond.value})"
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("main window", color = Color.White)
|
||||
}
|
||||
if (vShowSecond.value) {
|
||||
Window(
|
||||
onCloseRequest = { vShowSecond.value = false },
|
||||
title = "multiwin second",
|
||||
width = 300,
|
||||
height = 150,
|
||||
onFrame = { _, _ -> vSecondFrames++; true },
|
||||
) {
|
||||
Text("second window", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
println("multiwintest: secondFrames=$vSecondFrames")
|
||||
println("multiwintest: $vResult")
|
||||
}
|
||||
|
||||
/** Boots the REAL Search screen, clicks the first SearchBar's input field
|
||||
(expands it), presses Escape, and writes esc_before.bmp / esc_after.bmp -
|
||||
the expanded overlay must be visible in `before` and gone in `after`. */
|
||||
private fun runSearchEscTest() {
|
||||
fun snap(inBridge: com.compose.sdl.RenderBackend, inName: String) {
|
||||
val vSnap = inBridge.snapshotBgra() ?: return
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile(inName, encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("searchesctest: wrote $inName")
|
||||
}
|
||||
nativeComposeWindow(
|
||||
title = "searchesctest",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
when (vFrame) {
|
||||
30 -> { com.compose.sdl.injectMouseEvent(1, 200f, 230f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 230f); true }
|
||||
70 -> { snap(vBridge, "esc_before.bmp"); true }
|
||||
80 -> {
|
||||
com.compose.sdl.injectKey(41, true) // Escape
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
130 -> { snap(vBridge, "esc_after.bmp"); false }
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
val vScroll = rememberScrollState()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(vScroll)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
screens.M3SearchScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Boots an empty window, then composes screens.Navigation3Screen() at frame 30 -
|
||||
AFTER the window lifecycle reached RESUMED - mirroring the real sidebar flow.
|
||||
Crashes here (e.g. enableSavedStateHandles' INITIALIZED/CREATED contract) never
|
||||
reproduce under --screen, which composes during the initial CREATED composition.
|
||||
PASS = a screenshot gets written and the app exits cleanly. */
|
||||
/** P2.2 soak - cycle through EVERY registered screen kCycles times in ONE process,
|
||||
disposing each via a changing key() so composition + layer (skiko RenderNode)
|
||||
allocate-and-release is exercised repeatedly. After each full cycle, GC then
|
||||
record peak RSS (getrusage ru_maxrss). Peak RSS is monotonic, so after cycle 1 it
|
||||
already reflects visiting every screen once; if there is NO leak it plateaus, if
|
||||
there IS one it keeps climbing each cycle. PASS iff last-cycle peak stays within a
|
||||
ceiling of the first-cycle peak. */
|
||||
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class, ExperimentalForeignApi::class)
|
||||
private fun runSoakTest() {
|
||||
// Disable never-settling animations so RSS reflects composition/layer lifetime,
|
||||
// not live animation-state churn (same seed the screenshot path uses).
|
||||
com.compose.sdl.disableInfiniteAnimations = true
|
||||
com.compose.sdl.useVirtualFrameTime = true
|
||||
val vAll = allCategories().flatMap { it.screens }
|
||||
// CDN_SOAK_SCREEN=<name> repeats ONE screen 40x/cycle (bisect a specific screen);
|
||||
// default cycles through every screen.
|
||||
val vTarget = platform.posix.getenv("CDN_SOAK_SCREEN")?.toKString()
|
||||
val vScreens = if (vTarget != null) { val vS = vAll.first { it.name.equals(vTarget, ignoreCase = true) }; List(40) { vS } }
|
||||
else vAll
|
||||
val vIndex = mutableStateOf(0)
|
||||
// CDN_SOAK_STATIC=1: mount ONE screen once, never remount - measure RSS every 120 frames.
|
||||
// Isolates a per-FRAME leak (RSS climbs with no remounts) from a per-MOUNT leak.
|
||||
val vStatic = platform.posix.getenv("CDN_SOAK_STATIC")?.toKString() == "1"
|
||||
val kFramesPerScreen = if (vStatic) 120 else 3
|
||||
val kCycles = platform.posix.getenv("CDN_SOAK_CYCLES")?.toKString()?.toIntOrNull() ?: 3
|
||||
val vRssPerCycleMb = mutableListOf<Long>()
|
||||
var vShown = 0
|
||||
|
||||
nativeComposeWindow(
|
||||
title = "soaktest",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { _, vFrame ->
|
||||
if (vFrame > 0 && vFrame % kFramesPerScreen == 0) {
|
||||
if (vStatic) {
|
||||
// No remount - just pump frames on the one mounted screen.
|
||||
kotlin.native.runtime.GC.collect()
|
||||
vRssPerCycleMb.add(currentResidentMb())
|
||||
println("soaktest[static]: measure ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
|
||||
} else {
|
||||
vShown++
|
||||
vIndex.value = vShown % vScreens.size
|
||||
if (vShown % vScreens.size == 0) {
|
||||
kotlin.native.runtime.GC.collect()
|
||||
vRssPerCycleMb.add(currentResidentMb())
|
||||
println("soaktest: cycle ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vRssPerCycleMb.size >= kCycles) {
|
||||
val vFirst = vRssPerCycleMb.first()
|
||||
val vLast = vRssPerCycleMb.last()
|
||||
val vGrowth = vLast - vFirst
|
||||
val vCeiling = maxOf(48L, vFirst / 4) // 25% or 48MB, whichever larger
|
||||
println("soaktest: currentRSS/cycle(MB)=$vRssPerCycleMb (${vScreens.size} screens x $kCycles cycles)")
|
||||
if (vGrowth <= vCeiling) {
|
||||
println("soaktest: PASS (current RSS grew ${vGrowth}MB over cycles 1->$kCycles, within ${vCeiling}MB ceiling)")
|
||||
} else {
|
||||
println("soaktest: FAIL (current RSS grew ${vGrowth}MB > ${vCeiling}MB ceiling - possible leak)")
|
||||
}
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
val vScroll = rememberScrollState()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(vScroll)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
// key(vShown) forces a FULL dispose+recompose each advance (even when the
|
||||
// target screen repeats) - the allocate/release churn we soak. Reading
|
||||
// vIndex.value (a State) is what triggers the recomposition each mount.
|
||||
key(vShown) {
|
||||
vScreens[vIndex.value].content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CURRENT resident set (MB): current RSS can DROP after GC, so it distinguishes a
|
||||
true leak (ratchets up) from K/N allocator high-water, unlike getrusage's
|
||||
monotonic peak. posix reads it from `ps`; mingw has no `ps`/popen and returns
|
||||
-1 (the soak gate runs on macOS/Linux - see scripts/verify-mac.sh). */
|
||||
internal expect fun currentResidentMb(): Long
|
||||
|
||||
/** --localetest: prints Locale.current / LocaleList.current (should reflect the OS
|
||||
preferred locales via SDL) and screenshots an M3 DatePicker, whose headline and
|
||||
navigation labels are M3-translated by Locale.current. Run under a forced locale,
|
||||
e.g. `demo.kexe --localetest -AppleLanguages "(fr-FR)"` on macOS. */
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
private fun runLocaleTest() {
|
||||
nativeComposeWindow(
|
||||
title = "localetest",
|
||||
width = 420,
|
||||
height = 560,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
when (vFrame) {
|
||||
2 -> {
|
||||
val vCur = androidx.compose.ui.text.intl.Locale.current
|
||||
val vList = androidx.compose.ui.text.intl.LocaleList.current
|
||||
println("localetest: Locale.current=${vCur.toLanguageTag()}")
|
||||
println("localetest: LocaleList.current=[${vList.localeList.joinToString { it.toLanguageTag() }}]")
|
||||
true
|
||||
}
|
||||
40 -> {
|
||||
val vSnap = vBridge.snapshotBgra()
|
||||
if (vSnap != null) {
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile("localetest.bmp", encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("localetest: wrote localetest.bmp (${vW}x${vH})")
|
||||
} else println("localetest: FAIL (no snapshot)")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val vState = androidx.compose.material3.rememberDatePickerState()
|
||||
androidx.compose.material3.DatePicker(state = vState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** --cursortest: injects hover moves over pointerHoverIcon(Text) / (Hand) regions
|
||||
and a bare background, printing the applied SDL system cursor after each. Proves
|
||||
PointerIcon -> SDL_SetCursor end-to-end through the live hover pipeline. */
|
||||
private fun runCursorTest() {
|
||||
var vBg = "?"; var vText = "?"; var vHand = "?"
|
||||
nativeComposeWindow(
|
||||
title = "cursortest",
|
||||
width = 400,
|
||||
height = 400,
|
||||
onFrame = { _, vFrame ->
|
||||
when (vFrame) {
|
||||
10 -> { com.compose.sdl.injectMouseEvent(0, 200f, 300f); true } // background (below both boxes)
|
||||
14 -> { vBg = com.compose.sdl.appliedCursorName(); true }
|
||||
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 50f); true } // Text box
|
||||
24 -> { vText = com.compose.sdl.appliedCursorName(); true }
|
||||
30 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true } // Hand box
|
||||
34 -> {
|
||||
vHand = com.compose.sdl.appliedCursorName()
|
||||
println("cursortest: background=$vBg text=$vText hand=$vHand")
|
||||
val vOk = vBg.endsWith("DEFAULT") && vText.endsWith("TEXT") && vHand.endsWith("POINTER")
|
||||
println(if (vOk) "cursortest: PASS" else "cursortest: FAIL")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
Box(Modifier.fillMaxWidth().height(100.dp).pointerHoverIcon(PointerIcon.Text))
|
||||
Box(Modifier.fillMaxWidth().height(100.dp).pointerHoverIcon(PointerIcon.Hand))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** --windowinfotest: prints LocalWindowInfo.isWindowFocused / containerSize /
|
||||
containerDpSize. containerSize starts Zero and becomes the window pixel size
|
||||
after the first measure - proof it's fed from the live root constraints. */
|
||||
private fun runWindowInfoTest() {
|
||||
nativeComposeWindow(
|
||||
title = "windowinfotest",
|
||||
width = 400,
|
||||
height = 300,
|
||||
onFrame = { _, vFrame -> vFrame < 6 },
|
||||
) {
|
||||
val vWi = androidx.compose.ui.platform.LocalWindowInfo.current
|
||||
androidx.compose.runtime.LaunchedEffect(vWi.containerSize, vWi.isWindowFocused) {
|
||||
println("windowinfotest: focused=${vWi.isWindowFocused} containerSize=${vWi.containerSize} containerDpSize=${vWi.containerDpSize}")
|
||||
}
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** --imetest: focuses a BasicTextField, injects an IME composition (SDL TEXT_EDITING
|
||||
"ni"), then a commit (SDL TEXT_INPUT "に"). Verifies the preedit shows a
|
||||
composing region and the commit REPLACES it (not appends) - the real IME path. */
|
||||
private fun runImeTest() {
|
||||
val vField = mutableStateOf(androidx.compose.ui.text.input.TextFieldValue(""))
|
||||
nativeComposeWindow(
|
||||
title = "imetest",
|
||||
width = 500,
|
||||
height = 200,
|
||||
onFrame = { _, vFrame ->
|
||||
when (vFrame) {
|
||||
12 -> { com.compose.sdl.injectMouseEvent(1, 250f, 100f); true } // focus centered field
|
||||
14 -> { com.compose.sdl.injectMouseEvent(2, 250f, 100f); true }
|
||||
34 -> { com.compose.sdl.injectTextEditing("ni"); true } // composing (preedit)
|
||||
40 -> { println("imetest: composing text='${vField.value.text}' composition=${vField.value.composition}"); true }
|
||||
50 -> { com.compose.sdl.injectTextInput("に"); true } // commit "に"
|
||||
60 -> {
|
||||
println("imetest: committed text='${vField.value.text}' composition=${vField.value.composition}")
|
||||
val vOk = vField.value.text == "に" && vField.value.composition == null
|
||||
println(if (vOk) "imetest: PASS" else "imetest: FAIL")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
value = vField.value,
|
||||
onValueChange = { vField.value = it },
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 24.sp),
|
||||
cursorBrush = androidx.compose.ui.graphics.SolidColor(Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** --imagebytestest: decodes an in-memory 2x2 BMP via ByteArray.decodeToImageBitmap()
|
||||
(routes to createImageBitmap(bytes)) through Skia's image decoder. Needs a window
|
||||
so the render backend installs the encoded-image decoder. */
|
||||
private fun runImageBytesTest() {
|
||||
nativeComposeWindow(
|
||||
title = "imagebytestest",
|
||||
width = 200,
|
||||
height = 200,
|
||||
onFrame = { _, vFrame ->
|
||||
if (vFrame >= 2) {
|
||||
try {
|
||||
val vBitmap = tinyRedBmp().decodeToImageBitmap()
|
||||
println("imagebytestest: decoded ${vBitmap.width}x${vBitmap.height}")
|
||||
println(if (vBitmap.width == 2 && vBitmap.height == 2) "imagebytestest: PASS" else "imagebytestest: FAIL (wrong size)")
|
||||
} catch (e: Throwable) {
|
||||
println("imagebytestest: FAIL (${e::class.simpleName}: ${e.message})")
|
||||
}
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {}
|
||||
}
|
||||
|
||||
/** --fonttest: same sample in FontFamily.Default (sans) and FontFamily.Monospace.
|
||||
Monospace used to collapse to the default sans; now it renders NotoSansMono
|
||||
(bundled because this source references FontFamily.Monospace). */
|
||||
private fun runFontTest() {
|
||||
nativeComposeWindow(
|
||||
title = "fonttest",
|
||||
width = 560,
|
||||
height = 220,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
if (vFrame >= 10) {
|
||||
val vSnap = vBridge.snapshotBgra()
|
||||
if (vSnap != null) {
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile("fonttest.bmp", encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("fonttest: wrote fonttest.bmp (${vW}x${vH})")
|
||||
}
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Column(Modifier.fillMaxSize().background(Color(0xFF202020)).padding(20.dp)) {
|
||||
val vSample = "Illegal1 lIO0 {}=>"
|
||||
Text(vSample, color = Color.White, fontSize = 30.sp)
|
||||
Text(
|
||||
vSample, color = Color.Cyan, fontSize = 30.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A minimal 2x2 24bpp red BMP (no compression) - valid decoder input.
|
||||
private fun tinyRedBmp(): ByteArray {
|
||||
fun le16(v: Int) = byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte())
|
||||
fun le32(v: Int) = byteArrayOf(
|
||||
(v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(),
|
||||
((v shr 16) and 0xFF).toByte(), ((v shr 24) and 0xFF).toByte(),
|
||||
)
|
||||
val vPixel = byteArrayOf(0, 0, 0xFF.toByte()) // BGR red
|
||||
val vRow = vPixel + vPixel + byteArrayOf(0, 0) // 2px + 2 pad = 8 bytes (4-byte aligned)
|
||||
val vPixels = vRow + vRow // 2 rows = 16 bytes
|
||||
val vFileHeader = byteArrayOf('B'.code.toByte(), 'M'.code.toByte()) + le32(70) + le32(0) + le32(54)
|
||||
val vDib = le32(40) + le32(2) + le32(2) + le16(1) + le16(24) + le32(0) + le32(16) +
|
||||
le32(2835) + le32(2835) + le32(0) + le32(0)
|
||||
return vFileHeader + vDib + vPixels
|
||||
}
|
||||
|
||||
private fun runNav3Test() {
|
||||
val vShow = mutableStateOf(false)
|
||||
nativeComposeWindow(
|
||||
title = "nav3test",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
when (vFrame) {
|
||||
30 -> { vShow.value = true; true }
|
||||
// Push Detail #1 (click its card) → per-entry lifecycle goes
|
||||
// CREATED/STARTED during the slide, RESUMED once settled.
|
||||
60 -> { com.compose.sdl.injectMouseEvent(1, 500f, 216f); true }
|
||||
62 -> { com.compose.sdl.injectMouseEvent(2, 500f, 216f); true }
|
||||
// Pop with ESC → ON_PAUSE, then CREATED while animating out,
|
||||
// then the entry disposes.
|
||||
120 -> {
|
||||
com.compose.sdl.injectKey(41, true)
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
180 -> {
|
||||
val vSnap = vBridge.snapshotBgra()
|
||||
if (vSnap != null) {
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile("nav3late.bmp", encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("nav3test: wrote nav3late.bmp (${vW}x${vH})")
|
||||
println("nav3test: PASS (Navigation3 composed at RESUMED; push + pop ran)")
|
||||
} else println("nav3test: FAIL (no snapshot)")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
if (vShow.value) {
|
||||
Box(modifier = Modifier.padding(24.dp)) { screens.Navigation3Screen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Frame-by-frame trace of AnimatedVisibility(fadeIn+expandVertically / fadeOut+
|
||||
shrinkVertically): logs the AV container's animated size and the window-Y of a
|
||||
marker Box below it every frame, toggling visibility three times. Diagnoses
|
||||
(a) a size snap between just-before-end and end of the animation and (b)
|
||||
instant (non-animated) transitions on subsequent toggles.
|
||||
|
||||
Expected healthy trace: ~24 smooth frames per toggle ending exactly at 0/60,
|
||||
PLUS one 16px marker jump when the fully-shrunk node unmounts (exit end) or
|
||||
mounts (enter start) - that's the parent's spacedBy(16) collapsing, inherent
|
||||
upstream behaviour (spacing applies to zero-height children too), NOT a bug. */
|
||||
private fun runAnimVisTest() {
|
||||
val vShown = mutableStateOf(true)
|
||||
var vAvSize = androidx.compose.ui.unit.IntSize(-1, -1)
|
||||
var vMarkerY = -1f
|
||||
var vLastLog = ""
|
||||
nativeComposeWindow(
|
||||
title = "animvistest",
|
||||
width = 600,
|
||||
height = 500,
|
||||
onFrame = { _, vFrame ->
|
||||
// Log only when something moved - keeps the trace readable.
|
||||
val vLine = "size=$vAvSize markerY=$vMarkerY shown=${vShown.value}"
|
||||
if (vLine != vLastLog) {
|
||||
println("animvistest: f=$vFrame $vLine")
|
||||
vLastLog = vLine
|
||||
}
|
||||
when (vFrame) {
|
||||
60 -> { println("animvistest: === HIDE 1 ==="); vShown.value = false; true }
|
||||
140 -> { println("animvistest: === SHOW 2 ==="); vShown.value = true; true }
|
||||
220 -> { println("animvistest: === HIDE 3 ==="); vShown.value = false; true }
|
||||
300 -> false
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
// spacedBy mirrors the demo's Section column - the end-of-exit jump
|
||||
// reported on the FoundationExtra screen involves the spacing around
|
||||
// the AnimatedVisibility node collapsing when the node unmounts.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(Modifier.size(30.dp).background(androidx.compose.ui.graphics.Color(0xFFC07040)))
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = vShown.value,
|
||||
modifier = Modifier.onSizeChanged { vAvSize = it },
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.background(androidx.compose.ui.graphics.Color(0xFF7040C0)),
|
||||
) {
|
||||
Text("Animated content", modifier = Modifier.padding(14.dp))
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(androidx.compose.ui.graphics.Color(0xFF40C070))
|
||||
.onGloballyPositioned { vMarkerY = it.positionInRoot().y },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a window whose full-size surface opens an m3 AlertDialog on click (the real
|
||||
material3 → ui.window.Dialog path, default DialogProperties ⇒ animateTransition on),
|
||||
injects the click, then dumps screenshots during the 0.2s appearance animation and
|
||||
after it settles; then injects Escape and dumps mid-disappearance (0.1s reverse,
|
||||
via the popup host's exit deferral) + after. Visual check: mid shots must show the
|
||||
dialog semi-transparent, slightly scaled-down and shifted down vs the settled shot,
|
||||
and exit_end must show no dialog at all - skiko-parity animation both ways. */
|
||||
private fun runDialogAnimTest() {
|
||||
fun snap(inBridge: com.compose.sdl.RenderBackend, inName: String) {
|
||||
val vSnap = inBridge.snapshotBgra() ?: return
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile(inName, encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("dialoganimtest: wrote $inName")
|
||||
}
|
||||
nativeComposeWindow(
|
||||
title = "dialoganimtest",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
when (vFrame) {
|
||||
30 -> { com.compose.sdl.injectMouseEvent(1, 500f, 350f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 500f, 350f); true }
|
||||
36 -> { snap(vBridge, "dialog_mid1.bmp"); true }
|
||||
40 -> { snap(vBridge, "dialog_mid2.bmp"); true }
|
||||
90 -> { snap(vBridge, "dialog_end.bmp"); true }
|
||||
92 -> {
|
||||
com.compose.sdl.injectKey(41, true) // Escape → dismiss
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
97 -> { snap(vBridge, "dialog_exit_mid.bmp"); true }
|
||||
140 -> { snap(vBridge, "dialog_exit_end.bmp"); false }
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
var vShow by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.clickable { vShow = true },
|
||||
)
|
||||
if (vShow) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { vShow = false },
|
||||
title = { Text("Animated dialog") },
|
||||
text = { Text("Appearance animation parity with the JVM (skiko) Dialog.") },
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { vShow = false }) { Text("OK") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a real window, then builds an upstream Paragraph via the
|
||||
vendored factory (→ SkiaParagraph) for a long string constrained to a narrow width. Verifies it
|
||||
wrapped to multiple lines, has positive size, and that getHorizontalPosition/getOffsetForPosition
|
||||
round-trip - proving the paragraph-engine measurement bridge works. */
|
||||
private fun runParagraphTest() {
|
||||
nativeComposeWindow(
|
||||
title = "paragraphtest",
|
||||
width = 400,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
if (frameIndex == 10) {
|
||||
val vP = androidx.compose.ui.text.Paragraph(
|
||||
text = "Hello world foo bar baz qux quux corge grault garply waldo",
|
||||
style = androidx.compose.ui.text.TextStyle(fontSize = 16.sp),
|
||||
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 120),
|
||||
density = androidx.compose.ui.unit.Density(1f),
|
||||
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
|
||||
)
|
||||
val vHpos = vP.getHorizontalPosition(3, true)
|
||||
val vOffBack = vP.getOffsetForPosition(androidx.compose.ui.geometry.Offset(vHpos, 2f))
|
||||
val vLine = vP.getLineForOffset(30)
|
||||
println("paragraphtest: lineCount=${vP.lineCount} w=${vP.width} h=${vP.height} hpos(3)=$vHpos offBack=$vOffBack lineFor(30)=$vLine")
|
||||
val vPass = vP.lineCount >= 2 && vP.height > 0f && vP.width > 0f && vOffBack in 2..4
|
||||
println(if (vPass) "paragraphtest: PASS (real width-wrapped Paragraph via SkiaParagraph)" else "paragraphtest: FAIL")
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Prints paragraph metrics (single-line cell, lineHeight-styled single + triple line,
|
||||
first baseline) for a font-size sweep at density 1 - the native half of the
|
||||
metrics-alignment probe. Mirror of MainJvm's `--metrics`; both must print the same
|
||||
numbers for the parity text drift to vanish. */
|
||||
private fun runMetricsProbe() {
|
||||
fun paragraph(inText: String, inSize: Int, inLineHeight: Int?, inM3Style: Boolean): androidx.compose.ui.text.Paragraph =
|
||||
androidx.compose.ui.text.Paragraph(
|
||||
text = inText,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = inSize.sp,
|
||||
lineHeight = inLineHeight?.sp ?: androidx.compose.ui.unit.TextUnit.Unspecified,
|
||||
lineHeightStyle = if (inM3Style) {
|
||||
androidx.compose.ui.text.style.LineHeightStyle(
|
||||
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
|
||||
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
|
||||
)
|
||||
} else null,
|
||||
),
|
||||
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 10_000),
|
||||
density = androidx.compose.ui.unit.Density(1f),
|
||||
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
|
||||
)
|
||||
nativeComposeWindow(
|
||||
title = "metricsprobe",
|
||||
width = 300,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
if (frameIndex == 10) {
|
||||
for (vSize in listOf(11, 12, 14, 16, 22, 24)) {
|
||||
val vLh = vSize + 6
|
||||
val vCell = paragraph("Hg", vSize, null, false)
|
||||
val vOne = paragraph("Hg", vSize, vLh, false)
|
||||
val vThree = paragraph("Hg\nHg\nHg", vSize, vLh, false)
|
||||
val vOneM3 = paragraph("Hg", vSize, vLh, true)
|
||||
val vThreeM3 = paragraph("Hg\nHg\nHg", vSize, vLh, true)
|
||||
println(
|
||||
"metrics: size=$vSize lh=$vLh cell=${vCell.height} " +
|
||||
"one=${vOne.height} three=${vThree.height} " +
|
||||
"base1=${vOne.firstBaseline} base3=${vThree.lastBaseline} " +
|
||||
"oneM3=${vOneM3.height} threeM3=${vThreeM3.height} base1M3=${vOneM3.firstBaseline}"
|
||||
)
|
||||
}
|
||||
for ((vS, vL) in listOf(24 to 24, 24 to 25, 32 to 24, 16 to 16)) {
|
||||
val vB = paragraph("Hg", vS, vL, true)
|
||||
println("metrics: boundary $vS/$vL m3=${vB.height} base=${vB.firstBaseline}")
|
||||
}
|
||||
val vBig = paragraph("42", 48, 24, false)
|
||||
val vBigM3 = paragraph("42", 48, 24, true)
|
||||
println("metrics: big48/lh24 raw=${vBig.height} base=${vBig.firstBaseline} m3=${vBigM3.height} baseM3=${vBigM3.firstBaseline}")
|
||||
println("metricsprobe: DONE")
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: clicktest
|
||||
// ==================
|
||||
|
||||
/** Boots a real nativeComposeWindow with a full-size clickable box, then injects
|
||||
move→press→release SDL mouse events a few frames apart (giving the upstream
|
||||
clickable's gesture coroutine time to launch + await between frames), and prints
|
||||
PASS/FAIL based on whether onClick fired. Proves the whole vendored interaction
|
||||
pipeline works under the real Sdl3MainDispatcher + frame loop. */
|
||||
private fun runClickTest() {
|
||||
var vClicks = 0
|
||||
nativeComposeWindow(
|
||||
title = "clicktest",
|
||||
width = 400,
|
||||
height = 300,
|
||||
onFrame = { _, frameIndex ->
|
||||
when (frameIndex) {
|
||||
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true }
|
||||
26 -> { com.compose.sdl.injectMouseEvent(1, 200f, 150f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 150f); true }
|
||||
70 -> {
|
||||
println(
|
||||
if (vClicks > 0) "clicktest: PASS ($vClicks click(s) via upstream clickable)"
|
||||
else "clicktest: FAIL (0 clicks - upstream clickable did not fire)"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF303040))
|
||||
.clickable { vClicks++; println("clicktest: onClick fired -> $vClicks") },
|
||||
) {
|
||||
Text("Click test", color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Same injection harness as clicktest, but the target is a Material Switch (Modifier.toggleable
|
||||
from the vendored foundation.selection). Asserts onCheckedChange flipped the state - proving
|
||||
toggleable rides the same verified upstream interaction path as clickable. */
|
||||
private fun runToggleTest() {
|
||||
var vChecked = false
|
||||
var vChanges = 0
|
||||
nativeComposeWindow(
|
||||
title = "toggletest",
|
||||
width = 400,
|
||||
height = 300,
|
||||
onFrame = { _, frameIndex ->
|
||||
when (frameIndex) {
|
||||
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true }
|
||||
26 -> { com.compose.sdl.injectMouseEvent(1, 200f, 150f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 150f); true }
|
||||
70 -> {
|
||||
println(
|
||||
if (vChanges > 0 && vChecked) "toggletest: PASS (switch toggled to $vChecked via toggleable)"
|
||||
else "toggletest: FAIL (changes=$vChanges checked=$vChecked)"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
androidx.compose.material3.Switch(
|
||||
checked = vChecked,
|
||||
onCheckedChange = { vChecked = it; vChanges++; println("toggletest: onCheckedChange -> $it") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a window with a real BasicTextField, clicks it to focus (focus-on-click via the
|
||||
FocusOwner), then injects TEXT_INPUT ("A","B") and a Backspace key through the live SDL path.
|
||||
Asserts the field edits to "A" - proving click-to-focus + typing + editing keys route to the
|
||||
focused field via ComposeRootHost.dispatchKeyEvent + the synthesised typed-key path. */
|
||||
private fun runKeyTest() {
|
||||
val vText = mutableStateOf("")
|
||||
nativeComposeWindow(
|
||||
title = "keytest",
|
||||
width = 400,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
when (frameIndex) {
|
||||
12 -> { com.compose.sdl.injectMouseEvent(1, 200f, 100f); true } // click field to focus
|
||||
14 -> { com.compose.sdl.injectMouseEvent(2, 200f, 100f); true }
|
||||
24 -> { com.compose.sdl.injectTextInput("A"); true }
|
||||
28 -> { com.compose.sdl.injectTextInput("B"); true }
|
||||
32 -> { com.compose.sdl.injectKey(42, true); com.compose.sdl.injectKey(42, false); true } // Backspace → "A"
|
||||
70 -> {
|
||||
println("keytest: real BasicTextField value='${vText.value}'")
|
||||
println(
|
||||
if (vText.value == "A") "keytest: PASS (click-to-focus + type 'AB' + backspace = 'A')"
|
||||
else "keytest: FAIL (expected 'A')"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
// The exact regression path: a real BasicTextField, focused by clicking it,
|
||||
// receiving typed text (SDL TEXT_INPUT) + editing keys (Backspace).
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
value = vText.value,
|
||||
onValueChange = { vText.value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a tall Column wrapped in the vendored Modifier.verticalScroll, injects several wheel-down
|
||||
events through the live SDL path (→ processor → MouseWheelScrollingLogic), and asserts the
|
||||
ScrollState offset advanced - proving upstream scrolling works end-to-end. */
|
||||
private fun runScrollTest() {
|
||||
val vScroll = androidx.compose.foundation.ScrollState(0)
|
||||
nativeComposeWindow(
|
||||
title = "scrolltest",
|
||||
width = 400,
|
||||
height = 300,
|
||||
onFrame = { _, frameIndex ->
|
||||
when {
|
||||
frameIndex in 20..40 && frameIndex % 2 == 0 -> {
|
||||
com.compose.sdl.injectWheel(200f, 150f, 0f, -3f) // wheel down
|
||||
true
|
||||
}
|
||||
frameIndex == 90 -> {
|
||||
println("scrolltest: ScrollState.value=${vScroll.value} maxValue=${vScroll.maxValue}")
|
||||
println(
|
||||
if (vScroll.value > 0) "scrolltest: PASS (scrolled to ${vScroll.value}px)"
|
||||
else "scrolltest: FAIL (did not scroll)"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(vScroll)) {
|
||||
repeat(40) { i ->
|
||||
Text("Scroll row $i - lorem ipsum dolor sit", color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.compose.sdl.ComposeNativeWindow
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
|
||||
/**
|
||||
`--winattrtest`: opens a window with the non-default Compose Desktop attributes
|
||||
set, then checks four things and exits -
|
||||
|
||||
1. the attributes reach SDL and read back off the facade,
|
||||
2. the raw SDL escape hatch hands out the pointers the resolved renderer owns,
|
||||
3. onPreviewKeyEvent sees a key BEFORE the focused content does,
|
||||
4. `enabled = false` drops input entirely.
|
||||
*/
|
||||
fun runWindowAttrTest() {
|
||||
var vPreviewSaw = 0
|
||||
var vKeySaw = 0
|
||||
var vFacade: ComposeNativeWindow? = null
|
||||
var vFrames = 0
|
||||
nativeComposeWindow(
|
||||
title = "attr probe",
|
||||
width = 420,
|
||||
height = 240,
|
||||
undecorated = true,
|
||||
resizable = false,
|
||||
alwaysOnTop = true,
|
||||
focusable = true,
|
||||
enabled = true,
|
||||
// Returning false lets the event continue down the chain, so this also
|
||||
// proves preview runs first without swallowing anything.
|
||||
onPreviewKeyEvent = { vPreviewSaw++; false },
|
||||
onKeyEvent = { vKeySaw++; false },
|
||||
onFrame = { _, _ ->
|
||||
vFrames++
|
||||
// Let the first frames settle so SDL has applied everything.
|
||||
if (vFrames < 3) return@nativeComposeWindow true
|
||||
if (vFrames == 3) {
|
||||
com.compose.sdl.injectKey(41, true) // Escape down
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
return@nativeComposeWindow true
|
||||
}
|
||||
if (vFrames == 4) {
|
||||
// Nothing is focused, so the content declines and BOTH window
|
||||
// handlers must have seen the key.
|
||||
println("KEYCHAIN preview=$vPreviewSaw onKey=$vKeySaw")
|
||||
// Now disable the window and inject again: neither may fire.
|
||||
vFacade?.setEnabled(false)
|
||||
com.compose.sdl.injectKey(41, true)
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
return@nativeComposeWindow true
|
||||
}
|
||||
if (vFrames == 5) {
|
||||
println("DISABLED preview=$vPreviewSaw onKey=$vKeySaw (must be unchanged)")
|
||||
vFacade?.setEnabled(true)
|
||||
}
|
||||
val vW = vFacade
|
||||
if (vW == null) {
|
||||
println("ATTR FAIL: window facade never captured")
|
||||
} else {
|
||||
println(
|
||||
"ATTR undecorated=${vW.isUndecorated} resizable=${vW.isResizable} " +
|
||||
"alwaysOnTop=${vW.isAlwaysOnTop} focusable=${vW.isFocusable} " +
|
||||
"enabled=${vW.isEnabled}"
|
||||
)
|
||||
val vRaw = vW.rawSdlHandles()
|
||||
println(
|
||||
"RAW gpu=${vW.gpuMode} window=${vRaw.window != null} " +
|
||||
"renderer=${vRaw.renderer != null} glContext=${vRaw.glContext != null} " +
|
||||
"metalView=${vRaw.metalView != null}"
|
||||
)
|
||||
}
|
||||
false
|
||||
},
|
||||
) {
|
||||
SideEffect { vFacade = window }
|
||||
Box(Modifier.fillMaxSize()) { Text("attr probe") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
import utils.encodeBmpBgra32
|
||||
import utils.writeFile
|
||||
|
||||
// ==================
|
||||
// MARK: Animation probes - shared transitions, AnimatedVisibility, dialog entry.
|
||||
// ==================
|
||||
//
|
||||
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
|
||||
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
|
||||
// argument dispatch still reaches it.
|
||||
|
||||
/** Drives a SharedTransitionLayout shared-element morph both directions from
|
||||
the frame counter (no clicks) - it requires the LOOKAHEAD pass, which dies
|
||||
with "LookaheadDelegate has not been measured yet" if the owner drops
|
||||
affectsLookahead measure/relayout requests. Reaching the end frame = PASS. */
|
||||
@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
|
||||
internal fun runSharedTest() {
|
||||
val vBig = mutableStateOf(false)
|
||||
nativeComposeWindow(
|
||||
title = "sharedtest",
|
||||
width = 300,
|
||||
height = 300,
|
||||
onFrame = { _, vFrame ->
|
||||
when (vFrame) {
|
||||
20 -> { vBig.value = true; true } // small → big morph
|
||||
70 -> { vBig.value = false; true } // big → small morph
|
||||
130 -> {
|
||||
println("sharedtest: PASS (shared-element morph ran both directions)")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
androidx.compose.animation.SharedTransitionLayout {
|
||||
Column {
|
||||
androidx.compose.animation.AnimatedVisibility(visible = !vBig.value) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key = "box"),
|
||||
animatedVisibilityScope = this@AnimatedVisibility,
|
||||
)
|
||||
.size(48.dp)
|
||||
.background(Color(0xFF7C4DFF), RoundedCornerShape(8.dp)),
|
||||
)
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(visible = vBig.value) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key = "box"),
|
||||
animatedVisibilityScope = this@AnimatedVisibility,
|
||||
)
|
||||
.size(160.dp)
|
||||
.background(Color(0xFF26A69A), RoundedCornerShape(24.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Frame-by-frame trace of AnimatedVisibility(fadeIn+expandVertically / fadeOut+
|
||||
shrinkVertically): logs the AV container's animated size and the window-Y of a
|
||||
marker Box below it every frame, toggling visibility three times. Diagnoses
|
||||
(a) a size snap between just-before-end and end of the animation and (b)
|
||||
instant (non-animated) transitions on subsequent toggles.
|
||||
|
||||
Expected healthy trace: ~24 smooth frames per toggle ending exactly at 0/60,
|
||||
PLUS one 16px marker jump when the fully-shrunk node unmounts (exit end) or
|
||||
mounts (enter start) - that's the parent's spacedBy(16) collapsing, inherent
|
||||
upstream behaviour (spacing applies to zero-height children too), NOT a bug. */
|
||||
internal fun runAnimVisTest() {
|
||||
val vShown = mutableStateOf(true)
|
||||
var vAvSize = androidx.compose.ui.unit.IntSize(-1, -1)
|
||||
var vMarkerY = -1f
|
||||
var vLastLog = ""
|
||||
nativeComposeWindow(
|
||||
title = "animvistest",
|
||||
width = 600,
|
||||
height = 500,
|
||||
onFrame = { _, vFrame ->
|
||||
// Log only when something moved - keeps the trace readable.
|
||||
val vLine = "size=$vAvSize markerY=$vMarkerY shown=${vShown.value}"
|
||||
if (vLine != vLastLog) {
|
||||
println("animvistest: f=$vFrame $vLine")
|
||||
vLastLog = vLine
|
||||
}
|
||||
when (vFrame) {
|
||||
60 -> { println("animvistest: === HIDE 1 ==="); vShown.value = false; true }
|
||||
140 -> { println("animvistest: === SHOW 2 ==="); vShown.value = true; true }
|
||||
220 -> { println("animvistest: === HIDE 3 ==="); vShown.value = false; true }
|
||||
300 -> false
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
// spacedBy mirrors the demo's Section column - the end-of-exit jump
|
||||
// reported on the FoundationExtra screen involves the spacing around
|
||||
// the AnimatedVisibility node collapsing when the node unmounts.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(Modifier.size(30.dp).background(androidx.compose.ui.graphics.Color(0xFFC07040)))
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = vShown.value,
|
||||
modifier = Modifier.onSizeChanged { vAvSize = it },
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.background(androidx.compose.ui.graphics.Color(0xFF7040C0)),
|
||||
) {
|
||||
Text("Animated content", modifier = Modifier.padding(14.dp))
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(androidx.compose.ui.graphics.Color(0xFF40C070))
|
||||
.onGloballyPositioned { vMarkerY = it.positionInRoot().y },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a window whose full-size surface opens an m3 AlertDialog on click (the real
|
||||
material3 → ui.window.Dialog path, default DialogProperties ⇒ animateTransition on),
|
||||
injects the click, then dumps screenshots during the 0.2s appearance animation and
|
||||
after it settles; then injects Escape and dumps mid-disappearance (0.1s reverse,
|
||||
via the popup host's exit deferral) + after. Visual check: mid shots must show the
|
||||
dialog semi-transparent, slightly scaled-down and shifted down vs the settled shot,
|
||||
and exit_end must show no dialog at all - skiko-parity animation both ways. */
|
||||
internal fun runDialogAnimTest() {
|
||||
fun snap(inBridge: com.compose.sdl.RenderBackend, inName: String) {
|
||||
val vSnap = inBridge.snapshotBgra() ?: return
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile(inName, encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("dialoganimtest: wrote $inName")
|
||||
}
|
||||
nativeComposeWindow(
|
||||
title = "dialoganimtest",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
when (vFrame) {
|
||||
30 -> { com.compose.sdl.injectMouseEvent(1, 500f, 350f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 500f, 350f); true }
|
||||
36 -> { snap(vBridge, "dialog_mid1.bmp"); true }
|
||||
40 -> { snap(vBridge, "dialog_mid2.bmp"); true }
|
||||
90 -> { snap(vBridge, "dialog_end.bmp"); true }
|
||||
92 -> {
|
||||
com.compose.sdl.injectKey(41, true) // Escape → dismiss
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
97 -> { snap(vBridge, "dialog_exit_mid.bmp"); true }
|
||||
140 -> { snap(vBridge, "dialog_exit_end.bmp"); false }
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
var vShow by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.clickable { vShow = true },
|
||||
)
|
||||
if (vShow) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { vShow = false },
|
||||
title = { Text("Animated dialog") },
|
||||
text = { Text("Appearance animation parity with the JVM (skiko) Dialog.") },
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { vShow = false }) { Text("OK") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
|
||||
// ==================
|
||||
// MARK: Input probes - pointer, keyboard, wheel, cursor and IME.
|
||||
// ==================
|
||||
//
|
||||
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
|
||||
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
|
||||
// argument dispatch still reaches it.
|
||||
|
||||
/** Boots a real nativeComposeWindow with a full-size clickable box, then injects
|
||||
move→press→release SDL mouse events a few frames apart (giving the upstream
|
||||
clickable's gesture coroutine time to launch + await between frames), and prints
|
||||
PASS/FAIL based on whether onClick fired. Proves the whole vendored interaction
|
||||
pipeline works under the real Sdl3MainDispatcher + frame loop. */
|
||||
internal fun runClickTest() {
|
||||
var vClicks = 0
|
||||
nativeComposeWindow(
|
||||
title = "clicktest",
|
||||
width = 400,
|
||||
height = 300,
|
||||
onFrame = { _, frameIndex ->
|
||||
when (frameIndex) {
|
||||
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true }
|
||||
26 -> { com.compose.sdl.injectMouseEvent(1, 200f, 150f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 150f); true }
|
||||
70 -> {
|
||||
println(
|
||||
if (vClicks > 0) "clicktest: PASS ($vClicks click(s) via upstream clickable)"
|
||||
else "clicktest: FAIL (0 clicks - upstream clickable did not fire)"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF303040))
|
||||
.clickable { vClicks++; println("clicktest: onClick fired -> $vClicks") },
|
||||
) {
|
||||
Text("Click test", color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Same injection harness as clicktest, but the target is a Material Switch (Modifier.toggleable
|
||||
from the vendored foundation.selection). Asserts onCheckedChange flipped the state - proving
|
||||
toggleable rides the same verified upstream interaction path as clickable. */
|
||||
internal fun runToggleTest() {
|
||||
var vChecked = false
|
||||
var vChanges = 0
|
||||
nativeComposeWindow(
|
||||
title = "toggletest",
|
||||
width = 400,
|
||||
height = 300,
|
||||
onFrame = { _, frameIndex ->
|
||||
when (frameIndex) {
|
||||
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true }
|
||||
26 -> { com.compose.sdl.injectMouseEvent(1, 200f, 150f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 150f); true }
|
||||
70 -> {
|
||||
println(
|
||||
if (vChanges > 0 && vChecked) "toggletest: PASS (switch toggled to $vChecked via toggleable)"
|
||||
else "toggletest: FAIL (changes=$vChanges checked=$vChecked)"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
androidx.compose.material3.Switch(
|
||||
checked = vChecked,
|
||||
onCheckedChange = { vChecked = it; vChanges++; println("toggletest: onCheckedChange -> $it") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a window with a real BasicTextField, clicks it to focus (focus-on-click via the
|
||||
FocusOwner), then injects TEXT_INPUT ("A","B") and a Backspace key through the live SDL path.
|
||||
Asserts the field edits to "A" - proving click-to-focus + typing + editing keys route to the
|
||||
focused field via ComposeRootHost.dispatchKeyEvent + the synthesised typed-key path. */
|
||||
internal fun runKeyTest() {
|
||||
val vText = mutableStateOf("")
|
||||
nativeComposeWindow(
|
||||
title = "keytest",
|
||||
width = 400,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
when (frameIndex) {
|
||||
12 -> { com.compose.sdl.injectMouseEvent(1, 200f, 100f); true } // click field to focus
|
||||
14 -> { com.compose.sdl.injectMouseEvent(2, 200f, 100f); true }
|
||||
24 -> { com.compose.sdl.injectTextInput("A"); true }
|
||||
28 -> { com.compose.sdl.injectTextInput("B"); true }
|
||||
32 -> { com.compose.sdl.injectKey(42, true); com.compose.sdl.injectKey(42, false); true } // Backspace → "A"
|
||||
70 -> {
|
||||
println("keytest: real BasicTextField value='${vText.value}'")
|
||||
println(
|
||||
if (vText.value == "A") "keytest: PASS (click-to-focus + type 'AB' + backspace = 'A')"
|
||||
else "keytest: FAIL (expected 'A')"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
// The exact regression path: a real BasicTextField, focused by clicking it,
|
||||
// receiving typed text (SDL TEXT_INPUT) + editing keys (Backspace).
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
value = vText.value,
|
||||
onValueChange = { vText.value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a tall Column wrapped in the vendored Modifier.verticalScroll, injects several wheel-down
|
||||
events through the live SDL path (→ processor → MouseWheelScrollingLogic), and asserts the
|
||||
ScrollState offset advanced - proving upstream scrolling works end-to-end. */
|
||||
internal fun runScrollTest() {
|
||||
val vScroll = androidx.compose.foundation.ScrollState(0)
|
||||
nativeComposeWindow(
|
||||
title = "scrolltest",
|
||||
width = 400,
|
||||
height = 300,
|
||||
onFrame = { _, frameIndex ->
|
||||
when {
|
||||
frameIndex in 20..40 && frameIndex % 2 == 0 -> {
|
||||
com.compose.sdl.injectWheel(200f, 150f, 0f, -3f) // wheel down
|
||||
true
|
||||
}
|
||||
frameIndex == 90 -> {
|
||||
println("scrolltest: ScrollState.value=${vScroll.value} maxValue=${vScroll.maxValue}")
|
||||
println(
|
||||
if (vScroll.value > 0) "scrolltest: PASS (scrolled to ${vScroll.value}px)"
|
||||
else "scrolltest: FAIL (did not scroll)"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(vScroll)) {
|
||||
repeat(40) { i ->
|
||||
Text("Scroll row $i - lorem ipsum dolor sit", color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** --cursortest: injects hover moves over pointerHoverIcon(Text) / (Hand) regions
|
||||
and a bare background, printing the applied SDL system cursor after each. Proves
|
||||
PointerIcon -> SDL_SetCursor end-to-end through the live hover pipeline. */
|
||||
internal fun runCursorTest() {
|
||||
var vBg = "?"; var vText = "?"; var vHand = "?"
|
||||
nativeComposeWindow(
|
||||
title = "cursortest",
|
||||
width = 400,
|
||||
height = 400,
|
||||
onFrame = { _, vFrame ->
|
||||
when (vFrame) {
|
||||
10 -> { com.compose.sdl.injectMouseEvent(0, 200f, 300f); true } // background (below both boxes)
|
||||
14 -> { vBg = com.compose.sdl.appliedCursorName(); true }
|
||||
20 -> { com.compose.sdl.injectMouseEvent(0, 200f, 50f); true } // Text box
|
||||
24 -> { vText = com.compose.sdl.appliedCursorName(); true }
|
||||
30 -> { com.compose.sdl.injectMouseEvent(0, 200f, 150f); true } // Hand box
|
||||
34 -> {
|
||||
vHand = com.compose.sdl.appliedCursorName()
|
||||
println("cursortest: background=$vBg text=$vText hand=$vHand")
|
||||
val vOk = vBg.endsWith("DEFAULT") && vText.endsWith("TEXT") && vHand.endsWith("POINTER")
|
||||
println(if (vOk) "cursortest: PASS" else "cursortest: FAIL")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
Box(Modifier.fillMaxWidth().height(100.dp).pointerHoverIcon(PointerIcon.Text))
|
||||
Box(Modifier.fillMaxWidth().height(100.dp).pointerHoverIcon(PointerIcon.Hand))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** --imetest: focuses a BasicTextField, injects an IME composition (SDL TEXT_EDITING
|
||||
"ni"), then a commit (SDL TEXT_INPUT "に"). Verifies the preedit shows a
|
||||
composing region and the commit REPLACES it (not appends) - the real IME path. */
|
||||
internal fun runImeTest() {
|
||||
val vField = mutableStateOf(androidx.compose.ui.text.input.TextFieldValue(""))
|
||||
nativeComposeWindow(
|
||||
title = "imetest",
|
||||
width = 500,
|
||||
height = 200,
|
||||
onFrame = { _, vFrame ->
|
||||
when (vFrame) {
|
||||
12 -> { com.compose.sdl.injectMouseEvent(1, 250f, 100f); true } // focus centered field
|
||||
14 -> { com.compose.sdl.injectMouseEvent(2, 250f, 100f); true }
|
||||
34 -> { com.compose.sdl.injectTextEditing("ni"); true } // composing (preedit)
|
||||
40 -> { println("imetest: composing text='${vField.value.text}' composition=${vField.value.composition}"); true }
|
||||
50 -> { com.compose.sdl.injectTextInput("に"); true } // commit "に"
|
||||
60 -> {
|
||||
println("imetest: committed text='${vField.value.text}' composition=${vField.value.composition}")
|
||||
val vOk = vField.value.text == "に" && vField.value.composition == null
|
||||
println(if (vOk) "imetest: PASS" else "imetest: FAIL")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
value = vField.value,
|
||||
onValueChange = { vField.value = it },
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 24.sp),
|
||||
cursorBrush = androidx.compose.ui.graphics.SolidColor(Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
import utils.encodeBmpBgra32
|
||||
import utils.writeFile
|
||||
|
||||
// ==================
|
||||
// MARK: Lifecycle probes - back navigation and Navigation3.
|
||||
// ==================
|
||||
//
|
||||
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
|
||||
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
|
||||
// argument dispatch still reaches it.
|
||||
|
||||
/** Boots a window with a BackHandler, injects an Escape key through the live SDL
|
||||
path, and asserts the handler fired - proving ComposeWindow's
|
||||
BackNavigationInput drives the NavigationEventDispatcher (the mechanism that
|
||||
collapses an expanded m3 SearchBar on Escape). */
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
internal fun runBackTest() {
|
||||
val vBackNoFocus = mutableStateOf(false)
|
||||
val vBackFocused = mutableStateOf(false)
|
||||
val vText = mutableStateOf("")
|
||||
nativeComposeWindow(
|
||||
title = "backtest",
|
||||
width = 400,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
when (frameIndex) {
|
||||
// Phase 1: Escape with NOTHING focused.
|
||||
20 -> {
|
||||
com.compose.sdl.injectKey(41, true) // SDL_SCANCODE_ESCAPE
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
// Phase 2: click the text field to focus it, then Escape -
|
||||
// the user-facing SearchBar scenario (field focused while
|
||||
// the back handler should collapse the bar).
|
||||
30 -> { com.compose.sdl.injectMouseEvent(1, 200f, 100f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 100f); true }
|
||||
44 -> {
|
||||
com.compose.sdl.injectKey(41, true)
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
70 -> {
|
||||
println("backtest: noFocus=${vBackNoFocus.value} focused=${vBackFocused.value}")
|
||||
println(
|
||||
if (vBackNoFocus.value && vBackFocused.value)
|
||||
"backtest: PASS (Escape completed back navigation with and without a focused field)"
|
||||
else
|
||||
"backtest: FAIL (noFocus=${vBackNoFocus.value} focusedField=${vBackFocused.value})"
|
||||
)
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
androidx.compose.ui.backhandler.BackHandler(enabled = true) {
|
||||
if (!vBackNoFocus.value) vBackNoFocus.value = true else vBackFocused.value = true
|
||||
}
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// The NEW state-based field - the one m3 SearchBar's InputField
|
||||
// uses - so the probe exercises its key handler, not the legacy
|
||||
// value-based one.
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
state = androidx.compose.foundation.text.input.rememberTextFieldState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun runNav3Test() {
|
||||
val vShow = mutableStateOf(false)
|
||||
nativeComposeWindow(
|
||||
title = "nav3test",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
when (vFrame) {
|
||||
30 -> { vShow.value = true; true }
|
||||
// Push Detail #1 (click its card) → per-entry lifecycle goes
|
||||
// CREATED/STARTED during the slide, RESUMED once settled.
|
||||
60 -> { com.compose.sdl.injectMouseEvent(1, 500f, 216f); true }
|
||||
62 -> { com.compose.sdl.injectMouseEvent(2, 500f, 216f); true }
|
||||
// Pop with ESC → ON_PAUSE, then CREATED while animating out,
|
||||
// then the entry disposes.
|
||||
120 -> {
|
||||
com.compose.sdl.injectKey(41, true)
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
180 -> {
|
||||
val vSnap = vBridge.snapshotBgra()
|
||||
if (vSnap != null) {
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile("nav3late.bmp", encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("nav3test: wrote nav3late.bmp (${vW}x${vH})")
|
||||
println("nav3test: PASS (Navigation3 composed at RESUMED; push + pop ran)")
|
||||
} else println("nav3test: FAIL (no snapshot)")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
if (vShow.value) {
|
||||
Box(modifier = Modifier.padding(24.dp)) { screens.Navigation3Screen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.decodeToImageBitmap
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
import demo.registry.allCategories
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.toKString
|
||||
|
||||
// ==================
|
||||
// MARK: Renderer probes - the soak run and encoded-image decoding.
|
||||
// ==================
|
||||
//
|
||||
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
|
||||
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
|
||||
// argument dispatch still reaches it.
|
||||
|
||||
/** P2.2 soak - cycle through EVERY registered screen kCycles times in ONE process,
|
||||
disposing each via a changing key() so composition + layer (skiko RenderNode)
|
||||
allocate-and-release is exercised repeatedly. After each full cycle, GC then
|
||||
record peak RSS (getrusage ru_maxrss). Peak RSS is monotonic, so after cycle 1 it
|
||||
already reflects visiting every screen once; if there is NO leak it plateaus, if
|
||||
there IS one it keeps climbing each cycle. PASS iff last-cycle peak stays within a
|
||||
ceiling of the first-cycle peak. */
|
||||
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class, ExperimentalForeignApi::class)
|
||||
internal fun runSoakTest() {
|
||||
// Disable never-settling animations so RSS reflects composition/layer lifetime,
|
||||
// not live animation-state churn (same seed the screenshot path uses).
|
||||
com.compose.sdl.disableInfiniteAnimations = true
|
||||
com.compose.sdl.useVirtualFrameTime = true
|
||||
val vAll = allCategories().flatMap { it.screens }
|
||||
// CDN_SOAK_SCREEN=<name> repeats ONE screen 40x/cycle (bisect a specific screen);
|
||||
// default cycles through every screen.
|
||||
val vTarget = platform.posix.getenv("CDN_SOAK_SCREEN")?.toKString()
|
||||
val vScreens = if (vTarget != null) { val vS = vAll.first { it.name.equals(vTarget, ignoreCase = true) }; List(40) { vS } }
|
||||
else vAll
|
||||
val vIndex = mutableStateOf(0)
|
||||
// CDN_SOAK_STATIC=1: mount ONE screen once, never remount - measure RSS every 120 frames.
|
||||
// Isolates a per-FRAME leak (RSS climbs with no remounts) from a per-MOUNT leak.
|
||||
val vStatic = platform.posix.getenv("CDN_SOAK_STATIC")?.toKString() == "1"
|
||||
val kFramesPerScreen = if (vStatic) 120 else 3
|
||||
val kCycles = platform.posix.getenv("CDN_SOAK_CYCLES")?.toKString()?.toIntOrNull() ?: 3
|
||||
val vRssPerCycleMb = mutableListOf<Long>()
|
||||
var vShown = 0
|
||||
|
||||
nativeComposeWindow(
|
||||
title = "soaktest",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { _, vFrame ->
|
||||
if (vFrame > 0 && vFrame % kFramesPerScreen == 0) {
|
||||
if (vStatic) {
|
||||
// No remount - just pump frames on the one mounted screen.
|
||||
kotlin.native.runtime.GC.collect()
|
||||
vRssPerCycleMb.add(currentResidentMb())
|
||||
println("soaktest[static]: measure ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
|
||||
} else {
|
||||
vShown++
|
||||
vIndex.value = vShown % vScreens.size
|
||||
if (vShown % vScreens.size == 0) {
|
||||
kotlin.native.runtime.GC.collect()
|
||||
vRssPerCycleMb.add(currentResidentMb())
|
||||
println("soaktest: cycle ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vRssPerCycleMb.size >= kCycles) {
|
||||
val vFirst = vRssPerCycleMb.first()
|
||||
val vLast = vRssPerCycleMb.last()
|
||||
val vGrowth = vLast - vFirst
|
||||
val vCeiling = maxOf(48L, vFirst / 4) // 25% or 48MB, whichever larger
|
||||
println("soaktest: currentRSS/cycle(MB)=$vRssPerCycleMb (${vScreens.size} screens x $kCycles cycles)")
|
||||
if (vGrowth <= vCeiling) {
|
||||
println("soaktest: PASS (current RSS grew ${vGrowth}MB over cycles 1->$kCycles, within ${vCeiling}MB ceiling)")
|
||||
} else {
|
||||
println("soaktest: FAIL (current RSS grew ${vGrowth}MB > ${vCeiling}MB ceiling - possible leak)")
|
||||
}
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
val vScroll = rememberScrollState()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(vScroll)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
// key(vShown) forces a FULL dispose+recompose each advance (even when the
|
||||
// target screen repeats) - the allocate/release churn we soak. Reading
|
||||
// vIndex.value (a State) is what triggers the recomposition each mount.
|
||||
key(vShown) {
|
||||
vScreens[vIndex.value].content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CURRENT resident set (MB): current RSS can DROP after GC, so it distinguishes a
|
||||
true leak (ratchets up) from K/N allocator high-water, unlike getrusage's
|
||||
monotonic peak. posix reads it from `ps`; mingw has no `ps`/popen and returns
|
||||
-1 (the soak gate runs on macOS/Linux - see scripts/verify-mac.sh). */
|
||||
internal expect fun currentResidentMb(): Long
|
||||
|
||||
/** --imagebytestest: decodes an in-memory 2x2 BMP via ByteArray.decodeToImageBitmap()
|
||||
(routes to createImageBitmap(bytes)) through Skia's image decoder. Needs a window
|
||||
so the render backend installs the encoded-image decoder. */
|
||||
internal fun runImageBytesTest() {
|
||||
nativeComposeWindow(
|
||||
title = "imagebytestest",
|
||||
width = 200,
|
||||
height = 200,
|
||||
onFrame = { _, vFrame ->
|
||||
if (vFrame >= 2) {
|
||||
try {
|
||||
val vBitmap = tinyRedBmp().decodeToImageBitmap()
|
||||
println("imagebytestest: decoded ${vBitmap.width}x${vBitmap.height}")
|
||||
println(if (vBitmap.width == 2 && vBitmap.height == 2) "imagebytestest: PASS" else "imagebytestest: FAIL (wrong size)")
|
||||
} catch (e: Throwable) {
|
||||
println("imagebytestest: FAIL (${e::class.simpleName}: ${e.message})")
|
||||
}
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {}
|
||||
}
|
||||
|
||||
// A minimal 2x2 24bpp red BMP (no compression) - valid decoder input.
|
||||
internal fun tinyRedBmp(): ByteArray {
|
||||
fun le16(v: Int) = byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte())
|
||||
fun le32(v: Int) = byteArrayOf(
|
||||
(v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(),
|
||||
((v shr 16) and 0xFF).toByte(), ((v shr 24) and 0xFF).toByte(),
|
||||
)
|
||||
val vPixel = byteArrayOf(0, 0, 0xFF.toByte()) // BGR red
|
||||
val vRow = vPixel + vPixel + byteArrayOf(0, 0) // 2px + 2 pad = 8 bytes (4-byte aligned)
|
||||
val vPixels = vRow + vRow // 2 rows = 16 bytes
|
||||
val vFileHeader = byteArrayOf('B'.code.toByte(), 'M'.code.toByte()) + le32(70) + le32(0) + le32(54)
|
||||
val vDib = le32(40) + le32(2) + le32(2) + le16(1) + le16(24) + le32(0) + le32(16) +
|
||||
le32(2835) + le32(2835) + le32(0) + le32(0)
|
||||
return vFileHeader + vDib + vPixels
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
import utils.encodeBmpBgra32
|
||||
import utils.writeFile
|
||||
|
||||
// ==================
|
||||
// MARK: Text probes - font resolution, paragraph layout, metrics.
|
||||
// ==================
|
||||
//
|
||||
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
|
||||
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
|
||||
// argument dispatch still reaches it.
|
||||
|
||||
/** --fonttest: same sample in FontFamily.Default (sans) and FontFamily.Monospace.
|
||||
Monospace used to collapse to the default sans; now it renders NotoSansMono
|
||||
(bundled because this source references FontFamily.Monospace). */
|
||||
internal fun runFontTest() {
|
||||
nativeComposeWindow(
|
||||
title = "fonttest",
|
||||
width = 560,
|
||||
height = 220,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
if (vFrame >= 10) {
|
||||
val vSnap = vBridge.snapshotBgra()
|
||||
if (vSnap != null) {
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile("fonttest.bmp", encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("fonttest: wrote fonttest.bmp (${vW}x${vH})")
|
||||
}
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Column(Modifier.fillMaxSize().background(Color(0xFF202020)).padding(20.dp)) {
|
||||
val vSample = "Illegal1 lIO0 {}=>"
|
||||
Text(vSample, color = Color.White, fontSize = 30.sp)
|
||||
Text(
|
||||
vSample, color = Color.Cyan, fontSize = 30.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a real window, then builds an upstream Paragraph via the
|
||||
vendored factory (→ SkiaParagraph) for a long string constrained to a narrow width. Verifies it
|
||||
wrapped to multiple lines, has positive size, and that getHorizontalPosition/getOffsetForPosition
|
||||
round-trip - proving the paragraph-engine measurement bridge works. */
|
||||
internal fun runParagraphTest() {
|
||||
nativeComposeWindow(
|
||||
title = "paragraphtest",
|
||||
width = 400,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
if (frameIndex == 10) {
|
||||
val vP = androidx.compose.ui.text.Paragraph(
|
||||
text = "Hello world foo bar baz qux quux corge grault garply waldo",
|
||||
style = androidx.compose.ui.text.TextStyle(fontSize = 16.sp),
|
||||
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 120),
|
||||
density = androidx.compose.ui.unit.Density(1f),
|
||||
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
|
||||
)
|
||||
val vHpos = vP.getHorizontalPosition(3, true)
|
||||
val vOffBack = vP.getOffsetForPosition(androidx.compose.ui.geometry.Offset(vHpos, 2f))
|
||||
val vLine = vP.getLineForOffset(30)
|
||||
println("paragraphtest: lineCount=${vP.lineCount} w=${vP.width} h=${vP.height} hpos(3)=$vHpos offBack=$vOffBack lineFor(30)=$vLine")
|
||||
val vPass = vP.lineCount >= 2 && vP.height > 0f && vP.width > 0f && vOffBack in 2..4
|
||||
println(if (vPass) "paragraphtest: PASS (real width-wrapped Paragraph via SkiaParagraph)" else "paragraphtest: FAIL")
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Prints paragraph metrics (single-line cell, lineHeight-styled single + triple line,
|
||||
first baseline) for a font-size sweep at density 1 - the native half of the
|
||||
metrics-alignment probe. Mirror of MainJvm's `--metrics`; both must print the same
|
||||
numbers for the parity text drift to vanish. */
|
||||
internal fun runMetricsProbe() {
|
||||
fun paragraph(inText: String, inSize: Int, inLineHeight: Int?, inM3Style: Boolean): androidx.compose.ui.text.Paragraph =
|
||||
androidx.compose.ui.text.Paragraph(
|
||||
text = inText,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = inSize.sp,
|
||||
lineHeight = inLineHeight?.sp ?: androidx.compose.ui.unit.TextUnit.Unspecified,
|
||||
lineHeightStyle = if (inM3Style) {
|
||||
androidx.compose.ui.text.style.LineHeightStyle(
|
||||
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
|
||||
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
|
||||
)
|
||||
} else null,
|
||||
),
|
||||
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 10_000),
|
||||
density = androidx.compose.ui.unit.Density(1f),
|
||||
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
|
||||
)
|
||||
nativeComposeWindow(
|
||||
title = "metricsprobe",
|
||||
width = 300,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
if (frameIndex == 10) {
|
||||
for (vSize in listOf(11, 12, 14, 16, 22, 24)) {
|
||||
val vLh = vSize + 6
|
||||
val vCell = paragraph("Hg", vSize, null, false)
|
||||
val vOne = paragraph("Hg", vSize, vLh, false)
|
||||
val vThree = paragraph("Hg\nHg\nHg", vSize, vLh, false)
|
||||
val vOneM3 = paragraph("Hg", vSize, vLh, true)
|
||||
val vThreeM3 = paragraph("Hg\nHg\nHg", vSize, vLh, true)
|
||||
println(
|
||||
"metrics: size=$vSize lh=$vLh cell=${vCell.height} " +
|
||||
"one=${vOne.height} three=${vThree.height} " +
|
||||
"base1=${vOne.firstBaseline} base3=${vThree.lastBaseline} " +
|
||||
"oneM3=${vOneM3.height} threeM3=${vThreeM3.height} base1M3=${vOneM3.firstBaseline}"
|
||||
)
|
||||
}
|
||||
for ((vS, vL) in listOf(24 to 24, 24 to 25, 32 to 24, 16 to 16)) {
|
||||
val vB = paragraph("Hg", vS, vL, true)
|
||||
println("metrics: boundary $vS/$vL m3=${vB.height} base=${vB.firstBaseline}")
|
||||
}
|
||||
val vBig = paragraph("42", 48, 24, false)
|
||||
val vBigM3 = paragraph("42", 48, 24, true)
|
||||
println("metrics: big48/lh24 raw=${vBig.height} base=${vBig.firstBaseline} m3=${vBigM3.height} baseM3=${vBigM3.firstBaseline}")
|
||||
println("metricsprobe: DONE")
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: clicktest
|
||||
// ==================
|
||||
@@ -0,0 +1,188 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.compose.sdl.Window
|
||||
import com.compose.sdl.nativeComposeApp
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
import utils.encodeBmpBgra32
|
||||
import utils.writeFile
|
||||
|
||||
// ==================
|
||||
// MARK: Window probes - multi-window lifecycle, window info, locale.
|
||||
// ==================
|
||||
//
|
||||
// Split out of MainNative.kt, which held the entry point plus 20 scenarios in
|
||||
// 1240 lines. Behaviour is unchanged; each probe is `internal` so main()'s
|
||||
// argument dispatch still reaches it.
|
||||
|
||||
/** Boots TWO windows via nativeComposeApp, asserts both render, closes the
|
||||
second by flipping the state that composes its Window(), and asserts the app
|
||||
keeps running on the first - the multi-window lifecycle end-to-end. */
|
||||
internal fun runMultiWindowTest() {
|
||||
val vShowSecond = mutableStateOf(true)
|
||||
var vSecondFrames = 0
|
||||
var vResult = "FAIL (main window never reached the end frame)"
|
||||
nativeComposeApp {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "multiwin main",
|
||||
width = 400,
|
||||
height = 200,
|
||||
onFrame = { _, vFrame ->
|
||||
when (vFrame) {
|
||||
40 -> {
|
||||
if (vSecondFrames == 0) {
|
||||
vResult = "FAIL (second window never rendered)"
|
||||
false
|
||||
} else {
|
||||
vShowSecond.value = false // close the second window via state
|
||||
true
|
||||
}
|
||||
}
|
||||
80 -> {
|
||||
vResult =
|
||||
if (!vShowSecond.value && vSecondFrames > 0)
|
||||
"PASS (both windows rendered; second closed via state; app survived on the first)"
|
||||
else "FAIL (second=$vSecondFrames showSecond=${vShowSecond.value})"
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("main window", color = Color.White)
|
||||
}
|
||||
if (vShowSecond.value) {
|
||||
Window(
|
||||
onCloseRequest = { vShowSecond.value = false },
|
||||
title = "multiwin second",
|
||||
width = 300,
|
||||
height = 150,
|
||||
onFrame = { _, _ -> vSecondFrames++; true },
|
||||
) {
|
||||
Text("second window", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
println("multiwintest: secondFrames=$vSecondFrames")
|
||||
println("multiwintest: $vResult")
|
||||
}
|
||||
|
||||
/** --windowinfotest: prints LocalWindowInfo.isWindowFocused / containerSize /
|
||||
containerDpSize. containerSize starts Zero and becomes the window pixel size
|
||||
after the first measure - proof it's fed from the live root constraints. */
|
||||
internal fun runWindowInfoTest() {
|
||||
nativeComposeWindow(
|
||||
title = "windowinfotest",
|
||||
width = 400,
|
||||
height = 300,
|
||||
onFrame = { _, vFrame -> vFrame < 6 },
|
||||
) {
|
||||
val vWi = androidx.compose.ui.platform.LocalWindowInfo.current
|
||||
androidx.compose.runtime.LaunchedEffect(vWi.containerSize, vWi.isWindowFocused) {
|
||||
println("windowinfotest: focused=${vWi.isWindowFocused} containerSize=${vWi.containerSize} containerDpSize=${vWi.containerDpSize}")
|
||||
}
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots the REAL Search screen, clicks the first SearchBar's input field
|
||||
(expands it), presses Escape, and writes esc_before.bmp / esc_after.bmp -
|
||||
the expanded overlay must be visible in `before` and gone in `after`. */
|
||||
internal fun runSearchEscTest() {
|
||||
fun snap(inBridge: com.compose.sdl.RenderBackend, inName: String) {
|
||||
val vSnap = inBridge.snapshotBgra() ?: return
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile(inName, encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("searchesctest: wrote $inName")
|
||||
}
|
||||
nativeComposeWindow(
|
||||
title = "searchesctest",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
when (vFrame) {
|
||||
30 -> { com.compose.sdl.injectMouseEvent(1, 200f, 230f); true }
|
||||
32 -> { com.compose.sdl.injectMouseEvent(2, 200f, 230f); true }
|
||||
70 -> { snap(vBridge, "esc_before.bmp"); true }
|
||||
80 -> {
|
||||
com.compose.sdl.injectKey(41, true) // Escape
|
||||
com.compose.sdl.injectKey(41, false)
|
||||
true
|
||||
}
|
||||
130 -> { snap(vBridge, "esc_after.bmp"); false }
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
val vScroll = rememberScrollState()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(vScroll)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
screens.M3SearchScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Boots an empty window, then composes screens.Navigation3Screen() at frame 30 -
|
||||
AFTER the window lifecycle reached RESUMED - mirroring the real sidebar flow.
|
||||
Crashes here (e.g. enableSavedStateHandles' INITIALIZED/CREATED contract) never
|
||||
reproduce under --screen, which composes during the initial CREATED composition.
|
||||
PASS = a screenshot gets written and the app exits cleanly. */
|
||||
|
||||
/** --localetest: prints Locale.current / LocaleList.current (should reflect the OS
|
||||
preferred locales via SDL) and screenshots an M3 DatePicker, whose headline and
|
||||
navigation labels are M3-translated by Locale.current. Run under a forced locale,
|
||||
e.g. `demo.kexe --localetest -AppleLanguages "(fr-FR)"` on macOS. */
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
internal fun runLocaleTest() {
|
||||
nativeComposeWindow(
|
||||
title = "localetest",
|
||||
width = 420,
|
||||
height = 560,
|
||||
onFrame = { vBridge, vFrame ->
|
||||
when (vFrame) {
|
||||
2 -> {
|
||||
val vCur = androidx.compose.ui.text.intl.Locale.current
|
||||
val vList = androidx.compose.ui.text.intl.LocaleList.current
|
||||
println("localetest: Locale.current=${vCur.toLanguageTag()}")
|
||||
println("localetest: LocaleList.current=[${vList.localeList.joinToString { it.toLanguageTag() }}]")
|
||||
true
|
||||
}
|
||||
40 -> {
|
||||
val vSnap = vBridge.snapshotBgra()
|
||||
if (vSnap != null) {
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
writeFile("localetest.bmp", encodeBmpBgra32(vW, vH, vBgra))
|
||||
println("localetest: wrote localetest.bmp (${vW}x${vH})")
|
||||
} else println("localetest: FAIL (no snapshot)")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val vState = androidx.compose.material3.rememberDatePickerState()
|
||||
androidx.compose.material3.DatePicker(state = vState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ import com.compose.sdl.LocalComposeNativeWindow
|
||||
own id, so closing one removes exactly THAT id (a count would always drop the
|
||||
last-declared window, closing the wrong one). */
|
||||
val ExtraWindows = mutableStateListOf<Int>()
|
||||
private var fNextExtraWindowId = 1
|
||||
private var mNextExtraWindowId = 1
|
||||
|
||||
fun openExtraWindow() {
|
||||
ExtraWindows.add(fNextExtraWindowId++)
|
||||
ExtraWindows.add(mNextExtraWindowId++)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.compose.sdl.GpuMode
|
||||
|
||||
/** Parsed view of the demo's command line.
|
||||
|
||||
--gpu=auto | software | skia.metal | skia.opengl (default: auto)
|
||||
--gpu=auto | cpu | metal | opengl (default: auto)
|
||||
--screen=Buttons | TextField | ... (default: full app w/ sidebar)
|
||||
--screenshot=path.bmp capture at quiescence and quit
|
||||
--width=W --height=H (default 1000 / 700)
|
||||
@@ -25,15 +25,14 @@ internal data class CliArgs(
|
||||
val maxFrames: Int = 300,
|
||||
)
|
||||
|
||||
/** Translates the --gpu= string to the right GpuMode sealed instance.
|
||||
Accepts both dotted (`skia.metal`) and dashed (`skia-metal`) forms,
|
||||
plus the bare driver names (`metal`, `opengl`, …) as Skia aliases
|
||||
for backwards compatibility. */
|
||||
/** Translates the --gpu= string to a GpuMode. Accepts the bare driver names
|
||||
(`metal`, `opengl`, `cpu`) plus the retired `skia.*` / `skia-*` spellings, so
|
||||
existing scripts and docs keep working after GpuMode was flattened. */
|
||||
private fun parseGpu(inValue: String): GpuMode = when (inValue.lowercase().replace('-', '.')) {
|
||||
"auto" -> GpuMode.Auto
|
||||
"none", "cpu", "software" -> GpuMode.Software
|
||||
"metal", "skia.metal" -> GpuMode.Skia.Metal
|
||||
"opengl", "gl", "skia.opengl" -> GpuMode.Skia.OpenGL
|
||||
"none", "cpu", "software", "raster" -> GpuMode.CpuRaster
|
||||
"metal", "skia.metal" -> GpuMode.Metal
|
||||
"opengl", "gl", "skia.opengl" -> GpuMode.OpenGL
|
||||
else -> {
|
||||
println("Unknown --gpu=$inValue, using auto")
|
||||
GpuMode.Auto
|
||||
|
||||
+18
-1
@@ -10,6 +10,7 @@ import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.work.DisableCachingByDefault
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.io.File
|
||||
import java.util.zip.Inflater
|
||||
@@ -91,10 +92,23 @@ internal fun installAppIcon(inProject: Project, inExt: ComposeDesktopNativeExten
|
||||
}
|
||||
// Link the object last: the mingw link tasks must run windres first (the
|
||||
// path is referenced via linkerOpts, added reflectively in NativeApplication).
|
||||
//
|
||||
// `inputs.files(task)` rather than a bare `dependsOn`: dependsOn only orders
|
||||
// the two, it does NOT make the object's CONTENT part of the link's up-to-date
|
||||
// check. The .o path reaches the linker as a linkerOpts STRING, which is an
|
||||
// input only as a string - so editing an icon PNG rebuilt the .ico and the .o
|
||||
// and then left the link UP-TO-DATE, silently shipping an .exe with the OLD
|
||||
// icon embedded. Declaring the task's outputs as link inputs carries the
|
||||
// dependency AND invalidates on content.
|
||||
inProject.tasks.matching {
|
||||
it.name == "linkDebugExecutableMingwX64" || it.name == "linkReleaseExecutableMingwX64" ||
|
||||
it.name == "runDebugExecutableMingwX64" || it.name == "runReleaseExecutableMingwX64"
|
||||
}.configureEach { it.dependsOn(vWindresTask) }
|
||||
}.configureEach { task ->
|
||||
task.inputs.files(vWindresTask)
|
||||
.withPropertyName("composeNativeAppIconObject")
|
||||
.withPathSensitivity(PathSensitivity.NONE)
|
||||
task.dependsOn(vWindresTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +116,7 @@ internal fun installAppIcon(inProject: Project, inExt: ComposeDesktopNativeExten
|
||||
// MARK: Tasks
|
||||
// ==================
|
||||
|
||||
@DisableCachingByDefault(because = "trivial PNG decode; caching would cost more than it saves")
|
||||
abstract class GenerateRgbaIconsTask : DefaultTask() {
|
||||
@get:InputFiles
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
@@ -124,6 +139,7 @@ abstract class GenerateRgbaIconsTask : DefaultTask() {
|
||||
}
|
||||
}
|
||||
|
||||
@DisableCachingByDefault(because = "trivial byte concatenation of already-local PNGs")
|
||||
abstract class GenerateIcoTask : DefaultTask() {
|
||||
@get:InputFiles
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
@@ -140,6 +156,7 @@ abstract class GenerateIcoTask : DefaultTask() {
|
||||
}
|
||||
}
|
||||
|
||||
@DisableCachingByDefault(because = "shells out to the host windres; not portable across machines")
|
||||
abstract class CompileWindowsIconResourceTask : DefaultTask() {
|
||||
@get:InputFiles
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
|
||||
+21
@@ -88,6 +88,26 @@ private fun registerDataKresTasks(project: Project) {
|
||||
val compress = project.providers.gradleProperty("compressResources").orNull?.toBoolean() ?: false
|
||||
task.entryCompression = if (compress) ZipEntryCompression.DEFLATED else ZipEntryCompression.STORED
|
||||
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
// MULTI-MODULE: source from Kotlin's per-target resource AGGREGATE, which
|
||||
// already merges this project's OWN composeResources with those of every
|
||||
// dependency module (each under its own `composeResources/<package>/`).
|
||||
// Reading the project's `preparedResources/` instead - as this did - silently
|
||||
// dropped every library module's resources from data.kres, and forced a
|
||||
// single `resPackage` onto the lot, which is wrong the moment more than one
|
||||
// module contributes.
|
||||
//
|
||||
// The aggregate directory is already rooted at `composeResources/`, so it is
|
||||
// copied in with NO `into()` remapping.
|
||||
val aggregateName = "${target.replaceFirstChar { it.lowercase() }}AggregateResources"
|
||||
if (aggregateName in project.tasks.names) {
|
||||
val aggregated = project.layout.buildDirectory.dir(
|
||||
"kotlin-multiplatform-resources/aggregated-resources/${target.replaceFirstChar { it.lowercase() }}"
|
||||
)
|
||||
task.from(aggregated)
|
||||
task.dependsOn(project.tasks.named(aggregateName))
|
||||
} else {
|
||||
// Fallback for Kotlin versions without the resources-aggregation tasks:
|
||||
// this project's own resources only (the pre-existing behaviour).
|
||||
val resPackage = project.resolveResourcePackage()
|
||||
val prepareNames = sourceSets
|
||||
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
|
||||
@@ -102,6 +122,7 @@ private fun registerDataKresTasks(project: Project) {
|
||||
task.dependsOn(project.tasks.named(prepareName))
|
||||
}
|
||||
}
|
||||
}
|
||||
project.tasks.matching { it.name == linkName }.configureEach { it.dependsOn(zipTask) }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import org.gradle.api.tasks.Copy
|
||||
//
|
||||
// Text rendering goes through Skia's skparagraph (HarfBuzz + skunicode). On
|
||||
// Windows, Skia loads its ICU data (icudtl.dat) from a file next to the binary at
|
||||
// runtime - without it ParagraphBuilder fatal-aborts (check(fUnicode)). Unlike
|
||||
// runtime - without it ParagraphBuilder fatal-aborts (check(mUnicode)). Unlike
|
||||
// macOS/Linux skiko, which bake the ICU data in, the Windows build ships it as a
|
||||
// sidecar. The official skiko-awt-runtime-windows-x64 jar already contains a
|
||||
// compatible icudtl.dat (same Skia base as the fork), so we source it from there
|
||||
|
||||
Reference in New Issue
Block a user